diff --git a/stock_picking_batch_custom/__manifest__.py b/stock_picking_batch_custom/__manifest__.py index 4882701..27e80bd 100644 --- a/stock_picking_batch_custom/__manifest__.py +++ b/stock_picking_batch_custom/__manifest__.py @@ -13,6 +13,8 @@ "stock_picking_batch", ], "data": [ + "security/ir.model.access.csv", "views/stock_move_line_views.xml", + "views/stock_picking_batch_views.xml", ], } diff --git a/stock_picking_batch_custom/models/__init__.py b/stock_picking_batch_custom/models/__init__.py index 5a228fe..24709d5 100644 --- a/stock_picking_batch_custom/models/__init__.py +++ b/stock_picking_batch_custom/models/__init__.py @@ -1 +1,2 @@ from . import stock_move_line # noqa: F401 +from . import stock_picking_batch # noqa: F401 diff --git a/stock_picking_batch_custom/models/stock_move_line.py b/stock_picking_batch_custom/models/stock_move_line.py index cb4ad48..82bd6d2 100644 --- a/stock_picking_batch_custom/models/stock_move_line.py +++ b/stock_picking_batch_custom/models/stock_move_line.py @@ -10,8 +10,14 @@ class StockMoveLine(models.Model): product_categ_id = fields.Many2one( comodel_name="product.category", - string="Product Category", + string="Product Category (Batch)", related="product_id.categ_id", store=True, readonly=True, ) + + is_collected = fields.Boolean( + string="Collected", + default=False, + copy=False, + ) diff --git a/stock_picking_batch_custom/models/stock_picking_batch.py b/stock_picking_batch_custom/models/stock_picking_batch.py new file mode 100644 index 0000000..da3d865 --- /dev/null +++ b/stock_picking_batch_custom/models/stock_picking_batch.py @@ -0,0 +1,159 @@ +# 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 diff --git a/stock_picking_batch_custom/readme/DESCRIPTION.rst b/stock_picking_batch_custom/readme/DESCRIPTION.rst index d8fc673..da6edad 100644 --- a/stock_picking_batch_custom/readme/DESCRIPTION.rst +++ b/stock_picking_batch_custom/readme/DESCRIPTION.rst @@ -1,11 +1,14 @@ -Este módulo añade dos columnas opcionales en las operaciones detalladas de los -lotes de picking: +Este módulo amplía las operaciones detalladas y añade un resumen por producto +en los lotes de picking: - ``picking_partner_id`` (Partner del albarán) para que el personal de almacén - pueda identificar rápidamente el cliente/proveedor asociado. -- ``product_categ_id`` (Categoría de producto) para permitir ordenación y - agrupación por categoría. + identifique rápido el cliente/proveedor. +- ``product_categ_id`` (Categoría de producto) para ordenar y agrupar. +- ``is_collected`` (Recogido) como check manual en cada línea para marcar si se + ha recolectado. +- Nueva pestaña **Product Summary** con totales por producto (demandado, hecho, + pendiente), categoría y el check de recogido consolidado. -Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del -lote, de modo que el usuario puede activarlas desde el selector de columnas sin -cargar la vista por defecto. +Las columnas se añaden como ``optional="hide"`` en la vista de líneas del lote, +de modo que el usuario puede activarlas desde el selector de columnas sin +recargar la vista por defecto. diff --git a/stock_picking_batch_custom/readme/USAGE.rst b/stock_picking_batch_custom/readme/USAGE.rst index d652a4e..89f41e8 100644 --- a/stock_picking_batch_custom/readme/USAGE.rst +++ b/stock_picking_batch_custom/readme/USAGE.rst @@ -6,5 +6,9 @@ Uso - **Partner** (``picking_partner_id``) para ver el cliente/proveedor. - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría. + - **Collected** (``is_collected``) para marcar manualmente líneas recolectadas. -3. Ordena o agrupa por la columna de categoría según convenga. +3. Pestaña **Product Summary**: consulta los totales por producto (demandado, + hecho y pendiente) y marca el check de recogido consolidado si corresponde. + +4. Ordena o agrupa por categoría en cualquiera de las vistas según convenga. diff --git a/stock_picking_batch_custom/security/ir.model.access.csv b/stock_picking_batch_custom/security/ir.model.access.csv new file mode 100644 index 0000000..57ebabc --- /dev/null +++ b/stock_picking_batch_custom/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_picking_batch_summary_line_user,stock_picking_batch_summary_line_user,model_stock_picking_batch_summary_line,base.group_user,1,1,1,0 diff --git a/stock_picking_batch_custom/tests/__init__.py b/stock_picking_batch_custom/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stock_picking_batch_custom/tests/test_batch_summary.py b/stock_picking_batch_custom/tests/test_batch_summary.py new file mode 100644 index 0000000..d41d7c0 --- /dev/null +++ b/stock_picking_batch_custom/tests/test_batch_summary.py @@ -0,0 +1,127 @@ +# Copyright 2026 Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + + +class TestBatchSummary(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + cls.location_src = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.env.ref("stock.stock_location_customers") + cls.picking_type = cls.env.ref("stock.picking_type_out") + + cls.product = cls.env["product.product"].create( + { + "name": "Test Product", + "uom_id": cls.uom_unit.id, + "uom_po_id": cls.uom_unit.id, + } + ) + + cls.batch = cls.env["stock.picking.batch"].create( + {"name": "Batch Test", "picking_type_id": cls.picking_type.id} + ) + + cls.picking1 = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + "batch_id": cls.batch.id, + } + ) + cls.picking2 = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "location_id": cls.location_src.id, + "location_dest_id": cls.location_dest.id, + "batch_id": cls.batch.id, + } + ) + + # Helpers + def _add_move(self, picking, qty_demanded, qty_done, uom): + move = self.env["stock.move"].create( + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": qty_demanded, + "product_uom": uom.id, + "picking_id": picking.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + } + ) + if qty_done: + self.env["stock.move.line"].create( + { + "move_id": move.id, + "picking_id": picking.id, + "product_id": self.product.id, + "product_uom_id": uom.id, + "location_id": self.location_src.id, + "location_dest_id": self.location_dest.id, + "quantity": qty_done, + } + ) + return move + + def _recompute_batch(self): + self.batch.invalidate_cache() + self.batch._compute_summary_line_ids() + + # Tests + def test_totals_and_pending_with_conversion(self): + """Totals aggregate per product with UoM conversion and pending.""" + + # demand 12 units, done 5 units + self._add_move(self.picking1, qty_demanded=12, qty_done=5, uom=self.uom_unit) + # demand 2 dozens (24 units), done 6 units + self._add_move(self.picking2, qty_demanded=2, qty_done=6, uom=self.uom_dozen) + + self._recompute_batch() + + self.assertEqual(len(self.batch.summary_line_ids), 1) + line = self.batch.summary_line_ids + self.assertEqual(line.product_id, self.product) + self.assertEqual(line.product_uom_id, self.uom_unit) + self.assertAlmostEqual(line.qty_demanded, 36.0) + self.assertAlmostEqual(line.qty_done, 11.0) + self.assertAlmostEqual(line.qty_pending, 25.0) + + def test_collected_flag_preserved_on_recompute(self): + """Collected stays checked after totals change.""" + + self._add_move(self.picking1, qty_demanded=1, qty_done=1, uom=self.uom_unit) + self._recompute_batch() + + line = self.batch.summary_line_ids + line.is_collected = True + + # Add more demand/done to trigger an update + self._add_move(self.picking1, qty_demanded=3, qty_done=2, uom=self.uom_unit) + self._recompute_batch() + + self.assertTrue(self.batch.summary_line_ids.is_collected) + + def test_cancelled_moves_are_ignored(self): + """Cancelled moves do not count in the summary and lines are removed.""" + + move1 = self._add_move( + self.picking1, qty_demanded=4, qty_done=2, uom=self.uom_unit + ) + move2 = self._add_move( + self.picking2, qty_demanded=6, qty_done=3, uom=self.uom_unit + ) + self._recompute_batch() + self.assertEqual(len(self.batch.summary_line_ids), 1) + + move1.write({"state": "cancel"}) + move2.write({"state": "cancel"}) + self._recompute_batch() + + self.assertFalse(self.batch.summary_line_ids) diff --git a/stock_picking_batch_custom/views/stock_move_line_views.xml b/stock_picking_batch_custom/views/stock_move_line_views.xml index a000a97..b857a2c 100644 --- a/stock_picking_batch_custom/views/stock_move_line_views.xml +++ b/stock_picking_batch_custom/views/stock_move_line_views.xml @@ -11,6 +11,9 @@ + + + diff --git a/stock_picking_batch_custom/views/stock_picking_batch_views.xml b/stock_picking_batch_custom/views/stock_picking_batch_views.xml new file mode 100644 index 0000000..10c26ce --- /dev/null +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -0,0 +1,31 @@ + + + + stock.picking.batch.summary.line.tree + stock.picking.batch.summary.line + + + + + + + + + + + + + + + stock.picking.batch.form.summary + stock.picking.batch + + + + + + + + + +