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.py b/stock_full_location_reservation/models/stock_move.py index cdc0fae7f7d..ea5b373688f 100644 --- a/stock_full_location_reservation/models/stock_move.py +++ b/stock_full_location_reservation/models/stock_move.py @@ -1,5 +1,8 @@ # 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 @@ -72,7 +75,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 @@ -86,5 +92,20 @@ 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, + reservation_mode: Literal["strict", "package", "product"] | 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" + return self.move_line_ids._full_location_reservation( + 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 aee2844f9bc..54b0da5dd4c 100644 --- a/stock_full_location_reservation/models/stock_move_line.py +++ b/stock_full_location_reservation/models/stock_move_line.py @@ -1,6 +1,9 @@ +# 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 from collections import defaultdict +from typing import Literal from odoo import models from odoo.osv import expression @@ -10,7 +13,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)] @@ -19,15 +24,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 @@ -42,24 +57,104 @@ 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) - moves_to_assign_ids = [] + 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 - # Copy location and package as move line could be deleted if merge occurs + 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(): - moves_to_assign_ids.append( + move_ids.append( line.move_id._full_location_reservation_create_move( product, qty, location, package ).id ) reservable_qties[location].pop(package) - moves_to_assign = self.env["stock.move"].browse(moves_to_assign_ids) + 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", "product"] | 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" + 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: + 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/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 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..21699ef5f84 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(reservation_mode="strict") + + 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, @@ -104,11 +170,82 @@ 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) + 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. @@ -141,5 +278,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)