From 907a99697638c6c964d984cc61e8991352349ce4 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Wed, 10 Jun 2026 15:15:46 +0200 Subject: [PATCH 1/2] [IMP] stock_available_to_promise_release_block: add backorder auto-blocking modes Replace the boolean "Auto-block Release on Backorders" option by a selection field to support multiple blocking strategies. Available modes are: Never Always On single OUT move for customer The new customer-based mode automatically blocks a backorder only when it would become the sole outgoing move for the customer, helping avoid shipping a delivery containing a single late product. --- .../__manifest__.py | 2 +- .../migrations/16.0.1.2.0/post-migrate.py | 24 ++++++++ .../migrations/16.0.1.2.0/pre-migrate.py | 20 +++++++ .../models/stock_move.py | 34 ++++++++++- .../models/stock_picking.py | 3 +- .../models/stock_route.py | 10 +++- .../models/stock_rule.py | 2 +- .../readme/CONTRIBUTORS.rst | 1 + .../tests/test_block_release.py | 59 ++++++++++++++++++- .../views/stock_route.xml | 4 +- .../models/stock_move.py | 16 +++++ 11 files changed, 164 insertions(+), 11 deletions(-) create mode 100644 stock_available_to_promise_release_block/migrations/16.0.1.2.0/post-migrate.py create mode 100644 stock_available_to_promise_release_block/migrations/16.0.1.2.0/pre-migrate.py create mode 100644 stock_available_to_promise_release_block_lonely/models/stock_move.py diff --git a/stock_available_to_promise_release_block/__manifest__.py b/stock_available_to_promise_release_block/__manifest__.py index bc22452b7ca..03f19898a13 100644 --- a/stock_available_to_promise_release_block/__manifest__.py +++ b/stock_available_to_promise_release_block/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Stock Available to Promise Release - Block", "summary": """Block Release of Operations""", - "version": "16.0.1.1.1", + "version": "16.0.1.2.0", "license": "AGPL-3", "author": "Camptocamp, ACSONE SA/NV, BCIM, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", diff --git a/stock_available_to_promise_release_block/migrations/16.0.1.2.0/post-migrate.py b/stock_available_to_promise_release_block/migrations/16.0.1.2.0/post-migrate.py new file mode 100644 index 00000000000..96a967f225b --- /dev/null +++ b/stock_available_to_promise_release_block/migrations/16.0.1.2.0/post-migrate.py @@ -0,0 +1,24 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +def migrate(cr, version): + cr.execute( + """ + UPDATE stock_route + SET autoblock_release_on_backorder = + CASE + WHEN autoblock_release_on_backorder_legacy + THEN 'always' + ELSE 'never' + END + """ + ) + + cr.execute( + """ + ALTER TABLE stock_route + DROP COLUMN IF EXISTS + autoblock_release_on_backorder_legacy + """ + ) diff --git a/stock_available_to_promise_release_block/migrations/16.0.1.2.0/pre-migrate.py b/stock_available_to_promise_release_block/migrations/16.0.1.2.0/pre-migrate.py new file mode 100644 index 00000000000..42563c035a5 --- /dev/null +++ b/stock_available_to_promise_release_block/migrations/16.0.1.2.0/pre-migrate.py @@ -0,0 +1,20 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +def migrate(cr, version): + cr.execute( + """ + ALTER TABLE stock_route + ADD COLUMN IF NOT EXISTS + autoblock_release_on_backorder_legacy boolean + """ + ) + + cr.execute( + """ + UPDATE stock_route + SET autoblock_release_on_backorder_legacy = + autoblock_release_on_backorder + """ + ) diff --git a/stock_available_to_promise_release_block/models/stock_move.py b/stock_available_to_promise_release_block/models/stock_move.py index 32a716c175d..00bf2124944 100644 --- a/stock_available_to_promise_release_block/models/stock_move.py +++ b/stock_available_to_promise_release_block/models/stock_move.py @@ -31,8 +31,38 @@ def _is_release_ready(self): return False def _blocked_on_backorder(self): - """Hook that aims to be overridden.""" - return True + self.ensure_one() + mode = self.rule_id.autoblock_release_on_backorder + + if mode == "always": + return True + + if mode == "never": + return False + + if mode == "single_customer_outgoing_move": + if self.picking_code != "outgoing": + return False + + partner = self.partner_id + if not partner: + return False + + # Block if no other out moves for this client + other_move_exists = bool( + self.search_count( + [ + ("id", "!=", self.id), + ("partner_id", "=", partner.id), + ("state", "in", ["confirmed", "waiting", "assigned"]), + ("picking_code", "=", "outgoing"), + ], + limit=1, + ) + ) + return not other_move_exists + + return False def action_block_release(self): """Block the release.""" diff --git a/stock_available_to_promise_release_block/models/stock_picking.py b/stock_available_to_promise_release_block/models/stock_picking.py index f1eaefbb15f..cf1fcb3fc1d 100644 --- a/stock_available_to_promise_release_block/models/stock_picking.py +++ b/stock_available_to_promise_release_block/models/stock_picking.py @@ -54,8 +54,7 @@ def _create_backorder(self): backorders = super()._create_backorder() # Auto-block backorders for move in backorders.move_ids: - if move.rule_id.autoblock_release_on_backorder: - move.release_blocked = move._blocked_on_backorder() + move.release_blocked = move._blocked_on_backorder() return backorders def action_block_release(self): diff --git a/stock_available_to_promise_release_block/models/stock_route.py b/stock_available_to_promise_release_block/models/stock_route.py index 1fa1b17ca5c..dacb15e7432 100644 --- a/stock_available_to_promise_release_block/models/stock_route.py +++ b/stock_available_to_promise_release_block/models/stock_route.py @@ -7,7 +7,13 @@ class StockRoute(models.Model): _inherit = "stock.route" - autoblock_release_on_backorder = fields.Boolean( + autoblock_release_on_backorder = fields.Selection( string="Auto-block Release on Backorders", - default=False, + selection=[ + ("never", "Never"), + ("always", "Always"), + ("single_customer_outgoing_move", "On single OUT move for customer"), + ], + default="never", + required=True, ) diff --git a/stock_available_to_promise_release_block/models/stock_rule.py b/stock_available_to_promise_release_block/models/stock_rule.py index 5acad56caeb..375e0b6d631 100644 --- a/stock_available_to_promise_release_block/models/stock_rule.py +++ b/stock_available_to_promise_release_block/models/stock_rule.py @@ -7,6 +7,6 @@ class StockRule(models.Model): _inherit = "stock.rule" - autoblock_release_on_backorder = fields.Boolean( + autoblock_release_on_backorder = fields.Selection( related="route_id.autoblock_release_on_backorder", store=True ) diff --git a/stock_available_to_promise_release_block/readme/CONTRIBUTORS.rst b/stock_available_to_promise_release_block/readme/CONTRIBUTORS.rst index be6931494a4..efb615c3261 100644 --- a/stock_available_to_promise_release_block/readme/CONTRIBUTORS.rst +++ b/stock_available_to_promise_release_block/readme/CONTRIBUTORS.rst @@ -1,4 +1,5 @@ * ACSONE SA/NV + * Nicolas Delbovier (https://www.acsone.eu/) * BCIM: * Jacques-Etienne Baudoux * Camptocamp: diff --git a/stock_available_to_promise_release_block/tests/test_block_release.py b/stock_available_to_promise_release_block/tests/test_block_release.py index 04743af8959..104bfe92458 100644 --- a/stock_available_to_promise_release_block/tests/test_block_release.py +++ b/stock_available_to_promise_release_block/tests/test_block_release.py @@ -101,7 +101,7 @@ def test_block_release_allowed(self): self.assertFalse(picking.block_release_allowed) def test_autoblock_release_on_backorder(self): - self.wh.delivery_route_id.autoblock_release_on_backorder = True + self.wh.delivery_route_id.autoblock_release_on_backorder = "always" picking = self._out_picking( self._create_picking_chain( self.wh, @@ -118,3 +118,60 @@ def test_autoblock_release_on_backorder(self): # Backorder is not release ready and is automatically blocked self.assertFalse(backorder.release_ready) self.assertTrue(backorder.release_blocked) + + def test_autoblock_release_on_backorder_single_customer_move_block(self): + """Backorder is blocked when it becomes the only outgoing move.""" + self.wh.delivery_route_id.autoblock_release_on_backorder = ( + "single_customer_outgoing_move" + ) + + picking = self._out_picking( + self._create_picking_chain( + self.wh, + [(self.product1, 3), (self.product2, 5)], + ) + ) + + self._update_qty_in_location(self.loc_bin1, self.product1, 3.0) + + move = picking.move_ids.filtered(lambda m: m.product_id == self.product1) + move.quantity_done = move.product_uom_qty + picking.move_ids._action_done() + + backorder = picking.backorder_ids + + self.assertTrue(backorder.release_blocked) + self.assertFalse(backorder.release_ready) + + def test_autoblock_release_on_backorder_single_customer_move_not_blocked(self): + """Backorder is not blocked when another outgoing move exists.""" + self.wh.delivery_route_id.autoblock_release_on_backorder = ( + "single_customer_outgoing_move" + ) + + picking = self._out_picking( + self._create_picking_chain( + self.wh, + [(self.product1, 3), (self.product2, 5)], + ) + ) + + # Create another outgoing picking for the same customer + other_picking = self._out_picking( + self._create_picking_chain( + self.wh, + [(self.product3, 1)], + ) + ) + + self.assertTrue(other_picking) + + self._update_qty_in_location(self.loc_bin1, self.product1, 3.0) + + move = picking.move_ids.filtered(lambda m: m.product_id == self.product1) + move.quantity_done = move.product_uom_qty + picking.move_ids._action_done() + + backorder = picking.backorder_ids + + self.assertFalse(backorder.release_blocked) diff --git a/stock_available_to_promise_release_block/views/stock_route.xml b/stock_available_to_promise_release_block/views/stock_route.xml index 55a862172b9..39ac4f91fde 100644 --- a/stock_available_to_promise_release_block/views/stock_route.xml +++ b/stock_available_to_promise_release_block/views/stock_route.xml @@ -11,12 +11,12 @@ ref="stock_available_to_promise_release.stock_route_form_view" /> - + - + diff --git a/stock_available_to_promise_release_block_lonely/models/stock_move.py b/stock_available_to_promise_release_block_lonely/models/stock_move.py new file mode 100644 index 00000000000..b75a6c82fd1 --- /dev/null +++ b/stock_available_to_promise_release_block_lonely/models/stock_move.py @@ -0,0 +1,16 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockMove(models.Model): + + _inherit = "stock.move" + + def _blocked_on_backorder(self): + res = super()._blocked_on_backorder() + + ... + + return res From 129f3582d14453b61468bdc82d3a90a095f2ede3 Mon Sep 17 00:00:00 2001 From: Nicolas Delbovier Date: Wed, 10 Jun 2026 16:13:25 +0200 Subject: [PATCH 2/2] [FIX] stock_available_to_promise_release_block: duplicate label warning --- stock_available_to_promise_release_block/models/stock_move.py | 1 - stock_available_to_promise_release_block/models/stock_picking.py | 1 - 2 files changed, 2 deletions(-) diff --git a/stock_available_to_promise_release_block/models/stock_move.py b/stock_available_to_promise_release_block/models/stock_move.py index 00bf2124944..0e7d0ee536c 100644 --- a/stock_available_to_promise_release_block/models/stock_move.py +++ b/stock_available_to_promise_release_block/models/stock_move.py @@ -10,7 +10,6 @@ class StockMove(models.Model): release_blocked = fields.Boolean(readonly=True) release_blocked_label = fields.Char( - string="Release Blocked", compute="_compute_release_blocked_label", ) diff --git a/stock_available_to_promise_release_block/models/stock_picking.py b/stock_available_to_promise_release_block/models/stock_picking.py index cf1fcb3fc1d..02a88805567 100644 --- a/stock_available_to_promise_release_block/models/stock_picking.py +++ b/stock_available_to_promise_release_block/models/stock_picking.py @@ -19,7 +19,6 @@ class StockPicking(models.Model): tracking=True, ) release_blocked_label = fields.Char( - string="Release Blocked", compute="_compute_release_blocked_label", )