From 9c14e1dc1a28b347d893d5a394a5c38cedebe503 Mon Sep 17 00:00:00 2001 From: snt Date: Thu, 5 Mar 2026 18:57:10 +0100 Subject: [PATCH 1/3] [FIX] website_sale_aplicoop: ensure add-to-cart on infinite scroll --- .../static/src/js/website_sale.js | 133 ++++++++++-------- 1 file changed, 73 insertions(+), 60 deletions(-) diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index b88bbee..3f76c3c 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -694,6 +694,70 @@ return; } + // Shared handler for add-to-cart to reuse across grid/document listeners + var handleAddToCart = function (e) { + var cartBtn = e.target.closest(".add-to-cart-btn"); + if (!cartBtn) return; + + e.preventDefault(); + var form = cartBtn.closest(".add-to-cart-form"); + if (!form) return; + + var productId = form.getAttribute("data-product-id"); + var productName = form.getAttribute("data-product-name") || "Product"; + var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0; + var quantityInput = form.querySelector(".product-qty"); + var quantity = quantityInput ? parseFloat(quantityInput.value) : 1; + + // Block add-to-cart if product is flagged out of stock (from template) + var isOutOfStock = + (form.getAttribute("data-out-of-stock") || "false") === "true" || + (cartBtn.getAttribute("data-out-of-stock") || "false") === "true"; + + // Fallback guards in case cached markup drops the data attribute + if (!isOutOfStock) { + var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase(); + var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase(); + var iconEl = cartBtn.querySelector("i"); + var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban"); + + if ( + hasBanIcon || + btnTitle.includes("out of stock") || + btnTitle.includes("sin stock") || + btnAria.includes("out of stock") || + btnAria.includes("sin stock") + ) { + isOutOfStock = true; + } + } + if (isOutOfStock) { + var labels = self._getLabels(); + self._showNotification( + labels.out_of_stock || "Product is out of stock", + "warning" + ); + return; + } + + console.log("Adding:", { + productId: productId, + productName: productName, + productPrice: productPrice, + quantity: quantity, + }); + + if (quantity > 0) { + self._addToCart(productId, productName, productPrice, quantity); + } else { + var labels2 = self._getLabels(); + self._showNotification( + labels2.invalid_quantity || "Please enter a valid quantity", + "warning" + ); + } + }; + // First, adjust quantity steps for all existing inputs var unitInputs = document.querySelectorAll(".product-qty"); console.log("=== ADJUSTING QUANTITY STEPS (from data-quantity-step) ==="); @@ -779,67 +843,16 @@ } }); - // Add to cart button (via event delegation) - productsGrid.addEventListener("click", function (e) { - var cartBtn = e.target.closest(".add-to-cart-btn"); - if (!cartBtn) return; + // Add to cart button (via event delegation on grid) + productsGrid.addEventListener("click", handleAddToCart); - e.preventDefault(); - var form = cartBtn.closest(".add-to-cart-form"); - var productId = form.getAttribute("data-product-id"); - var productName = form.getAttribute("data-product-name") || "Product"; - var productPrice = parseFloat(form.getAttribute("data-product-price")) || 0; - var quantityInput = form.querySelector(".product-qty"); - var quantity = quantityInput ? parseFloat(quantityInput.value) : 1; - - // Block add-to-cart if product is flagged out of stock (from template) - var isOutOfStock = - (form.getAttribute("data-out-of-stock") || "false") === "true" || - (cartBtn.getAttribute("data-out-of-stock") || "false") === "true"; - - // Fallback guards in case cached markup drops the data attribute - if (!isOutOfStock) { - var btnTitle = (cartBtn.getAttribute("title") || "").toLowerCase(); - var btnAria = (cartBtn.getAttribute("aria-label") || "").toLowerCase(); - var iconEl = cartBtn.querySelector("i"); - var hasBanIcon = iconEl && iconEl.classList.contains("fa-ban"); - - if ( - hasBanIcon || - btnTitle.includes("out of stock") || - btnTitle.includes("sin stock") || - btnAria.includes("out of stock") || - btnAria.includes("sin stock") - ) { - isOutOfStock = true; - } - } - if (isOutOfStock) { - var labels = self._getLabels(); - self._showNotification( - labels.out_of_stock || "Product is out of stock", - "warning" - ); - return; - } - - console.log("Adding:", { - productId: productId, - productName: productName, - productPrice: productPrice, - quantity: quantity, - }); - - if (quantity > 0) { - self._addToCart(productId, productName, productPrice, quantity); - } else { - var labels = self._getLabels(); - self._showNotification( - labels.invalid_quantity || "Please enter a valid quantity", - "warning" - ); - } - }); + // Also attach a document-level delegation as fallback for dynamically + // inserted products (infinite scroll) in case the grid listener is lost + // after DOM replacement. + if (!this._docCartListenerAttached) { + document.addEventListener("click", handleAddToCart, true); + this._docCartListenerAttached = true; + } }, _addToCart: function (productId, productName, productPrice, quantity) { From ad8b759643ba72b5475f01eb1839d7a26427e09f Mon Sep 17 00:00:00 2001 From: snt Date: Thu, 5 Mar 2026 20:29:02 +0100 Subject: [PATCH 2/3] [ADD] stock_picking_batch_custom: product summary --- stock_picking_batch_custom/__manifest__.py | 2 + stock_picking_batch_custom/models/__init__.py | 1 + .../models/stock_move_line.py | 8 +- .../models/stock_picking_batch.py | 159 ++++++++++++++++++ .../readme/DESCRIPTION.rst | 19 ++- stock_picking_batch_custom/readme/USAGE.rst | 6 +- .../security/ir.model.access.csv | 2 + stock_picking_batch_custom/tests/__init__.py | 0 .../tests/test_batch_summary.py | 127 ++++++++++++++ .../views/stock_move_line_views.xml | 3 + .../views/stock_picking_batch_views.xml | 31 ++++ 11 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 stock_picking_batch_custom/models/stock_picking_batch.py create mode 100644 stock_picking_batch_custom/security/ir.model.access.csv create mode 100644 stock_picking_batch_custom/tests/__init__.py create mode 100644 stock_picking_batch_custom/tests/test_batch_summary.py create mode 100644 stock_picking_batch_custom/views/stock_picking_batch_views.xml 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 + + + + + + + + + + From 3eeca66551d81e8e4bff3bbfb166e6efba73a5ef Mon Sep 17 00:00:00 2001 From: snt Date: Thu, 5 Mar 2026 21:47:18 +0100 Subject: [PATCH 3/3] [FIX] stock_picking_batch_custom: prevent product_id null error on summary lines - Use regular dict instead of defaultdict to avoid empty entries - Make summary_line_ids readonly=True to prevent UI from inserting empty lines - Add SQL constraint CHECK(product_id IS NOT NULL) as safeguard - Use boolean_toggle widget for is_collected field - Fix tests to use TransactionCase and invalidate_recordset - Add test for empty batch + add pickings + confirm flow --- stock_picking_batch_custom/README.rst | 73 ++++++-- .../models/stock_picking_batch.py | 101 ++++++++--- stock_picking_batch_custom/tests/__init__.py | 1 + .../tests/test_batch_summary.py | 170 +++++++++++++++++- .../views/stock_picking_batch_views.xml | 4 +- 5 files changed, 304 insertions(+), 45 deletions(-) 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 - + - +