From b9041768c3680a44ca0ca5553980ebaa51ab041c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 24 Jun 2026 08:21:31 +0200 Subject: [PATCH 1/6] [IMP] stock_full_location_reservation: Don't merge if products are different --- stock_full_location_reservation/models/stock_move.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stock_full_location_reservation/models/stock_move.py b/stock_full_location_reservation/models/stock_move.py index cdc0fae7f7d..31c596b67a2 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -72,7 +72,10 @@ def _full_location_reservation_create_move( product, qty, location, package ) ) - if self.picking_type_id.merge_move_for_full_location_reservation: + if ( + self.product_id == product + and self.picking_type_id.merge_move_for_full_location_reservation + ): # To be able to be merged, the new move should use the same source location as # the original one. new_move.location_id = self.location_id From 8d5db38f858d7a79c7005d609ee791be7c8df9ce Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 24 Jun 2026 08:22:08 +0200 Subject: [PATCH 2/6] [IMP] stock_full_location_reservation: Introduce a strict mode As in some flows we need to limit the full reservation to the move line characteristics (lot, package, owner), add a strict mode that will use the Odoo core mechanism to gather the needed quant(ities) --- .../models/stock_move.py | 6 +- .../models/stock_move_line.py | 55 ++++++++++----- .../tests/common.py | 6 ++ .../tests/test_full_location_reservation.py | 67 +++++++++++++++++++ 4 files changed, 117 insertions(+), 17 deletions(-) diff --git a/stock_full_location_reservation/models/stock_move.py b/stock_full_location_reservation/models/stock_move.py index 31c596b67a2..6fd10486411 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -89,5 +89,7 @@ def _full_location_reservation_create_move( ) return new_move - def _full_location_reservation(self, package_only=None): - return self.move_line_ids._full_location_reservation(package_only) + def _full_location_reservation(self, strict=False, package_only=None): + return self.move_line_ids._full_location_reservation( + strict=strict, package_only=package_only + ) diff --git a/stock_full_location_reservation/models/stock_move_line.py b/stock_full_location_reservation/models/stock_move_line.py index aee2844f9bc..bbbff8ed582 100644 --- a/stock_full_location_reservation/models/stock_move_line.py +++ b/stock_full_location_reservation/models/stock_move_line.py @@ -42,23 +42,48 @@ def _get_full_location_reservable_qties(self, package_only=None): ] += qty_available return res - def _full_location_reservation(self, package_only=None): - reservable_qties = self._get_full_location_reservable_qties(package_only) + def _full_location_reservation(self, strict=False, package_only=None): moves_to_assign_ids = [] - for line in self.exists(): # Move line should have been deleted - # Copy location and package as move line could be deleted if merge occurs - location = line.location_id - package = line.package_id - qties = reservable_qties.get(location, {}).get(package, {}) - if not qties: - continue - for product, qty in qties.items(): - moves_to_assign_ids.append( - line.move_id._full_location_reservation_create_move( - product, qty, location, package - ).id + if not strict: + reservable_qties = self._get_full_location_reservable_qties( + package_only=package_only + ) + for line in self.exists(): + location = line.location_id + package = line.package_id + qties = reservable_qties.get(location, {}).get(package, {}) + if not qties: + continue + for product, qty in qties.items(): + moves_to_assign_ids.append( + line.move_id._full_location_reservation_create_move( + product, qty, location, package + ).id + ) + reservable_qties[location].pop(package) + + else: + # Use Odoo core mechanism + Quant = self.env["stock.quant"] + + for line in self.exists(): # Move line should have been deleted + quants = Quant._gather( + line.product_id, + line.location_id, + lot_id=line.lot_id, + package_id=line.package_id, + owner_id=line.owner_id, + strict=strict, ) - reservable_qties[location].pop(package) + if not quants: + continue + + total_quantity = 0.0 + for quant in quants: + total_quantity += quant.available_quantity + # We let the core mechanism occur that will reserve + # the needed quants + line.reserved_uom_qty += total_quantity moves_to_assign = self.env["stock.move"].browse(moves_to_assign_ids) if moves_to_assign: moves_to_assign._action_confirm() diff --git a/stock_full_location_reservation/tests/common.py b/stock_full_location_reservation/tests/common.py index ede76445b32..1e190d1f4d1 100644 --- a/stock_full_location_reservation/tests/common.py +++ b/stock_full_location_reservation/tests/common.py @@ -16,6 +16,12 @@ def setUpClass(cls): cls.location_rack_child = cls.location.create( {"name": "Rack child", "location_id": cls.location_rack.id} ) + cls.location_rack_child_2 = cls.location.create( + {"name": "Rack child 2", "location_id": cls.location_rack.id} + ) + cls.location_rack_child_3 = cls.location.create( + {"name": "Rack child 3", "location_id": cls.location_rack.id} + ) cls.customer_location = cls.env.ref("stock.stock_location_customers") def _create_quant(self, product, location, qty, package=None): diff --git a/stock_full_location_reservation/tests/test_full_location_reservation.py b/stock_full_location_reservation/tests/test_full_location_reservation.py index 8bb0267e375..a231740b4c4 100644 --- a/stock_full_location_reservation/tests/test_full_location_reservation.py +++ b/stock_full_location_reservation/tests/test_full_location_reservation.py @@ -51,6 +51,72 @@ def test_full_location_reservation(self): self._check_move_line_len(picking, 1) self._check_move_line_len(picking, 0, self._filter_func) + def test_multi_lines_per_move(self): + """ + We create : + + - Quantity of 10 on Rack of Product A + - Quantity of 10 on Rack 2 of Product A + - Quantity of 10 on Rack 3 of Product A + + We create a picking of 30 from parent Rack, all quantities should + be reserved. + + Then, we update the Rack to 60 and we launch the full reservation + on move 1. + + The moves lines should remains, the reserved total quantity should + be 80 when the original demand should remain. + + """ + self.picking_type.merge_move_for_full_location_reservation = True + self._create_quants( + [ + (self.productA, self.location_rack_child, 10.0), + (self.productA, self.location_rack_child_2, 10.0), + (self.productA, self.location_rack_child_3, 10.0), + (self.productB, self.location_rack_child, 15.0), + ] + ) + picking = self._create_picking( + self.location_rack, + self.customer_location, + self.picking_type, + [[self.productA, 30]], + ) + picking.action_confirm() + picking.action_assign() + self.assertEqual(1, len(picking.move_ids)) + move_line_ids = picking.move_line_ids + self.assertEqual(3, len(picking.move_line_ids)) + + self._create_quants( + [ + (self.productA, self.location_rack_child, 50.0), + ] + ) + + self.assertEqual( + 60.0, + self.productA.with_context( + location=self.location_rack_child.id + ).qty_available, + ) + + picking.move_line_ids[0]._full_location_reservation(strict=True) + + self.assertEqual(3, len(picking.move_line_ids)) + + self.assertEqual( + move_line_ids, + picking.move_line_ids, + ) + self.assertEqual(picking.move_line_ids[0].reserved_uom_qty, 60.0) + + # The original demand stays at 30.0 + self.assertEqual(picking.move_ids.product_uom_qty, 30.0) + self.assertEqual(picking.move_ids.reserved_availability, 80.0) + def test_multiple_pickings(self): picking = self._create_picking( self.location_rack, @@ -141,5 +207,6 @@ def test_full_location_reservation_merge(self): picking.do_full_location_reservation() self._check_move_line_len(picking, 1) + # The original demand remains the same self.assertEqual(10.0, picking.move_ids.product_uom_qty) self.assertEqual(10.0, picking.move_ids.reserved_availability) From 11b7e5884d78613934d858030a29344f096419dd Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 24 Jun 2026 08:46:04 +0200 Subject: [PATCH 3/6] [IMP] stock_full_location_reservation: Add reservation_mode Replace the 'strict' and 'package_only' parameters with a new reservation_mode parameter that can be set to "strict", "package" . The default behavior is the same as before but it ensures that the reservation mode is unique and can be extended in the future with new modes. --- .../models/stock_move.py | 15 +++++++++++-- .../models/stock_move_line.py | 22 ++++++++++++++----- .../tests/test_full_location_reservation.py | 4 ++-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/stock_full_location_reservation/models/stock_move.py b/stock_full_location_reservation/models/stock_move.py index 6fd10486411..4d002c2d32b 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -1,5 +1,7 @@ # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import warnings + from odoo import fields, models @@ -89,7 +91,16 @@ def _full_location_reservation_create_move( ) return new_move - def _full_location_reservation(self, strict=False, package_only=None): + def _full_location_reservation(self, reservation_mode=None, **kwargs): + if "package_only" in kwargs: + warnings.warn( + "The 'package_only' parameter is deprecated. " + "Use reservation_mode='package' instead.", + DeprecationWarning, + stacklevel=2, + ) + if kwargs.pop("package_only"): + reservation_mode = "package" return self.move_line_ids._full_location_reservation( - strict=strict, package_only=package_only + reservation_mode=reservation_mode ) diff --git a/stock_full_location_reservation/models/stock_move_line.py b/stock_full_location_reservation/models/stock_move_line.py index bbbff8ed582..5ac3e656800 100644 --- a/stock_full_location_reservation/models/stock_move_line.py +++ b/stock_full_location_reservation/models/stock_move_line.py @@ -1,6 +1,8 @@ # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import warnings from collections import defaultdict +from typing import Literal from odoo import models from odoo.osv import expression @@ -42,7 +44,20 @@ def _get_full_location_reservable_qties(self, package_only=None): ] += qty_available return res - def _full_location_reservation(self, strict=False, package_only=None): + def _full_location_reservation( + self, reservation_mode: Literal["strict", "package"] | None = None, **kwargs + ): + if "package_only" in kwargs: + warnings.warn( + "The 'package_only' parameter is deprecated. " + "Use reservation_mode='package' instead.", + DeprecationWarning, + stacklevel=2, + ) + if kwargs.pop("package_only"): + reservation_mode = "package" + strict = reservation_mode == "strict" + package_only = reservation_mode == "package" moves_to_assign_ids = [] if not strict: reservable_qties = self._get_full_location_reservable_qties( @@ -61,11 +76,9 @@ def _full_location_reservation(self, strict=False, package_only=None): ).id ) reservable_qties[location].pop(package) - else: # Use Odoo core mechanism Quant = self.env["stock.quant"] - for line in self.exists(): # Move line should have been deleted quants = Quant._gather( line.product_id, @@ -73,11 +86,10 @@ def _full_location_reservation(self, strict=False, package_only=None): lot_id=line.lot_id, package_id=line.package_id, owner_id=line.owner_id, - strict=strict, + strict=True, ) if not quants: continue - total_quantity = 0.0 for quant in quants: total_quantity += quant.available_quantity diff --git a/stock_full_location_reservation/tests/test_full_location_reservation.py b/stock_full_location_reservation/tests/test_full_location_reservation.py index a231740b4c4..c5fd5c038ae 100644 --- a/stock_full_location_reservation/tests/test_full_location_reservation.py +++ b/stock_full_location_reservation/tests/test_full_location_reservation.py @@ -103,7 +103,7 @@ def test_multi_lines_per_move(self): ).qty_available, ) - picking.move_line_ids[0]._full_location_reservation(strict=True) + picking.move_line_ids[0]._full_location_reservation(reservation_mode="strict") self.assertEqual(3, len(picking.move_line_ids)) @@ -170,7 +170,7 @@ def test_package_only(self): self.assertEqual(picking.move_line_ids.package_id, package) self._check_move_line_len(picking, 2) - picking.move_ids._full_location_reservation(package_only=True) + picking.move_ids._full_location_reservation(reservation_mode="package") self._check_move_line_len(picking, 3) self.assertEqual(picking.move_line_ids.package_id, package) self.assertEqual(sum(picking.move_line_ids.mapped("reserved_qty")), 11) From 9556d2ac2a666cc974a9a9f70faf6cdc6858116e Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 24 Jun 2026 09:37:24 +0200 Subject: [PATCH 4/6] [IMP] stock_full_location_reservation: Add new reservation_mode "product" Added a new reservation_mode "product" to the stock_full_location_reservation module. This mode allows for reserving all the same product at location, regardless of the package or lot. --- .../models/stock_move.py | 7 +- .../models/stock_move_line.py | 149 ++++++++++++------ .../tests/test_full_location_reservation.py | 71 +++++++++ 3 files changed, 180 insertions(+), 47 deletions(-) diff --git a/stock_full_location_reservation/models/stock_move.py b/stock_full_location_reservation/models/stock_move.py index 4d002c2d32b..ea5b373688f 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -1,6 +1,7 @@ # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import warnings +from typing import Literal from odoo import fields, models @@ -91,7 +92,11 @@ def _full_location_reservation_create_move( ) return new_move - def _full_location_reservation(self, reservation_mode=None, **kwargs): + def _full_location_reservation( + self, + reservation_mode: Literal["strict", "package", "product"] | None = None, + **kwargs + ): if "package_only" in kwargs: warnings.warn( "The 'package_only' parameter is deprecated. " diff --git a/stock_full_location_reservation/models/stock_move_line.py b/stock_full_location_reservation/models/stock_move_line.py index 5ac3e656800..33b92574d26 100644 --- a/stock_full_location_reservation/models/stock_move_line.py +++ b/stock_full_location_reservation/models/stock_move_line.py @@ -12,7 +12,9 @@ class StockMoveLine(models.Model): _inherit = "stock.move.line" - def _prepare_full_location_reservation_quants_domain(self, package_only=None): + def _prepare_full_location_reservation_quants_domain( + self, package_only=None, product_only=False + ): domains = [] for line in self: domain = [("location_id", "=", line.location_id.id)] @@ -21,15 +23,25 @@ def _prepare_full_location_reservation_quants_domain(self, package_only=None): domain += [("package_id", "=", line.package_id.id)] else: continue + if product_only: + domain += [("product_id", "=", line.product_id.id)] domains.append(domain) return expression.OR(domains) - def _get_full_location_reservation_quants(self, package_only=None): - domain = self._prepare_full_location_reservation_quants_domain(package_only) + def _get_full_location_reservation_quants( + self, package_only=None, product_only=False + ): + domain = self._prepare_full_location_reservation_quants_domain( + package_only=package_only, product_only=product_only + ) return self.env["stock.quant"].search(domain) - def _get_full_location_reservable_qties(self, package_only=None): - quants = self._get_full_location_reservation_quants(package_only) + def _get_full_location_reservable_qties( + self, package_only=None, product_only=False + ): + quants = self._get_full_location_reservation_quants( + package_only=package_only, product_only=product_only + ) res = defaultdict(lambda: defaultdict(lambda: defaultdict(lambda: 0))) for quant in quants: qty_available = quant.available_quantity @@ -44,8 +56,84 @@ def _get_full_location_reservable_qties(self, package_only=None): ] += qty_available return res + def _full_location_reservation_strict(self): + """Reserve using Odoo's _gather with strict=True (exact lot/package/owner match). + + Increments reserved_uom_qty on existing move lines - no new moves created. + """ + Quant = self.env["stock.quant"] + for line in self.exists(): # Move line should have been deleted + quants = Quant._gather( + line.product_id, + line.location_id, + lot_id=line.lot_id, + package_id=line.package_id, + owner_id=line.owner_id, + strict=True, + ) + if not quants: + continue + # We let the core mechanism occur that will reserve the needed quants + line.reserved_uom_qty += sum(q.available_quantity for q in quants) + + def _full_location_reservation_product(self): + """Reserve all available qty of each line's product across every package + at the location - creates one new move per (package, product) combination. + """ + move_ids = [] + reservable_qties = self._get_full_location_reservable_qties(product_only=True) + for line in self.exists(): + location_qties = reservable_qties.get(line.location_id, {}) + for package in list(location_qties.keys()): + package_qties = location_qties[package] + qty = package_qties.pop(line.product_id, 0) + if not package_qties: + location_qties.pop(package) + if qty: + move_ids.append( + line.move_id._full_location_reservation_create_move( + line.product_id, qty, line.location_id, package + ).id + ) + return move_ids + + def _full_location_reservation_by_location(self, package_only=False): + """Shared implementation: creates one new move per (location, package, product) + combination found in reservable quantities. + """ + move_ids = [] + reservable_qties = self._get_full_location_reservable_qties( + package_only=package_only + ) + for line in self.exists(): + location = line.location_id + package = line.package_id + qties = reservable_qties.get(location, {}).get(package, {}) + if not qties: + continue + for product, qty in qties.items(): + move_ids.append( + line.move_id._full_location_reservation_create_move( + product, qty, location, package + ).id + ) + reservable_qties[location].pop(package) + return move_ids + + def _full_location_reservation_package(self): + """Reserve all products at each line's (location, package), + skipping lines that have no package. + """ + return self._full_location_reservation_by_location(package_only=True) + + def _full_location_reservation_default(self): + """Reserve all products at each line's (location, package).""" + return self._full_location_reservation_by_location() + def _full_location_reservation( - self, reservation_mode: Literal["strict", "package"] | None = None, **kwargs + self, + reservation_mode: Literal["strict", "package", "product"] | None = None, + **kwargs, ): if "package_only" in kwargs: warnings.warn( @@ -56,47 +144,16 @@ def _full_location_reservation( ) if kwargs.pop("package_only"): reservation_mode = "package" - strict = reservation_mode == "strict" - package_only = reservation_mode == "package" - moves_to_assign_ids = [] - if not strict: - reservable_qties = self._get_full_location_reservable_qties( - package_only=package_only - ) - for line in self.exists(): - location = line.location_id - package = line.package_id - qties = reservable_qties.get(location, {}).get(package, {}) - if not qties: - continue - for product, qty in qties.items(): - moves_to_assign_ids.append( - line.move_id._full_location_reservation_create_move( - product, qty, location, package - ).id - ) - reservable_qties[location].pop(package) + if reservation_mode == "strict": + self._full_location_reservation_strict() + return self.env["stock.move"] + elif reservation_mode == "product": + move_ids = self._full_location_reservation_product() + elif reservation_mode == "package": + move_ids = self._full_location_reservation_package() else: - # Use Odoo core mechanism - Quant = self.env["stock.quant"] - for line in self.exists(): # Move line should have been deleted - quants = Quant._gather( - line.product_id, - line.location_id, - lot_id=line.lot_id, - package_id=line.package_id, - owner_id=line.owner_id, - strict=True, - ) - if not quants: - continue - total_quantity = 0.0 - for quant in quants: - total_quantity += quant.available_quantity - # We let the core mechanism occur that will reserve - # the needed quants - line.reserved_uom_qty += total_quantity - moves_to_assign = self.env["stock.move"].browse(moves_to_assign_ids) + move_ids = self._full_location_reservation_default() + moves_to_assign = self.env["stock.move"].browse(move_ids) if moves_to_assign: moves_to_assign._action_confirm() moves_to_assign._action_assign() diff --git a/stock_full_location_reservation/tests/test_full_location_reservation.py b/stock_full_location_reservation/tests/test_full_location_reservation.py index c5fd5c038ae..21699ef5f84 100644 --- a/stock_full_location_reservation/tests/test_full_location_reservation.py +++ b/stock_full_location_reservation/tests/test_full_location_reservation.py @@ -175,6 +175,77 @@ def test_package_only(self): self.assertEqual(picking.move_line_ids.package_id, package) self.assertEqual(sum(picking.move_line_ids.mapped("reserved_qty")), 11) + def test_product_mode(self): + """ + Location has productA and productB. + After product mode on a productA move: only productA gets a new + full-reservation move — productB is left untouched. + """ + self._create_quants( + [ + (self.productA, self.location_rack_child, 10.0), + (self.productB, self.location_rack_child, 10.0), + ] + ) + picking = self._create_picking( + self.location_rack, + self.customer_location, + self.picking_type, + [[self.productA, 1]], + ) + picking.action_confirm() + picking.action_assign() + + self._check_move_line_len(picking, 1) + picking.move_ids._full_location_reservation(reservation_mode="product") + # Original move + 1 new full-reservation move for remaining productA + self._check_move_line_len(picking, 2) + self._check_move_line_len(picking, 1, self._filter_func) + full_reservation_moves = picking.move_ids.filtered(self._filter_func) + self.assertEqual(full_reservation_moves.product_id, self.productA) + # Total reserved = all 10 units of productA; productB untouched + self.assertEqual( + sum(picking.move_line_ids.mapped("reserved_uom_qty")), + 10.0, + ) + + def test_product_mode_multiple_packages(self): + """ + Location has productA in two package combinations (packaged and + unpackaged) plus productB. Move for productA (no package). + After product mode: new moves are created for each package combination + of productA — productB remains untouched. + """ + package = self.env["stock.quant.package"].create({"name": "test package"}) + self._create_quants( + [ + (self.productA, self.location_rack_child, 5.0), + (self.productA, self.location_rack_child, 7.0, package), + (self.productB, self.location_rack_child, 10.0), + ] + ) + picking = self._create_picking( + self.location_rack, + self.customer_location, + self.picking_type, + [[self.productA, 1]], + ) + picking.action_confirm() + picking.action_assign() + + self._check_move_line_len(picking, 1) + picking.move_ids._full_location_reservation(reservation_mode="product") + # Original move + 2 new full-reservation moves (one per package combo) + self._check_move_line_len(picking, 3) + self._check_move_line_len(picking, 2, self._filter_func) + full_reservation_moves = picking.move_ids.filtered(self._filter_func) + self.assertEqual(full_reservation_moves.product_id, self.productA) + # Total reserved: 1 (original) + 4 (remaining no-pkg) + 7 (packaged) = 12 + self.assertEqual( + sum(picking.move_line_ids.mapped("reserved_uom_qty")), + 12.0, + ) + def test_full_location_reservation_merge(self): """ Activate the merge for new quantity move. From 4d2d5b977134faf643d3ac6d4779bc73c7a75367 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 24 Jun 2026 09:49:24 +0200 Subject: [PATCH 5/6] [IMP] stock_full_location_reservation: ACSONE as author --- stock_full_location_reservation/__manifest__.py | 2 +- stock_full_location_reservation/models/stock_move_line.py | 1 + stock_full_location_reservation/readme/CONTRIBUTORS.rst | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/stock_full_location_reservation/__manifest__.py b/stock_full_location_reservation/__manifest__.py index 18a569e4740..aa5de32d3aa 100644 --- a/stock_full_location_reservation/__manifest__.py +++ b/stock_full_location_reservation/__manifest__.py @@ -2,7 +2,7 @@ { "name": "Stock full location reservation", "summary": "Extend reservation to full content of location", - "author": "MT Software, BCIM, Odoo Community Association (OCA)", + "author": "MT Software, BCIM, ACSONE SA/NV, Odoo Community Association (OCA)", "website": "https://github.com/OCA/wms", "category": "Warehouse Management", "version": "16.0.1.0.0", diff --git a/stock_full_location_reservation/models/stock_move_line.py b/stock_full_location_reservation/models/stock_move_line.py index 33b92574d26..54b0da5dd4c 100644 --- a/stock_full_location_reservation/models/stock_move_line.py +++ b/stock_full_location_reservation/models/stock_move_line.py @@ -1,3 +1,4 @@ +# Copyright 2026 ACSONE SA/NV # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import warnings diff --git a/stock_full_location_reservation/readme/CONTRIBUTORS.rst b/stock_full_location_reservation/readme/CONTRIBUTORS.rst index 866de0e85cb..679723b3255 100644 --- a/stock_full_location_reservation/readme/CONTRIBUTORS.rst +++ b/stock_full_location_reservation/readme/CONTRIBUTORS.rst @@ -1,3 +1,4 @@ * Michael Tietz (MT Software) * Jacques-Etienne Baudoux (BCIM) * Denis Roussel +* Laurent Mignon From b1d778a3d1d03afc9357d48f12c172854ac3c949 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Jun 2026 18:05:10 +0200 Subject: [PATCH 6/6] [FIX] stock_full_location_reservation: Do not unlink full location reservation moves on cancel The module stock_full_location_reservation has a feature that creates a new move for full location reservation when a move is partially reserved. However, when the original move is canceled, the new move is also unlinked, which can cause issues if the new move is still needed for other operations. This commit modifies the behavior of the module to prevent the unlinking of full location reservation moves when the original move is canceled. Instead, the deletion of the move is deferred until the end of the transaction, allowing other operations to complete without losing the move. --- .../models/stock_move.py | 47 ++++++++++++++++- .../tests/test_full_location_reservation.py | 51 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/stock_full_location_reservation/models/stock_move.py b/stock_full_location_reservation/models/stock_move.py index ea5b373688f..20c0cc9d9df 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -1,9 +1,12 @@ # Copyright 2023 Michael Tietz (MT Software) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging import warnings from typing import Literal -from odoo import fields, models +from odoo import api, fields, models, tools + +_logger = logging.getLogger(__name__) class StockMove(models.Model): @@ -13,6 +16,15 @@ class StockMove(models.Model): "Full location reservation move", default=False ) + def init(self): + tools.create_index( + self._cr, + "stock_move_full_loc_reservation_gc_idx", + self._table, + ["id"], + where="is_full_location_reservation AND state = 'cancel'", + ) + def _filter_full_location_reservation_moves(self): return self.filtered(lambda m: m.is_full_location_reservation) @@ -33,7 +45,38 @@ def _undo_full_location_reservation(self): self = self.with_context(skip_undo_full_location_reservation=True) self._do_unreserve() self._action_cancel() - self.unlink() + self._post_commit_unlink() + + def _post_commit_unlink(self): + # We need to defer the unlink of the moves until the end of the + # transaction to avoid issues where methods having a reference + # to the original move would try to access it after it has been unlinked + ids_to_unlink = self.ids + env = self.env + + def _deferred_unlink(): + try: + env["stock.move"].browse(ids_to_unlink).exists().unlink() + except Exception: + _logger.exception( + "Failed to unlink full location reservation moves %s", + ids_to_unlink, + ) + + self.env.cr.postcommit.add(_deferred_unlink) + + @api.autovacuum + def _gc_full_location_reservation_moves(self): + """This method is meant to be called by a cron and will delete full location + reservation moves that are still in canceled state. + + This should normally not be necessary as the moves should be unlinked right + after being canceled into the post commit hook of the transaction, + but this is a safety measure in case something goes wrong with the unlinking. + """ + self.search( + [("is_full_location_reservation", "=", True), ("state", "=", "cancel")] + ).unlink() def _prepare_full_location_reservation_package_level_vals(self, package): return { diff --git a/stock_full_location_reservation/tests/test_full_location_reservation.py b/stock_full_location_reservation/tests/test_full_location_reservation.py index 21699ef5f84..2ed4d9ae573 100644 --- a/stock_full_location_reservation/tests/test_full_location_reservation.py +++ b/stock_full_location_reservation/tests/test_full_location_reservation.py @@ -48,9 +48,60 @@ def test_full_location_reservation(self): picking.undo_full_location_reservation() + self.env["stock.move"]._gc_full_location_reservation_moves() + self._check_move_line_len(picking, 1) self._check_move_line_len(picking, 0, self._filter_func) + def test_full_location_reservation_and_cancel(self): + picking = self._create_picking( + self.location_rack, + self.customer_location, + self.picking_type, + [[self.productA, 5]], + ) + + picking.action_confirm() + self._check_move_line_len(picking, 1) + + picking.do_full_location_reservation() + self._check_move_line_len(picking, 1) + + self._create_quants( + [ + (self.productA, self.location_rack_child, 10.0), + (self.productB, self.location_rack_child, 10.0), + ] + ) + + original_moves = picking.move_ids + + picking.do_full_location_reservation() + self._check_move_line_len(picking, 1) + + picking.action_assign() + + picking.do_full_location_reservation() + + full_moves = picking.move_ids - original_moves + + self._check_move_line_len(picking, 3) + self._check_move_line_len(picking, 2, self._filter_func) + + # repeat test to check undo in do + picking.do_full_location_reservation() + + self._check_move_line_len(picking, 3) + self._check_move_line_len(picking, 2, self._filter_func) + + moves = picking.move_ids.filtered(self._filter_func) + self.assertEqual(moves.location_id, self.location_rack_child) + + picking.move_ids._action_cancel() + + for move in full_moves: + self.assertEqual("cancel", move.state) + def test_multi_lines_per_move(self): """ We create :