From 3b5b3a957118eac12b648298d8e9b4664b4fa3ba Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Sat, 12 Aug 2023 12:27:26 +0200 Subject: [PATCH 01/11] [PERF] dynamic routing As the call to _check_entire_pack can be very slow, bypass it's call when pre-computing the dynamic routing pull rules as this is not needed for the computation and will be rollbacked. Also in case of source relocation, ensure to call _check_entire_pack on the recordset of moves and not on each move one by one. This is because _check_entire_pack triggers the check on the picking of the move and so you perform the same check for each move of the picking. When you reserve from source packages you have many moves inside a picking, the check is quite long and this change makes a important performance improvement. When the dynamic routing does not change the source location, do not write the same value on the move as this triggers many useless additional recomputations. --- stock_dynamic_routing/__manifest__.py | 3 ++- stock_dynamic_routing/models/stock_move.py | 19 +++++++++++++++++-- stock_dynamic_routing/models/stock_picking.py | 6 ++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/stock_dynamic_routing/__manifest__.py b/stock_dynamic_routing/__manifest__.py index 0aceee6b566..8f68e37cb59 100644 --- a/stock_dynamic_routing/__manifest__.py +++ b/stock_dynamic_routing/__manifest__.py @@ -2,8 +2,9 @@ { "name": "Stock Dynamic Routing", "summary": "Dynamic routing of stock moves", - "author": "Camptocamp, Odoo Community Association (OCA)", + "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", + "maintainers": ["jbaudoux"], "category": "Warehouse Management", "version": "16.0.1.0.4", "license": "AGPL-3", diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 0bbfc63b467..9e7ece8da22 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -1,6 +1,7 @@ # Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) # Copyright 2021 Jacques-Etienne Baudoux (BCIM) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import logging import uuid from collections import OrderedDict, defaultdict, namedtuple @@ -8,6 +9,8 @@ from odoo import models +_logger = logging.getLogger(__name__) + class StockMove(models.Model): _inherit = "stock.move" @@ -118,7 +121,8 @@ def _prepare_routing_pull(self): self.env.cr.execute( sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) ) - super()._action_assign() + _logger.debug("Prepare pull re-routing") + super(StockMove, self.with_context(bypass_entire_pack=True))._action_assign() moves_routing = self._routing_compute_rules() if not any( @@ -131,8 +135,10 @@ def _prepare_routing_pull(self): self.env.cr.execute( sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) ) + self.mapped("picking_id")._check_entire_pack() return {} + _logger.debug("Rollback computation for applying pull re-routing") # rollback _action_assign, it'll be called again after the routing self.env.clear() # pylint: disable=sql-injection @@ -378,6 +384,7 @@ def _routing_pull_switch_source(self): new move but switch the source location of the current move. This might trigger a new routing on the destination move. """ + next_move_in_chain_ids = [] for move in self: origmoves_by_location = OrderedDict() for orig_move in move.move_orig_ids: @@ -398,7 +405,15 @@ def _routing_pull_switch_source(self): # we have a different routing move.move_orig_ids -= orig_moves split_move.move_orig_ids = orig_moves - split_move.location_id = location_id + if split_move.location_id != location_id: + split_move.with_context( + __applying_routing_rule=True + ).location_id = location_id + next_move_in_chain_ids.append(split_move.id) + # Apply dynamic routing on next waiting moves in the chain + self.browse(next_move_in_chain_ids).filtered( + lambda r: r.state == "waiting" + )._chain_apply_routing() def _apply_routing_rule_push(self, routing_details): """Apply push dynamic routing diff --git a/stock_dynamic_routing/models/stock_picking.py b/stock_dynamic_routing/models/stock_picking.py index 280a5c5a6db..970f36117ee 100644 --- a/stock_dynamic_routing/models/stock_picking.py +++ b/stock_dynamic_routing/models/stock_picking.py @@ -12,6 +12,12 @@ class StockPicking(models.Model): " canceled because it was left empty after a dynamic routing.", ) + def _check_entire_pack(self): + # This can be dropped in v16 as part of odoo standard + if self.env.context.get("bypass_entire_pack"): + return + return super()._check_entire_pack() + @api.depends("canceled_by_routing") def _compute_state(self): res = super()._compute_state() From 5bc5fd7233e94868c12420497d8614e66d216835 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Thu, 14 Aug 2025 13:50:27 +0200 Subject: [PATCH 02/11] [FIX] stock_dynamic_routing: package putaway Fix [PERF] dynamic routing When there is no dynamic routing rule, the package putaway was not computed as we skip the result_package_id during the reservation. Now the putaway is also deferred and applied after the package is set on the stock move line. --- stock_dynamic_routing/models/stock_move.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 9e7ece8da22..4c1f57c8514 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -122,7 +122,10 @@ def _prepare_routing_pull(self): sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) ) _logger.debug("Prepare pull re-routing") - super(StockMove, self.with_context(bypass_entire_pack=True))._action_assign() + super( + StockMove, + self.with_context(bypass_entire_pack=True, avoid_putaway_rules=True), + )._action_assign() moves_routing = self._routing_compute_rules() if not any( @@ -136,6 +139,9 @@ def _prepare_routing_pull(self): sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) ) self.mapped("picking_id")._check_entire_pack() + self.filtered( + lambda move: move.state in ("assigned", "partially_available") + ).move_line_ids._apply_putaway_strategy() return {} _logger.debug("Rollback computation for applying pull re-routing") From 298621bfe0b22b7dfebb173b7bc4252beeb63700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Thu, 18 Sep 2025 16:01:33 -0300 Subject: [PATCH 03/11] [FIX] stock_dynamic_routing: keep move source location if it's already a sublocation of the routing rule src location --- stock_dynamic_routing/models/stock_move.py | 11 +- .../tests/test_routing_pull.py | 100 ++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 4c1f57c8514..06811c188dc 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -276,9 +276,14 @@ def _apply_routing_rule_pull(self, routing_details): # pull routing rules original_destination = move.location_dest_id current_picking_type = move.picking_id.picking_type_id - move.with_context( - __applying_routing_rule=True - ).location_id = routing_rule.location_src_id + + # Use the source location of the routing rule, if not already done + # If a sublocation is used, it's respected. + if not move.location_id.is_sublocation_of(routing_rule.location_src_id): + move.with_context( + __applying_routing_rule=True + ).location_id = routing_rule.location_src_id + move.picking_type_id = routing_rule.picking_type_id dest_location = move.location_dest_id rule_location = routing_rule.location_dest_id diff --git a/stock_dynamic_routing/tests/test_routing_pull.py b/stock_dynamic_routing/tests/test_routing_pull.py index 4f9bcbe95f6..b36f375e282 100644 --- a/stock_dynamic_routing/tests/test_routing_pull.py +++ b/stock_dynamic_routing/tests/test_routing_pull.py @@ -919,6 +919,106 @@ def test_change_dest_move_source_chain(self): self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) + def test_change_dest_move_source_chain_with_sublocation(self): + location_qa = self.env["stock.location"].create( + {"location_id": self.wh.wh_output_stock_loc_id.id, "name": "QA"} + ) + location_qa_1 = self.env["stock.location"].create( + {"location_id": location_qa.id, "name": "QA: 1"} + ) + # The setup we want is: + # + # * When the initial move line reserves in Highbay, the move is + # classified in picking type "Dynamic Routing" with locations + # Highbay -> Handover (a new move is inserted between Handover and + # Output) + # * When the next move source location is set to "Handover", we + # we want to classify the next move as "QA" with locations Handover + # -> Output/QA/1 + # * When the last move source location is changed to "QA/1", it must be + # classified as "Delivery (after QA)" with locations Output/QA/1 -> + # Customer + + pick_type_routing_qa = self.env["stock.picking.type"].create( + { + "name": "QA", + "code": "internal", + "sequence_code": "WH/QA", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": self.location_handover.id, + "default_location_dest_id": location_qa_1.id, + } + ) + self.env["stock.routing"].create( + { + "location_id": self.location_handover.id, + "picking_type_id": self.wh.pick_type_id.id, + "rule_ids": [ + ( + 0, + 0, + {"method": "pull", "picking_type_id": pick_type_routing_qa.id}, + ) + ], + } + ) + + pick_type_routing_delivery = self.env["stock.picking.type"].create( + { + "name": "Delivery (after QA)", + "code": "outgoing", + "sequence_code": "OUT(R)", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": location_qa.id, + "default_location_dest_id": self.customer_loc.id, + } + ) + self.env["stock.routing"].create( + { + "location_id": location_qa.id, + "picking_type_id": self.wh.out_type_id.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "pull", + "picking_type_id": pick_type_routing_delivery.id, + }, + ) + ], + } + ) + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_ids + move_b = customer_picking.move_ids + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + move_middle = move_a.move_dest_ids + self.assertNotEqual(move_middle, move_b) + + self.assert_src_highbay(move_a) + self.assert_dest_handover(move_a) + self.assert_src_handover(move_middle) + self.assertEqual(move_middle.location_dest_id, location_qa_1) + self.assertEqual(move_b.location_id, location_qa_1) + self.assert_dest_customer(move_b) + + # routing has been applied + self.assertEqual(move_middle.picking_id.picking_type_id, pick_type_routing_qa) + + self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) + def test_mix_routing_reservation_same_location(self): """Test a picking with different types of routing From a9ccdcafa3b30c50b3477d601232e01fa9c519b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Thu, 18 Sep 2025 16:11:46 -0300 Subject: [PATCH 04/11] [IMP] stock_dynamic_routing: avoid writing picking type if it's already set --- stock_dynamic_routing/models/stock_move.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 06811c188dc..22d43d9ffa0 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -284,7 +284,10 @@ def _apply_routing_rule_pull(self, routing_details): __applying_routing_rule=True ).location_id = routing_rule.location_src_id - move.picking_type_id = routing_rule.picking_type_id + # Use the picking type of the routing rule, if not already done + if move.picking_type_id != routing_rule.picking_type_id: + move.picking_type_id = routing_rule.picking_type_id + dest_location = move.location_dest_id rule_location = routing_rule.location_dest_id if rule_location.is_sublocation_of(dest_location): From 7c5545b956460436579e55a8e9cf18f0224b5368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Thu, 18 Sep 2025 16:17:27 -0300 Subject: [PATCH 05/11] [IMP] stock_dynamic_routing: remove unnecessary stock_location_is_sublocation dependency --- stock_dynamic_routing/__manifest__.py | 5 ++++- stock_dynamic_routing/models/stock_move.py | 13 ++++--------- stock_dynamic_routing/models/stock_routing_rule.py | 4 ++-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/stock_dynamic_routing/__manifest__.py b/stock_dynamic_routing/__manifest__.py index 8f68e37cb59..6604dc49b21 100644 --- a/stock_dynamic_routing/__manifest__.py +++ b/stock_dynamic_routing/__manifest__.py @@ -8,7 +8,10 @@ "category": "Warehouse Management", "version": "16.0.1.0.4", "license": "AGPL-3", - "depends": ["stock", "stock_helper"], + "depends": [ + # core + "stock", + ], "demo": [ "demo/stock_location_demo.xml", "demo/stock_picking_type_demo.xml", diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 22d43d9ffa0..ac829427f91 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -279,7 +279,7 @@ def _apply_routing_rule_pull(self, routing_details): # Use the source location of the routing rule, if not already done # If a sublocation is used, it's respected. - if not move.location_id.is_sublocation_of(routing_rule.location_src_id): + if not move.location_id._child_of(routing_rule.location_src_id): move.with_context( __applying_routing_rule=True ).location_id = routing_rule.location_src_id @@ -288,9 +288,7 @@ def _apply_routing_rule_pull(self, routing_details): if move.picking_type_id != routing_rule.picking_type_id: move.picking_type_id = routing_rule.picking_type_id - dest_location = move.location_dest_id - rule_location = routing_rule.location_dest_id - if rule_location.is_sublocation_of(dest_location): + if routing_rule.location_dest_id._child_of(move.location_dest_id): # The destination of the move, is a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one. @@ -301,8 +299,7 @@ def _apply_routing_rule_pull(self, routing_details): next_moves_to_update |= move.move_dest_ids.filtered( lambda r: r.state == "waiting" ) - - elif not dest_location.is_sublocation_of(rule_location): + elif not move.location_dest_id._child_of(routing_rule.location_dest_id): # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move after to reach the original destination @@ -454,9 +451,7 @@ def _apply_routing_rule_push(self, routing_details): # routing move after this one continue - rule_location = routing_rule.location_src_id - location = move.location_id - if location.is_sublocation_of(rule_location): + if move.location_id._child_of(routing_rule.location_src_id): # The source is already correct (or more precise than the routing), # but we still want to classify the move in the routing's picking # type. diff --git a/stock_dynamic_routing/models/stock_routing_rule.py b/stock_dynamic_routing/models/stock_routing_rule.py index 95ab26f7f2b..33029bf549b 100644 --- a/stock_dynamic_routing/models/stock_routing_rule.py +++ b/stock_dynamic_routing/models/stock_routing_rule.py @@ -79,7 +79,7 @@ def _constrains_picking_type_location(self): if record.method == "pull" and ( not record.location_src_id - or not record.location_src_id.is_sublocation_of(base_location) + or not record.location_src_id._child_of(base_location) ): raise exceptions.ValidationError( _( @@ -89,7 +89,7 @@ def _constrains_picking_type_location(self): ) elif record.method == "push" and ( not record.location_dest_id - or not record.location_dest_id.is_sublocation_of(base_location) + or not record.location_dest_id._child_of(base_location) ): raise exceptions.ValidationError( From 64bdc5822aa4ba20db8a65613bbbf1196a56a16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Mon, 22 Sep 2025 15:07:55 -0300 Subject: [PATCH 06/11] [IMP] stock_dynamic_routing: add internal routing details in context --- stock_dynamic_routing/models/stock_move.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index ac829427f91..40b75901c2f 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -255,6 +255,9 @@ def _apply_routing_rule_pull(self, routing_details): (move, detail.rule) for move, detail in routing_details.items() ] for move, routing_rule in routing_to_apply: + # Add the routing rule to the context + move = move.with_context(__routing_rule=routing_rule) + if not routing_rule: move_ids_to_assign_nonrelocated.append(move.id) continue @@ -437,9 +440,11 @@ def _apply_routing_rule_push(self, routing_details): pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: move_routing_details = routing_details[move] + routing_rule = move_routing_details.rule + # Add the routing details to the context + move = move.with_context(__routing_rule=routing_rule) # At this point, we should not have lines with different source # locations, they have been split by _routing_splits() - routing_rule = move_routing_details.rule if not routing_rule.method == "push": continue if move.picking_id.picking_type_id == routing_rule.picking_type_id: From 29e30945413b045ba9cca36c505f12d12ab1857e Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Wed, 5 Nov 2025 06:51:22 +0100 Subject: [PATCH 07/11] [FIX] stock_dynamic_routing: push putaway Ensure the putaway is computed considering the package if any is set --- stock_dynamic_routing/models/stock_move.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 40b75901c2f..9b875f50640 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -499,8 +499,13 @@ def _routing_push_insert_move(self, routing_rule, destination): goes to the correct place. For all other moves, the destination is the one of the picking type to respect the locations chain. """ - self.location_dest_id = routing_rule.location_src_id + # Do not compute putaway for the new move location dest by first + # setting the new location on the move line because the putaway will + # not consider the package self.move_line_ids.location_dest_id = routing_rule.location_src_id + self.location_dest_id = routing_rule.location_src_id + # Recompute putaway considering the package if any + self.move_line_ids._apply_putaway_strategy() routing_move = self._insert_routing_moves( routing_rule.picking_type_id, routing_rule.location_src_id, From 8c7007c625ced6f1f51ad5d168956382f638d8f4 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Fri, 3 Apr 2026 20:47:41 +0200 Subject: [PATCH 08/11] [IMP] stock_dynamic_routing: message Do not log that the initial demand has been updated when the move is split. The demand is still the same, it's just split in 2 moves. --- stock_dynamic_routing/models/__init__.py | 1 + stock_dynamic_routing/models/stock_move.py | 16 +++++++++++++++- stock_dynamic_routing/models/stock_move_line.py | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 stock_dynamic_routing/models/stock_move_line.py diff --git a/stock_dynamic_routing/models/__init__.py b/stock_dynamic_routing/models/__init__.py index 9e756f5e583..c8857f1bd2d 100644 --- a/stock_dynamic_routing/models/__init__.py +++ b/stock_dynamic_routing/models/__init__.py @@ -1,5 +1,6 @@ from . import stock_location from . import stock_move +from . import stock_move_line from . import stock_picking from . import stock_routing from . import stock_routing_rule diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 9b875f50640..aade5a173ab 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -228,7 +228,21 @@ def _routing_splits(self, moves_routing): # lines with different routing (or lines with a dynamic # routing, lines without). We split the lines according to # these. - new_move_vals = move._split(qty) + # NOTE: starting from Odoo 18.0, '_split' method doesn't check + # anymore the move quantity against the qty to split, and performs + # a split in all cases, letting an empty move behind. Avoid this. + new_move_vals = [] + if ( + float_compare( + move.product_qty, + qty, + precision_rounding=move.product_id.uom_id.rounding, + ) + == 1 + ): + new_move_vals = move.with_context(bypass_log_message=True)._split( + qty + ) if new_move_vals: new_move = self.env["stock.move"].create(new_move_vals) new_move._action_confirm(merge=False) diff --git a/stock_dynamic_routing/models/stock_move_line.py b/stock_dynamic_routing/models/stock_move_line.py new file mode 100644 index 00000000000..989651209d6 --- /dev/null +++ b/stock_dynamic_routing/models/stock_move_line.py @@ -0,0 +1,14 @@ +# Copyright 2026 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + def _log_message(self, record, move, template, vals): + if self.env.context.get("bypass_log_message"): + return + super()._log_message(record, move, template, vals) + return From 391f70ce6da290e9068ab04d0ee96dc2e710997b Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Mon, 30 Mar 2026 13:44:11 +0200 Subject: [PATCH 09/11] [FIX] stock_dynamic_routing: split move When a confirmed move is split, do not call _action_confirm on the new move --- stock_dynamic_routing/models/stock_move.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index aade5a173ab..adecdaf5a27 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -231,7 +231,7 @@ def _routing_splits(self, moves_routing): # NOTE: starting from Odoo 18.0, '_split' method doesn't check # anymore the move quantity against the qty to split, and performs # a split in all cases, letting an empty move behind. Avoid this. - new_move_vals = [] + new_move_vals_list = [] if ( float_compare( move.product_qty, @@ -240,12 +240,18 @@ def _routing_splits(self, moves_routing): ) == 1 ): - new_move_vals = move.with_context(bypass_log_message=True)._split( - qty + new_move_vals_list = move.with_context( + bypass_log_message=True + )._split(qty) + if new_move_vals_list: + confirm_dict = dict( + state=move.state, reservation_date=move.reservation_date ) - if new_move_vals: - new_move = self.env["stock.move"].create(new_move_vals) - new_move._action_confirm(merge=False) + [d.update(confirm_dict) for d in new_move_vals_list] + # Only create the move, do not call _action_confirm as + # since Odoo 15.0, it calls _action_assign() or we only + # want to split + new_move = self.env["stock.move"].create(new_move_vals_list) else: # If no split occurred keep the current move new_move = move From 33592e35838d0cb1e312e55c2d6495c273b8cb51 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Fri, 3 Apr 2026 13:47:38 +0200 Subject: [PATCH 10/11] [IMP] stock_dynamic_routing: _action_assign Do not call _action_assign inside _action_assign but use super(). Add hook for relocate before updating next moves. --- stock_dynamic_routing/models/stock_move.py | 169 +++++++++++------- .../tests/test_routing_push.py | 2 +- 2 files changed, 104 insertions(+), 67 deletions(-) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index adecdaf5a27..ce25e24ea38 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -7,7 +7,8 @@ from psycopg2 import sql -from odoo import models +from odoo import api, models +from odoo.tools.float_utils import float_compare _logger = logging.getLogger(__name__) @@ -57,18 +58,16 @@ def _chain_apply_routing(self): ) else: moves_with_routing_details[move] = self._no_routing_details() - - self._apply_routing_rule_pull(moves_with_routing_details) - self._apply_routing_rule_push(moves_with_routing_details) + self._apply_routing_rule(moves_with_routing_details) def _action_assign(self, force_qty=False): if self.env.context.get("exclude_apply_dynamic_routing"): - return super()._action_assign(force_qty=force_qty) + super()._action_assign(force_qty=force_qty) else: - # these methods will call _action_assign in a savepoint - # and modify the routing if necessary - moves = self._split_and_apply_routing() - return super(StockMove, moves)._action_assign() + # These methods will call _action_assign in a savepoint and modify + # the routing if necessary. Call to super is done in the method. + self._split_and_apply_routing() + return def _split_and_apply_routing(self): """Apply routing rules @@ -78,10 +77,6 @@ def _split_and_apply_routing(self): * split the moves if their move lines have different source or destination locations and need routing * apply the routing rules (pull and push) - - Important: if you inherit this method to skip the routing for some - moves, the method has to return the moves in ``self`` so they are - assigned. """ moves_routing = self._prepare_routing_pull() if not moves_routing: @@ -89,13 +84,11 @@ def _split_and_apply_routing(self): # called _action_assign(), returning an empty recordset will # prevent the caller of the method to call _action_assign() again # on the same moves - return self.browse() + return # apply the routing moves_with_routing_details = self._routing_splits(moves_routing) moves = self.browse(move.id for move in moves_with_routing_details) - moves._apply_routing_rule_pull(moves_with_routing_details) - moves._apply_routing_rule_push(moves_with_routing_details) - return moves + moves._apply_routing_rule(moves_with_routing_details, assign=True) def _prepare_routing_pull(self): """Prepare pull routing rules for moves @@ -259,7 +252,47 @@ def _routing_splits(self, moves_routing): return moves_with_routing_details - def _apply_routing_rule_pull(self, routing_details): + def _after_apply_dynamic_routing_rule(self): + # Hook used by stock_move_source_relocate to also relocate confirmed + # moves causing them to get merged back with relocated available moves. + # That will prevent next moves to be split. + return self + + def _apply_routing_rule(self, routing_details, assign=False): + non_relocated_moves = self.browse() + pull_routing_details = {} + push_routing_details = {} + for move in self: + move_routing_details = routing_details[move] + routing_rule = move_routing_details.rule + if not routing_rule: + non_relocated_moves |= move + continue + if move.picking_type_id == routing_rule.picking_type_id: + # no routing to apply + non_relocated_moves |= move + continue + if routing_rule.method == "pull": + pull_routing_details[move] = move_routing_details + if routing_rule.method == "push": + push_routing_details[move] = move_routing_details + push_moves = self._apply_routing_rule_push(push_routing_details) + next_moves_to_update = self._apply_routing_rule_pull( + pull_routing_details, assign=assign + ) + moves = non_relocated_moves | push_moves + moves = moves._after_apply_dynamic_routing_rule() + if assign: + # assign the moves that have not been relocated. + # Do this before updating next moves. + super(StockMove, moves)._action_assign() + # Update the destination moves. This might trigger a new routing on the + # destination move. + next_moves_to_update._routing_pull_switch_source() + return + + @api.model + def _apply_routing_rule_pull(self, routing_details, assign=False): """Apply pull dynamic routing When a move has a dynamic routing configured on its location and the @@ -269,49 +302,49 @@ def _apply_routing_rule_pull(self, routing_details): """ pickings_to_check_for_emptiness = self.env["stock.picking"] move_ids_to_assign_per_location = defaultdict(list) - move_ids_to_assign_nonrelocated = [] next_moves_to_update = self.browse() routing_to_apply = [ (move, detail.rule) for move, detail in routing_details.items() ] for move, routing_rule in routing_to_apply: - # Add the routing rule to the context + # Add the routing rule to the context for stock_dynamic_routing_delivery move = move.with_context(__routing_rule=routing_rule) - if not routing_rule: - move_ids_to_assign_nonrelocated.append(move.id) - continue - - if routing_rule.method == "push": - # In this case, we should not assign the move inside the - # pull dynamic routing. The push move must first be added before the - # initial move is assigned. Otherwise, when the destination - # location of the initial move is changed by the push rule, the - # putaway won't be recomputed as it is already assigned. - continue - - if move.picking_id.picking_type_id == routing_rule.picking_type_id: - # already correct - move_ids_to_assign_nonrelocated.append(move.id) - continue - # we expect all the lines to go to the same destination for # pull routing rules original_destination = move.location_dest_id current_picking_type = move.picking_id.picking_type_id + _logger.debug( + f"Rerouting {move} with pull rule of transfer {move.picking_id.name}" + ) # Use the source location of the routing rule, if not already done # If a sublocation is used, it's respected. if not move.location_id._child_of(routing_rule.location_src_id): + _logger.debug( + "- changed source location: " + f"{routing_rule.location_src_id.display_name}" + ) move.with_context( __applying_routing_rule=True ).location_id = routing_rule.location_src_id # Use the picking type of the routing rule, if not already done if move.picking_type_id != routing_rule.picking_type_id: + _logger.debug( + f"- changed picking type: {routing_rule.picking_type_id.name}" + ) move.picking_type_id = routing_rule.picking_type_id - if routing_rule.location_dest_id._child_of(move.location_dest_id): + if move.location_dest_id == routing_rule.location_dest_id: + next_moves_to_update |= move.move_dest_ids.filtered( + lambda r: r.state == "waiting" + ) + elif routing_rule.location_dest_id._child_of(move.location_dest_id): + _logger.debug( + "- changed destination location: " + f"{routing_rule.location_dest_id.display_name}" + ) # The destination of the move, is a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one. @@ -323,6 +356,10 @@ def _apply_routing_rule_pull(self, routing_details): lambda r: r.state == "waiting" ) elif not move.location_dest_id._child_of(routing_rule.location_dest_id): + _logger.debug( + "- changed destination location: " + f"{routing_rule.location_dest_id.display_name}" + ) # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move after to reach the original destination @@ -389,28 +426,28 @@ def _apply_routing_rule_pull(self, routing_details): # by another move, sort the moves by their source location, from the # most precise to the least precise. The order of the move ids within # one location is preserved. - # - # The non routed moves are assigned at last. This allows compatibility - # with the module stock_move_source_relocate and ensures we call - # _action_assign on the complete set of moves - sorted_locations = sorted( - move_ids_to_assign_per_location, key=lambda l: l.parent_path, reverse=True - ) - to_assign_ids = [] - for location in sorted_locations: - to_assign_ids += move_ids_to_assign_per_location[location] - to_assign_ids += move_ids_to_assign_nonrelocated - self.browse(to_assign_ids).with_context( - exclude_apply_dynamic_routing=True - )._action_assign() + if assign: + sorted_locations = sorted( + move_ids_to_assign_per_location, + key=lambda loc: loc.parent_path, + reverse=True, + ) + to_assign_ids = [] + for location in sorted_locations: + to_assign_ids += move_ids_to_assign_per_location[location] - # Update the destination moves. This might trigger a new routing on the - # destination move. - next_moves_to_update._routing_pull_switch_source() + super( + StockMove, + self.browse(to_assign_ids).with_context( + exclude_apply_dynamic_routing=True + ), + )._action_assign() pickings_to_check_for_emptiness._dynamic_routing_handle_empty() + return next_moves_to_update + def _routing_pull_switch_source(self): """Switch the source location of the move in place. @@ -449,6 +486,7 @@ def _routing_pull_switch_source(self): lambda r: r.state == "waiting" )._chain_apply_routing() + @api.model def _apply_routing_rule_push(self, routing_details): """Apply push dynamic routing @@ -458,19 +496,13 @@ def _apply_routing_rule_push(self, routing_details): the routing ones and creates a new chained move after it. """ pickings_to_check_for_emptiness = self.env["stock.picking"] - for move in self: - move_routing_details = routing_details[move] + push_moves = self.browse() + for move, move_routing_details in routing_details.items(): routing_rule = move_routing_details.rule - # Add the routing details to the context + push_moves |= move + # Add the routing rule to the context for stock_dynamic_routing_delivery move = move.with_context(__routing_rule=routing_rule) - # At this point, we should not have lines with different source - # locations, they have been split by _routing_splits() - if not routing_rule.method == "push": - continue - if move.picking_id.picking_type_id == routing_rule.picking_type_id: - # the routing rule has already been applied and re-classified - # the move in the picking type - continue + if move.location_dest_id == routing_rule.location_src_id: # the routing rule has already been applied and added a new # routing move after this one @@ -495,6 +527,7 @@ def _apply_routing_rule_push(self, routing_details): ) pickings_to_check_for_emptiness._dynamic_routing_handle_empty() + return push_moves def _routing_push_switch_picking_type(self, routing_rule): """Switch the picking type of the move in place @@ -542,6 +575,10 @@ def _routing_push_insert_move(self, routing_rule, destination): def _insert_routing_moves(self, picking_type, location, destination): """Create a chained move for a routing rule""" self.ensure_one() + _logger.debug( + f"- changed with inserted move from {location.display_name} to " + f"{destination.display_name}" + ) dest_moves = self.move_dest_ids # Insert move between the source and destination for the new # operation diff --git a/stock_dynamic_routing/tests/test_routing_push.py b/stock_dynamic_routing/tests/test_routing_push.py index 288c6195456..072aa6e397d 100644 --- a/stock_dynamic_routing/tests/test_routing_push.py +++ b/stock_dynamic_routing/tests/test_routing_push.py @@ -437,7 +437,7 @@ def test_several_move_ids(self): moves = self.env["stock.move"].browse( move.id for move in moves_with_routing_details ) - moves._apply_routing_rule_push(moves_with_routing_details) + moves._apply_routing_rule(moves_with_routing_details) moves._action_assign() # At this point, we should have this From c49bf073dedd142a8d4dd3f8a3be22c3dba55e0e Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Mon, 13 Apr 2026 17:54:28 +0200 Subject: [PATCH 11/11] stock_dynamic_routing: set tests as post_install --- stock_dynamic_routing/tests/test_routing_pull.py | 3 ++- stock_dynamic_routing/tests/test_routing_push.py | 3 ++- stock_dynamic_routing/tests/test_routing_rule.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/stock_dynamic_routing/tests/test_routing_pull.py b/stock_dynamic_routing/tests/test_routing_pull.py index b36f375e282..afb71700843 100644 --- a/stock_dynamic_routing/tests/test_routing_pull.py +++ b/stock_dynamic_routing/tests/test_routing_pull.py @@ -1,8 +1,9 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -from odoo.tests import common +from odoo.tests import common, tagged +@tagged("post_install", "-at_install") class TestRoutingPullCommon(common.TransactionCase): @classmethod def setUpClass(cls): diff --git a/stock_dynamic_routing/tests/test_routing_push.py b/stock_dynamic_routing/tests/test_routing_push.py index 072aa6e397d..f2e4b0c2c33 100644 --- a/stock_dynamic_routing/tests/test_routing_push.py +++ b/stock_dynamic_routing/tests/test_routing_push.py @@ -1,8 +1,9 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -from odoo.tests import common +from odoo.tests import common, tagged +@tagged("post_install", "-at_install") class TestRoutingPush(common.TransactionCase): @classmethod def setUpClass(cls): diff --git a/stock_dynamic_routing/tests/test_routing_rule.py b/stock_dynamic_routing/tests/test_routing_rule.py index 72e6a18d063..09d302ed715 100644 --- a/stock_dynamic_routing/tests/test_routing_rule.py +++ b/stock_dynamic_routing/tests/test_routing_rule.py @@ -1,8 +1,9 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -from odoo.tests import common +from odoo.tests import common, tagged +@tagged("post_install", "-at_install") class TestRoutingRule(common.TransactionCase): @classmethod def setUpClass(cls):