284 lines
11 KiB
Python
284 lines
11 KiB
Python
# Copyright 2026 Criptomart
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
from odoo import api
|
|
from odoo import fields
|
|
from odoo import models
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class StockPickingBatch(models.Model):
|
|
_inherit = "stock.picking.batch"
|
|
|
|
summary_line_ids = fields.One2many(
|
|
comodel_name="stock.picking.batch.summary.line",
|
|
inverse_name="batch_id",
|
|
string="Product Summary",
|
|
compute="_compute_summary_line_ids",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
|
|
@api.depends(
|
|
"move_line_ids.move_id.product_id",
|
|
"move_line_ids.move_id.product_uom_qty",
|
|
"move_line_ids.move_id.quantity",
|
|
"move_line_ids.move_id.product_uom",
|
|
"move_line_ids.move_id.state",
|
|
)
|
|
def _compute_summary_line_ids(self):
|
|
"""Aggregate move quantities per product and keep collected flag.
|
|
|
|
- Demand: move.product_uom_qty converted to product UoM.
|
|
- Done: move.quantity converted to product UoM.
|
|
- Pending: demand - done.
|
|
- Keep is_collected value if product already present.
|
|
- Skip cancelled moves.
|
|
"""
|
|
|
|
for batch in self:
|
|
# Base: limpiar si no hay líneas
|
|
if not batch.move_line_ids:
|
|
batch.summary_line_ids = [fields.Command.clear()]
|
|
continue
|
|
|
|
previous_collected = {
|
|
line.product_id.id: line.is_collected
|
|
for line in batch.summary_line_ids
|
|
if line.product_id
|
|
}
|
|
# Use regular dict to avoid accidental creation of empty entries
|
|
aggregates = {}
|
|
|
|
# Demand per move (count once per move)
|
|
for move in batch.move_ids:
|
|
if move.state == "cancel" or not move.product_id:
|
|
continue
|
|
product = move.product_id
|
|
product_uom = product.uom_id
|
|
demand = move.product_uom._compute_quantity(
|
|
move.product_uom_qty, product_uom, rounding_method="HALF-UP"
|
|
)
|
|
if product.id not in aggregates:
|
|
aggregates[product.id] = {
|
|
"product_id": product.id,
|
|
"product_uom_id": product_uom.id,
|
|
"qty_demanded": 0.0,
|
|
"qty_done": 0.0,
|
|
}
|
|
aggregates[product.id]["qty_demanded"] += demand
|
|
|
|
# Done per move line
|
|
for line in batch.move_line_ids:
|
|
move = line.move_id
|
|
if move and move.state == "cancel":
|
|
continue
|
|
product = line.product_id
|
|
if not product:
|
|
continue
|
|
product_uom = product.uom_id
|
|
done = line.product_uom_id._compute_quantity(
|
|
line.quantity, product_uom, rounding_method="HALF-UP"
|
|
)
|
|
if product.id not in aggregates:
|
|
aggregates[product.id] = {
|
|
"product_id": product.id,
|
|
"product_uom_id": product_uom.id,
|
|
"qty_demanded": 0.0,
|
|
"qty_done": 0.0,
|
|
}
|
|
aggregates[product.id]["qty_done"] += done
|
|
|
|
commands = []
|
|
products_in_totals = set()
|
|
existing_by_product = {
|
|
line.product_id.id: line for line in batch.summary_line_ids
|
|
}
|
|
|
|
for product_id, values in aggregates.items():
|
|
# Double-check: skip if product_id is not valid
|
|
if not product_id or not values.get("product_id"):
|
|
continue
|
|
products_in_totals.add(product_id)
|
|
existing_line = existing_by_product.get(product_id)
|
|
is_collected = previous_collected.get(product_id, False)
|
|
if existing_line:
|
|
commands.append(
|
|
fields.Command.update(
|
|
existing_line.id,
|
|
{
|
|
"product_uom_id": values["product_uom_id"],
|
|
"qty_demanded": values["qty_demanded"],
|
|
"qty_done": values["qty_done"],
|
|
"is_collected": is_collected,
|
|
},
|
|
)
|
|
)
|
|
else:
|
|
# Ensure all required fields are present before create
|
|
if values["product_id"] and values["product_uom_id"]:
|
|
commands.append(
|
|
fields.Command.create(
|
|
{
|
|
"product_id": values["product_id"],
|
|
"product_uom_id": values["product_uom_id"],
|
|
"qty_demanded": values["qty_demanded"],
|
|
"qty_done": values["qty_done"],
|
|
"is_collected": is_collected,
|
|
}
|
|
)
|
|
)
|
|
|
|
obsolete_lines = [
|
|
fields.Command.unlink(line.id)
|
|
for line in batch.summary_line_ids
|
|
if line.product_id.id not in products_in_totals
|
|
]
|
|
|
|
if commands or obsolete_lines:
|
|
batch.summary_line_ids = commands + obsolete_lines
|
|
else:
|
|
batch.summary_line_ids = [fields.Command.clear()]
|
|
|
|
def _raise_collected_restriction(self, base_message, product_names):
|
|
message = self.env._(base_message)
|
|
if product_names:
|
|
message += "\n" + self.env._(
|
|
"Pending products: %(products)s", products=product_names
|
|
)
|
|
raise UserError(message)
|
|
|
|
def _get_not_collected_summary_lines(self, batch_pickings, scope):
|
|
summary_lines = self.summary_line_ids
|
|
if scope == "processed":
|
|
processed_product_ids = set(
|
|
batch_pickings.move_line_ids.filtered(
|
|
lambda line: (
|
|
line.move_id.state not in ("cancel", "done")
|
|
and line.product_id
|
|
and line.move_id.quantity > 0
|
|
)
|
|
).mapped("product_id.id")
|
|
)
|
|
summary_lines = summary_lines.filtered(
|
|
lambda line: line.product_id.id in processed_product_ids
|
|
)
|
|
return summary_lines.filtered(lambda line: not line.is_collected)
|
|
|
|
def _get_not_collected_detailed_lines(self, batch_pickings, scope):
|
|
detailed_lines = batch_pickings.move_line_ids.filtered(
|
|
lambda line: line.move_id.state not in ("cancel", "done")
|
|
and line.product_id
|
|
)
|
|
if scope == "processed":
|
|
detailed_lines = detailed_lines.filtered(
|
|
lambda line: line.move_id.quantity > 0
|
|
)
|
|
return detailed_lines.filtered(lambda line: not line.is_collected)
|
|
|
|
def _check_all_products_collected(self, pickings=None):
|
|
"""Validate collected restrictions based on tab configuration."""
|
|
|
|
for batch in self:
|
|
company = batch.company_id or self.env.company
|
|
batch_pickings = (
|
|
pickings.filtered(lambda p, batch=batch: p.batch_id == batch)
|
|
if pickings
|
|
else batch.picking_ids
|
|
)
|
|
if not batch_pickings:
|
|
continue
|
|
|
|
if company.batch_detailed_restriction_enabled:
|
|
not_collected_detailed = batch._get_not_collected_detailed_lines(
|
|
batch_pickings, company.batch_detailed_restriction_scope
|
|
)
|
|
if not_collected_detailed:
|
|
product_names = ", ".join(
|
|
sorted(
|
|
set(
|
|
not_collected_detailed.mapped("product_id.display_name")
|
|
)
|
|
)
|
|
)
|
|
batch._raise_collected_restriction(
|
|
"You must mark detailed operation lines as collected before validating the batch.",
|
|
product_names,
|
|
)
|
|
|
|
if company.batch_summary_restriction_enabled:
|
|
not_collected_summary = batch._get_not_collected_summary_lines(
|
|
batch_pickings, company.batch_summary_restriction_scope
|
|
)
|
|
if not_collected_summary:
|
|
product_names = ", ".join(
|
|
sorted(
|
|
set(not_collected_summary.mapped("product_id.display_name"))
|
|
)
|
|
)
|
|
batch._raise_collected_restriction(
|
|
"You must mark product summary lines as collected before validating the batch.",
|
|
product_names,
|
|
)
|
|
|
|
|
|
class StockPickingBatchSummaryLine(models.Model):
|
|
_name = "stock.picking.batch.summary.line"
|
|
_description = "Batch Product Summary Line"
|
|
_order = "product_categ_id, product_id"
|
|
|
|
_sql_constraints = [
|
|
(
|
|
"product_required",
|
|
"CHECK(product_id IS NOT NULL)",
|
|
"Product is required for summary lines.",
|
|
),
|
|
]
|
|
|
|
batch_id = fields.Many2one(
|
|
comodel_name="stock.picking.batch",
|
|
ondelete="cascade",
|
|
required=True,
|
|
index=True,
|
|
)
|
|
product_id = fields.Many2one(
|
|
comodel_name="product.product",
|
|
required=True,
|
|
index=True,
|
|
)
|
|
product_categ_id = fields.Many2one(
|
|
comodel_name="product.category",
|
|
string="Product Category",
|
|
related="product_id.categ_id",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
product_uom_id = fields.Many2one(
|
|
comodel_name="uom.uom",
|
|
string="Unit of Measure",
|
|
required=True,
|
|
)
|
|
qty_demanded = fields.Float(
|
|
string="Demanded Quantity",
|
|
digits="Product Unit of Measure",
|
|
required=True,
|
|
default=0.0,
|
|
)
|
|
qty_done = fields.Float(
|
|
string="Done Quantity",
|
|
digits="Product Unit of Measure",
|
|
required=True,
|
|
default=0.0,
|
|
)
|
|
qty_pending = fields.Float(
|
|
string="Pending Quantity",
|
|
digits="Product Unit of Measure",
|
|
compute="_compute_qty_pending",
|
|
store=True,
|
|
)
|
|
is_collected = fields.Boolean(string="Collected", default=False)
|
|
|
|
@api.depends("qty_demanded", "qty_done")
|
|
def _compute_qty_pending(self):
|
|
for line in self:
|
|
line.qty_pending = line.qty_demanded - line.qty_done
|