diff --git a/stock_warehouse_flow/README.rst b/stock_warehouse_flow/README.rst index 693493df2f2..e6e1bec1de5 100644 --- a/stock_warehouse_flow/README.rst +++ b/stock_warehouse_flow/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ==================== Stock Warehouse Flow ==================== @@ -13,7 +17,7 @@ Stock Warehouse Flow .. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png :target: https://odoo-community.org/page/development-status :alt: Alpha -.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github diff --git a/stock_warehouse_flow/__manifest__.py b/stock_warehouse_flow/__manifest__.py index 64fa9ab475a..bd67b9b597f 100644 --- a/stock_warehouse_flow/__manifest__.py +++ b/stock_warehouse_flow/__manifest__.py @@ -6,7 +6,7 @@ "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", "category": "Warehouse Management", - "version": "16.0.1.1.0", + "version": "16.0.1.2.0", "license": "AGPL-3", "depends": [ # core diff --git a/stock_warehouse_flow/i18n/es.po b/stock_warehouse_flow/i18n/es.po index 8b668222c02..8cba7c545cb 100644 --- a/stock_warehouse_flow/i18n/es.po +++ b/stock_warehouse_flow/i18n/es.po @@ -91,10 +91,14 @@ msgstr "Mostrar Nombre" #: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__move_domain msgid "" "Domain based on Stock Moves, to define if the routing flow is applicable or " -"not." +"not.\n" +"Use 'wh_total_products' to set flow routes based on the quantity assigned to " +"each warehouse." msgstr "" "Dominio basado en Movimientos de existencias, para definir si el flujo de " -"enrutamiento es aplicable o no." +"enrutamiento es aplicable o no.\n" +"Usa 'wh_total_products' para establecer los flujos de ruta en función de la " +"cantidad asignada a cada almacén." #. module: stock_warehouse_flow #. odoo-python @@ -438,6 +442,24 @@ msgstr "Almacén" msgid "Warehouse Flow" msgstr "Flujo de Almacén" +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_move__wh_total_products +msgid "Wh Total Products" +msgstr "Total productos por almacén" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_move__wh_total_products +msgid "" +"Total product units for the same sale order and warehouse. Equivalent to " +"sale.order.total_products but scoped to the warehouse assigned to this " +"move, useful for flow domain evaluation in multi-warehouse (omnichannel) " +"scenarios." +msgstr "" +"Total de unidades de producto para el mismo pedido de venta y almacén. " +"Equivalente a sale.order.total_products pero limitado al almacén asignado " +"a este movimiento, útil para la evaluación de dominios de flujo en " +"escenarios multi-almacén." + #. module: stock_warehouse_flow #: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__warning msgid "Warning" diff --git a/stock_warehouse_flow/i18n/stock_warehouse_flow.pot b/stock_warehouse_flow/i18n/stock_warehouse_flow.pot index 35710954779..965a3fce88e 100644 --- a/stock_warehouse_flow/i18n/stock_warehouse_flow.pot +++ b/stock_warehouse_flow/i18n/stock_warehouse_flow.pot @@ -88,7 +88,9 @@ msgstr "" #: model:ir.model.fields,help:stock_warehouse_flow.field_stock_warehouse_flow__move_domain msgid "" "Domain based on Stock Moves, to define if the routing flow is applicable or " -"not." +"not.\n" +"Use 'wh_total_products' to set flow routes based on the quantity assigned to " +"each warehouse." msgstr "" #. module: stock_warehouse_flow @@ -416,6 +418,20 @@ msgstr "" msgid "Warehouse Flow" msgstr "" +#. module: stock_warehouse_flow +#: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_move__wh_total_products +msgid "Wh Total Products" +msgstr "" + +#. module: stock_warehouse_flow +#: model:ir.model.fields,help:stock_warehouse_flow.field_stock_move__wh_total_products +msgid "" +"Total product units for the same sale order and warehouse. Equivalent to " +"sale.order.total_products but scoped to the warehouse assigned to this " +"move, useful for flow domain evaluation in multi-warehouse (omnichannel) " +"scenarios." +msgstr "" + #. module: stock_warehouse_flow #: model:ir.model.fields,field_description:stock_warehouse_flow.field_stock_warehouse_flow__warning msgid "Warning" diff --git a/stock_warehouse_flow/models/stock_move.py b/stock_warehouse_flow/models/stock_move.py index 5110c498d54..094e04ed30e 100644 --- a/stock_warehouse_flow/models/stock_move.py +++ b/stock_warehouse_flow/models/stock_move.py @@ -1,8 +1,9 @@ # Copyright 2022 Camptocamp SA # Copyright 2023 Michael Tietz (MT Software) +# © 2026 FactorLibre - Adriana Saiz # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import fields, models +from odoo import api, fields, models class StockMove(models.Model): @@ -15,6 +16,57 @@ class StockMove(models.Model): "before a new flow is applied." ), ) + wh_total_products = fields.Float( + compute="_compute_wh_total_products", + help=( + "Total product units for the same sale order and warehouse. " + "Equivalent to sale.order.total_products but scoped to the " + "warehouse assigned to this move, useful for flow domain " + "evaluation in multi-warehouse (omnichannel) scenarios." + ), + ) + + @api.depends( + "sale_line_id.order_id", + "warehouse_id", + "picking_type_id.warehouse_id", + "product_uom_qty", + ) + def _compute_wh_total_products(self): + """Sum product_uom_qty of outgoing sibling moves sharing same SO and warehouse. + + Groups all non-cancelled outgoing moves by (sale_order, warehouse) in a + single query, then assigns each group's total. Moves without + sale_line_id fall back to their own product_uom_qty. + + Only outgoing moves are counted to avoid inflating the total with + incoming (return) moves from exchange claims. + + Uses picking_type_id.warehouse_id as fallback when warehouse_id + is not set (common after flow application in multi-warehouse scenarios). + """ + moves_with_sale = self.filtered("sale_line_id") + for move in self - moves_with_sale: + move.wh_total_products = move.product_uom_qty + if not moves_with_sale: + return + order_ids = moves_with_sale.sale_line_id.order_id.ids + all_siblings = self.search( + [ + ("sale_line_id.order_id", "in", order_ids), + ("state", "!=", "cancel"), + ("picking_type_id.code", "=", "outgoing"), + ] + ) + totals = {} + for sibling in all_siblings: + wh = sibling.warehouse_id or sibling.picking_type_id.warehouse_id + key = (sibling.sale_line_id.order_id.id, wh.id) + totals[key] = totals.get(key, 0.0) + sibling.product_uom_qty + for move in moves_with_sale: + wh = move.warehouse_id or move.picking_type_id.warehouse_id + key = (move.sale_line_id.order_id.id, wh.id) + move.wh_total_products = totals.get(key, move.product_uom_qty) def _apply_flow_on_action_confirm(self): if self.rule_id.route_id.apply_flow_on != "on_confirm": diff --git a/stock_warehouse_flow/models/stock_warehouse_flow.py b/stock_warehouse_flow/models/stock_warehouse_flow.py index 1ea1966780d..0f7cc0b7d24 100644 --- a/stock_warehouse_flow/models/stock_warehouse_flow.py +++ b/stock_warehouse_flow/models/stock_warehouse_flow.py @@ -76,7 +76,9 @@ class StockWarehouseFlow(models.Model): default=[], copy=False, help="Domain based on Stock Moves, to define if the " - "routing flow is applicable or not.", + "routing flow is applicable or not.\n" + "Use 'wh_total_products' to set flow routes based on " + "the quantity assigned to each warehouse.", ) delivery_steps = fields.Selection( selection=[ diff --git a/stock_warehouse_flow/tests/__init__.py b/stock_warehouse_flow/tests/__init__.py index e37a7020881..90e0360849c 100644 --- a/stock_warehouse_flow/tests/__init__.py +++ b/stock_warehouse_flow/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_delivery_carrier from . import test_warehouse from . import test_warehouse_flow +from . import test_wh_total_products diff --git a/stock_warehouse_flow/tests/test_wh_total_products.py b/stock_warehouse_flow/tests/test_wh_total_products.py new file mode 100644 index 00000000000..6dbbefec1ce --- /dev/null +++ b/stock_warehouse_flow/tests/test_wh_total_products.py @@ -0,0 +1,277 @@ +# © 2026 FactorLibre - Adriana Saiz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests.common import TransactionCase + + +class TestWhTotalProducts(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.wh1 = cls.env.ref("stock.warehouse0") + cls.wh2 = cls.env["stock.warehouse"].create( + { + "name": "Warehouse 2", + "code": "WH2", + "company_id": cls.wh1.company_id.id, + } + ) + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + cls.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "type": "product", + "list_price": 10.0, + } + ) + cls.product_b = cls.env["product.product"].create( + { + "name": "Product B", + "type": "product", + "list_price": 20.0, + } + ) + cls.product_c = cls.env["product.product"].create( + { + "name": "Product C", + "type": "product", + "list_price": 30.0, + } + ) + cls.loc_customer = cls.env.ref("stock.stock_location_customers") + # Disable flow application on delivery routes to avoid interference + # with flow demo data — we only test the compute field here + cls.wh1.delivery_route_id.apply_flow_on = False + cls.wh2.delivery_route_id.apply_flow_on = False + + def _create_sale_order(self, lines): + return self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "warehouse_id": self.wh1.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": qty, + }, + ) + for product, qty in lines + ], + } + ) + + def _create_move(self, order_line, warehouse, qty=None): + """Create a stock move linked to a sale order line. + + Simulates the result of procurement with different warehouses, + which in production is done by sale_warehouse_allocation + (omnichannel module) that assigns different routes per line. + """ + return self.env["stock.move"].create( + { + "name": order_line.product_id.name, + "product_id": order_line.product_id.id, + "product_uom_qty": qty or order_line.product_uom_qty, + "product_uom": order_line.product_uom.id, + "sale_line_id": order_line.id, + "location_id": warehouse.lot_stock_id.id, + "location_dest_id": self.loc_customer.id, + "warehouse_id": warehouse.id, + "picking_type_id": warehouse.out_type_id.id, + } + ) + + def test_single_warehouse_all_moves(self): + """All moves in one warehouse: wh_total_products = SO total.""" + order = self._create_sale_order( + [ + (self.product_a, 5), + (self.product_b, 10), + (self.product_c, 3), + ] + ) + moves = self.env["stock.move"] + for line in order.order_line: + moves |= self._create_move(line, self.wh1) + + self.assertEqual(len(moves), 3) + self.assertEqual(set(moves.mapped("wh_total_products")), {18.0}) + + def test_multi_warehouse_split(self): + """Moves split across 2 warehouses: each sees only its share. + + Simulates omnichannel scenario where sale_warehouse_allocation + routes SO lines to different warehouses based on stock. + """ + order = self._create_sale_order( + [ + (self.product_a, 5), + (self.product_b, 10), + (self.product_c, 3), + ] + ) + lines = order.order_line.sorted("id") + # WH1 gets product_a (5) + product_b (10) = 15 + move_a = self._create_move(lines[0], self.wh1) + move_b = self._create_move(lines[1], self.wh1) + # WH2 gets product_c (3) + move_c = self._create_move(lines[2], self.wh2) + + self.assertEqual(move_a.wh_total_products, 15.0) + self.assertEqual(move_b.wh_total_products, 15.0) + self.assertEqual(move_c.wh_total_products, 3.0) + + def test_no_sale_line_fallback(self): + """Move without sale_line_id returns its own qty.""" + move = self.env["stock.move"].create( + { + "name": "Manual move", + "product_id": self.product_a.id, + "product_uom_qty": 7, + "product_uom": self.product_a.uom_id.id, + "location_id": self.wh1.lot_stock_id.id, + "location_dest_id": self.loc_customer.id, + "warehouse_id": self.wh1.id, + "picking_type_id": self.wh1.out_type_id.id, + } + ) + self.assertEqual(move.wh_total_products, 7.0) + + def test_cancelled_moves_excluded(self): + """Cancelled moves are not counted in wh_total_products.""" + order = self._create_sale_order( + [ + (self.product_a, 5), + (self.product_b, 10), + ] + ) + lines = order.order_line.sorted("id") + move_a = self._create_move(lines[0], self.wh1) + move_b = self._create_move(lines[1], self.wh1) + + self.assertEqual(move_a.wh_total_products, 15.0) + + # Cancel move_b + move_b._action_cancel() + # Invalidate ORM cache: non-stored compute won't auto-refresh + # when a sibling move changes state + self.env.invalidate_all() + + self.assertEqual(move_a.wh_total_products, 5.0) + + def test_flow_domain_filters_by_wh_total(self): + """Flow domain using wh_total_products filters moves correctly. + + Creates two moves from the same SO in different warehouses, + then verifies that a flow domain [('wh_total_products', '>', 1)] + matches only the move whose warehouse total exceeds 1. + This is the core use case: mono-line vs multi-line routing. + """ + order = self._create_sale_order( + [ + (self.product_a, 1), + (self.product_b, 3), + ] + ) + lines = order.order_line.sorted("id") + move_wh1 = self._create_move(lines[0], self.wh1) # 1 unit + move_wh2 = self._create_move(lines[1], self.wh2) # 3 units + + # Create a flow with move_domain filtering by wh_total_products + flow = self.env["stock.warehouse.flow"].create( + { + "name": "Multi-line flow", + "warehouse_id": self.wh1.id, + "from_picking_type_id": self.wh1.out_type_id.id, + "to_picking_type_id": self.wh1.out_type_id.id, + "move_domain": "[('wh_total_products', '>', 1)]", + } + ) + + # Domain should NOT match move_wh1 (wh_total=1, not > 1) + self.assertFalse(flow._is_domain_valid_for_move(move_wh1)) + # Domain SHOULD match move_wh2 (wh_total=3, > 1) + self.assertTrue(flow._is_domain_valid_for_move(move_wh2)) + + def test_single_line_per_warehouse(self): + """Each warehouse has one line: wh_total_products = line qty. + + Simulates: SO with Shirt(1) → Warehouse, Pants(3) → Store. + Global total_products would be 4, but each warehouse should + only see its own qty (1 and 3 respectively), so flow domains + like [('wh_total_products', '>', 1)] route correctly + (mono-line vs multi-line). + """ + order = self._create_sale_order( + [ + (self.product_a, 1), + (self.product_b, 3), + ] + ) + lines = order.order_line.sorted("id") + # Warehouse gets product_a (1 unit) + move_shirt = self._create_move(lines[0], self.wh1) + # Store gets product_b (3 units) + move_pants = self._create_move(lines[1], self.wh2) + + # Each warehouse sees only its own qty, not the global 4 + self.assertEqual(move_shirt.wh_total_products, 1.0) + self.assertEqual(move_pants.wh_total_products, 3.0) + + def test_incoming_moves_excluded(self): + """Incoming (return) moves are excluded from wh_total_products. + + Exchange claims generate both incoming (return) and outgoing (new + shipment) moves under the same sale.order. Only outgoing moves + should count to avoid inflating the total. + """ + order = self._create_sale_order( + [ + (self.product_a, 1), + (self.product_b, 1), + ] + ) + lines = order.order_line.sorted("id") + # Outgoing moves (new shipment) + move_out_a = self._create_move(lines[0], self.wh1) + move_out_b = self._create_move(lines[1], self.wh1) + # Incoming move (return) — same SO, same warehouse + move_in = self.env["stock.move"].create( + { + "name": "Return %s" % self.product_a.name, + "product_id": self.product_a.id, + "product_uom_qty": 1, + "product_uom": self.product_a.uom_id.id, + "sale_line_id": lines[0].id, + "location_id": self.loc_customer.id, + "location_dest_id": self.wh1.lot_stock_id.id, + "warehouse_id": self.wh1.id, + "picking_type_id": self.wh1.in_type_id.id, + } + ) + # Incoming move should NOT inflate the outgoing total + self.assertEqual(move_out_a.wh_total_products, 2.0) + self.assertEqual(move_out_b.wh_total_products, 2.0) + # Incoming move with sale_line_id sees outgoing siblings total + # (irrelevant in practice: flows only evaluate outgoing moves) + self.assertEqual(move_in.wh_total_products, 2.0) + + def test_single_warehouse_via_so_confirm(self): + """Integration: SO confirm generates moves with correct field.""" + order = self._create_sale_order( + [ + (self.product_a, 5), + (self.product_b, 10), + ] + ) + order.action_confirm() + moves = self.env["stock.move"].search( + [("sale_line_id.order_id", "=", order.id)] + ) + self.assertEqual(len(moves), 2) + for move in moves: + self.assertEqual(move.wh_total_products, 15.0)