# Copyright 2026 Criptomart # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from collections import defaultdict from odoo import api from odoo import fields from odoo import models 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=False, ) @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: previous_collected = { line.product_id.id: line.is_collected for line in batch.summary_line_ids } aggregates = defaultdict( lambda: { "product_id": False, "product_uom_id": False, "qty_demanded": 0.0, "qty_done": 0.0, } ) 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" ) done = move.product_uom._compute_quantity( move.quantity, product_uom, rounding_method="HALF-UP" ) entry = aggregates[product.id] entry["product_id"] = product.id entry["product_uom_id"] = product_uom.id entry["qty_demanded"] += demand entry["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(): products_in_totals.add(product_id) existing_line = existing_by_product.get(product_id) values["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": values["is_collected"], }, ) ) else: commands.append(fields.Command.create(values)) obsolete_lines = [ fields.Command.unlink(line.id) for line in batch.summary_line_ids if line.product_id.id not in products_in_totals ] batch.summary_line_ids = commands + obsolete_lines class StockPickingBatchSummaryLine(models.Model): _name = "stock.picking.batch.summary.line" _description = "Batch Product Summary Line" _order = "product_categ_id, product_id" batch_id = fields.Many2one( comodel_name="stock.picking.batch", string="Batch", ondelete="cascade", required=True, index=True, ) product_id = fields.Many2one( comodel_name="product.product", string="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