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