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
+
+
+
+
+
+
+
+
+
+