diff --git a/stock_picking_batch_custom/README.rst b/stock_picking_batch_custom/README.rst
index 9893a8f..964a1a7 100644
--- a/stock_picking_batch_custom/README.rst
+++ b/stock_picking_batch_custom/README.rst
@@ -1,13 +1,66 @@
-===============================
+============================
Stock Picking Batch Custom
-===============================
+============================
-.. contents::
- :local:
+Visión general
+==============
+Este módulo amplía las operaciones detalladas y añade un resumen por producto
+en los lotes de picking:
-.. include:: readme/DESCRIPTION.rst
-.. include:: readme/INSTALL.rst
-.. include:: readme/CONFIGURE.rst
-.. include:: readme/USAGE.rst
-.. include:: readme/CONTRIBUTORS.rst
-.. include:: readme/CREDITS.rst
+- ``picking_partner_id`` (Partner del albarán) para identificar 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.
+
+Instalación
+===========
+
+Actualizar o instalar el módulo:
+
+::
+
+ docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
+
+Configuración
+=============
+
+No requiere configuración adicional. Para usar las columnas:
+
+- Abrir un **Lote de picking**.
+- Ir a la pestaña **Detailed Operations**.
+- Abrir el **selector de columnas** y activar *Partner*, *Product Category* y *Collected* según necesidad.
+
+Uso
+===
+
+1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
+2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
+
+ - **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. 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.
+
+Contribuidores
+==============
+
+* Criptomart
+
+Créditos
+========
+
+Autor
+-----
+
+* Criptomart
+
+Financiador
+-----------
+
+* Elika Bilbo
diff --git a/stock_picking_batch_custom/models/stock_picking_batch.py b/stock_picking_batch_custom/models/stock_picking_batch.py
index da3d865..c2c77fc 100644
--- a/stock_picking_batch_custom/models/stock_picking_batch.py
+++ b/stock_picking_batch_custom/models/stock_picking_batch.py
@@ -1,8 +1,6 @@
# 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
@@ -17,7 +15,7 @@ class StockPickingBatch(models.Model):
string="Product Summary",
compute="_compute_summary_line_ids",
store=True,
- readonly=False,
+ readonly=True,
)
@api.depends(
@@ -38,36 +36,57 @@ class StockPickingBatch(models.Model):
"""
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,
- }
- )
+ # 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"
)
- done = move.product_uom._compute_quantity(
- move.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_demanded"] += demand
- entry = aggregates[product.id]
- entry["product_id"] = product.id
- entry["product_uom_id"] = product_uom.id
- entry["qty_demanded"] += demand
- entry["qty_done"] += done
+ # 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()
@@ -76,9 +95,12 @@ class StockPickingBatch(models.Model):
}
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)
- values["is_collected"] = previous_collected.get(product_id, False)
+ is_collected = previous_collected.get(product_id, False)
if existing_line:
commands.append(
fields.Command.update(
@@ -87,12 +109,24 @@ class StockPickingBatch(models.Model):
"product_uom_id": values["product_uom_id"],
"qty_demanded": values["qty_demanded"],
"qty_done": values["qty_done"],
- "is_collected": values["is_collected"],
+ "is_collected": is_collected,
},
)
)
else:
- commands.append(fields.Command.create(values))
+ # 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)
@@ -100,7 +134,10 @@ class StockPickingBatch(models.Model):
if line.product_id.id not in products_in_totals
]
- batch.summary_line_ids = commands + obsolete_lines
+ if commands or obsolete_lines:
+ batch.summary_line_ids = commands + obsolete_lines
+ else:
+ batch.summary_line_ids = [fields.Command.clear()]
class StockPickingBatchSummaryLine(models.Model):
@@ -108,16 +145,22 @@ class StockPickingBatchSummaryLine(models.Model):
_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",
- string="Batch",
ondelete="cascade",
required=True,
index=True,
)
product_id = fields.Many2one(
comodel_name="product.product",
- string="Product",
required=True,
index=True,
)
diff --git a/stock_picking_batch_custom/tests/__init__.py b/stock_picking_batch_custom/tests/__init__.py
index e69de29..161ba0b 100644
--- a/stock_picking_batch_custom/tests/__init__.py
+++ b/stock_picking_batch_custom/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_batch_summary # noqa: F401
diff --git a/stock_picking_batch_custom/tests/test_batch_summary.py b/stock_picking_batch_custom/tests/test_batch_summary.py
index d41d7c0..e8139e9 100644
--- a/stock_picking_batch_custom/tests/test_batch_summary.py
+++ b/stock_picking_batch_custom/tests/test_batch_summary.py
@@ -1,10 +1,12 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
-from odoo.tests.common import SavepointCase
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
-class TestBatchSummary(SavepointCase):
+@tagged("-at_install", "post_install")
+class TestBatchSummary(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
@@ -62,7 +64,7 @@ class TestBatchSummary(SavepointCase):
"move_id": move.id,
"picking_id": picking.id,
"product_id": self.product.id,
- "product_uom_id": uom.id,
+ "product_uom_id": self.product.uom_id.id,
"location_id": self.location_src.id,
"location_dest_id": self.location_dest.id,
"quantity": qty_done,
@@ -71,9 +73,45 @@ class TestBatchSummary(SavepointCase):
return move
def _recompute_batch(self):
- self.batch.invalidate_cache()
+ self.batch.invalidate_recordset()
self.batch._compute_summary_line_ids()
+ def _create_batch_with_pickings(self):
+ batch = self.env["stock.picking.batch"].create(
+ {"name": "Batch Flow", "picking_type_id": self.picking_type.id}
+ )
+ picking = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.picking_type.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ move = self.env["stock.move"].create(
+ {
+ "name": self.product.name,
+ "product_id": self.product.id,
+ "product_uom_qty": 2.0,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ self.env["stock.move.line"].create(
+ {
+ "move_id": move.id,
+ "picking_id": picking.id,
+ "product_id": self.product.id,
+ "product_uom_id": self.uom_unit.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ "quantity": 2.0,
+ }
+ )
+ picking.batch_id = batch.id
+ return batch
+
# Tests
def test_totals_and_pending_with_conversion(self):
"""Totals aggregate per product with UoM conversion and pending."""
@@ -125,3 +163,127 @@ class TestBatchSummary(SavepointCase):
self._recompute_batch()
self.assertFalse(self.batch.summary_line_ids)
+
+ def test_no_required_product_error_on_confirm(self):
+ """Confirming batch with pickings must not create summary lines without product."""
+
+ batch = self._create_batch_with_pickings()
+
+ # Trigger compute via confirm flow
+ batch.action_confirm()
+
+ self.assertTrue(batch.summary_line_ids)
+ self.assertFalse(
+ batch.summary_line_ids.filtered(lambda line: not line.product_id)
+ )
+
+ def test_empty_batch_add_pickings_then_confirm(self):
+ """Create empty draft batch, add multiple pickings, then confirm."""
+
+ # 1. Create empty batch in draft state
+ batch = self.env["stock.picking.batch"].create(
+ {"name": "Batch Empty Start", "picking_type_id": self.picking_type.id}
+ )
+ self.assertEqual(batch.state, "draft")
+ self.assertFalse(batch.picking_ids)
+ self.assertFalse(batch.summary_line_ids)
+
+ # 2. Create pickings with moves (without batch assignment)
+ product2 = self.env["product.product"].create(
+ {
+ "name": "Test Product 2",
+ "uom_id": self.uom_unit.id,
+ "uom_po_id": self.uom_unit.id,
+ }
+ )
+
+ picking_a = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.picking_type.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ self.env["stock.move"].create(
+ {
+ "name": self.product.name,
+ "product_id": self.product.id,
+ "product_uom_qty": 5.0,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking_a.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+
+ picking_b = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.picking_type.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ self.env["stock.move"].create(
+ {
+ "name": product2.name,
+ "product_id": product2.id,
+ "product_uom_qty": 10.0,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking_b.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+
+ picking_c = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.picking_type.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+ self.env["stock.move"].create(
+ {
+ "name": self.product.name,
+ "product_id": self.product.id,
+ "product_uom_qty": 3.0,
+ "product_uom": self.uom_unit.id,
+ "picking_id": picking_c.id,
+ "location_id": self.location_src.id,
+ "location_dest_id": self.location_dest.id,
+ }
+ )
+
+ # 3. Add pickings to the batch
+ picking_a.batch_id = batch
+ picking_b.batch_id = batch
+ picking_c.batch_id = batch
+
+ self.assertEqual(len(batch.picking_ids), 3)
+
+ # 4. Confirm the batch — this should not raise product_id required error
+ batch.action_confirm()
+
+ self.assertEqual(batch.state, "in_progress")
+
+ # 5. Verify summary lines are correct
+ self.assertTrue(batch.summary_line_ids)
+ self.assertFalse(
+ batch.summary_line_ids.filtered(lambda line: not line.product_id)
+ )
+
+ # Two products expected
+ products_in_summary = batch.summary_line_ids.mapped("product_id")
+ self.assertIn(self.product, products_in_summary)
+ self.assertIn(product2, products_in_summary)
+
+ # Check aggregated quantities
+ line_product1 = batch.summary_line_ids.filtered(
+ lambda line: line.product_id == self.product
+ )
+ self.assertAlmostEqual(line_product1.qty_demanded, 8.0) # 5 + 3
+
+ line_product2 = batch.summary_line_ids.filtered(
+ lambda line: line.product_id == product2
+ )
+ self.assertAlmostEqual(line_product2.qty_demanded, 10.0)
diff --git a/stock_picking_batch_custom/views/stock_picking_batch_views.xml b/stock_picking_batch_custom/views/stock_picking_batch_views.xml
index 10c26ce..43b3ae2 100644
--- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml
+++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml
@@ -4,14 +4,14 @@
stock.picking.batch.summary.line.tree
stock.picking.batch.summary.line
-
+
-
+