diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 56136fec..1e18c28c 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -1,5 +1,6 @@ # Copyright 2019 Camptocamp SA # Copyright 2019 Jacques-Etienne Baudoux (BCIM) +# Copyright 2025 Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import models from odoo.tools.float_utils import float_compare @@ -102,7 +103,9 @@ def _update_reserved_quantity( next_quant = strategy.send(still_need) if not next_quant: continue - location, location_quantity, to_take = next_quant + to_take, location, lot, pack, owner, strict_reservation = ( + next_quant + ) taken_in_loc = super( StockMove, self.with_context(**exclusion_ctx) )._update_reserved_quantity( @@ -110,10 +113,10 @@ def _update_reserved_quantity( # from this bin to_take, location_id=location, - lot_id=lot_id, - package_id=package_id, - owner_id=owner_id, - strict=strict, + lot_id=lot, + package_id=pack, + owner_id=owner, + strict=strict_reservation, ) still_need -= taken_in_loc # We should break between quants if original needs is fulfilled diff --git a/stock_reserve_rule/models/stock_quant.py b/stock_reserve_rule/models/stock_quant.py index 582f8fa2..5a8779e9 100644 --- a/stock_reserve_rule/models/stock_quant.py +++ b/stock_reserve_rule/models/stock_quant.py @@ -1,8 +1,6 @@ -# Copyright 2019 Camptocamp SA +# Copyright 2025 Akretion # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from collections import OrderedDict - from odoo import models from odoo.osv import expression @@ -10,26 +8,6 @@ class StockQuant(models.Model): _inherit = "stock.quant" - def _group_by_location(self): - """Return quants grouped by locations - - Group by location, but keeping the order of the quants (if we have more - than one quant per location, the order is based on the first quant seen - in the location). Thus, it can be used on a recordset returned by - _gather. - - The returned format is: [(location, quants)] - - """ - seen = OrderedDict() - for quant in self: - location = quant.location_id - if location in seen: - seen[location] = seen[location] | quant - else: - seen[location] = quant - return [(loc, quants) for loc, quants in seen.items()] - def _get_gather_domain( self, product_id, diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index d23dec3f..c470c889 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -210,16 +210,19 @@ def _apply_strategy_default(self, quants): # the _gather method, so based on fifo, fefo, ... for quant in quants: need = yield ( - quant.location_id, - quant.quantity - quant.reserved_quantity, need, + quant.location_id, + self.env["stock.lot"].browse(), + self.env["stock.quant.package"].browse(), + self.env["res.partner"].browse(), + False, ) def _apply_strategy_empty_bin(self, quants): need = yield # Group by location (in this removal strategies, we want to consider # the total quantity held in a location). - quants_per_bin = quants._group_by_location() + quants_per_bin = quants.grouped("location_id") # We take goods only if we empty the bin. # The original ordering (fefo, fifo, ...) must be kept. product = fields.first(quants).product_id @@ -236,7 +239,7 @@ def _apply_strategy_empty_bin(self, quants): "location_id", ) ] - for location, location_quants in quants_per_bin: + for location, location_quants in quants_per_bin.items(): if location.id in locations_with_other_quants: continue @@ -251,14 +254,22 @@ def _apply_strategy_empty_bin(self, quants): float_compare(need, location_quantity, precision_rounding=rounding) != -1 ): - need = yield location, location_quantity, need + need = yield ( + need, + location, + self.env["stock.lot"].browse(), + self.env["stock.quant.package"].browse(), + self.env["res.partner"].browse(), + False, + ) def _apply_strategy_packaging(self, quants): need = yield # Group by location (in this removal strategies, we want to consider # the total quantity held in a location). - quants_per_bin = quants._group_by_location() - + quants_per_key = quants.grouped( + key=lambda qu: (qu.location_id, qu.lot_id, qu.package_id, qu.owner_id) + ) product = fields.first(quants).product_id packaging_type_filter = self.packaging_level_ids @@ -285,7 +296,7 @@ def is_greater_eq(value, other): return float_compare(value, other, precision_rounding=rounding) >= 0 for pack_quantity in packaging_quantities: - for location, location_quants in quants_per_bin: + for (location, lot, pack, owner), location_quants in quants_per_key.items(): location_quantity = sum(location_quants.mapped("quantity")) - sum( location_quants.mapped("reserved_quantity") ) @@ -302,4 +313,4 @@ def is_greater_eq(value, other): if float_is_zero(take, precision_rounding=rounding): continue location_quantity -= take - need = yield location, location_quantity, take + need = yield take, location, lot, pack, owner, True diff --git a/stock_reserve_rule/tests/common.py b/stock_reserve_rule/tests/common.py index d62800d4..f951fbee 100644 --- a/stock_reserve_rule/tests/common.py +++ b/stock_reserve_rule/tests/common.py @@ -101,10 +101,23 @@ def _create_picking(self, wh, products=None, location_src_id=None): return picking def _update_qty_in_location( - self, location, product, quantity, in_date=None, package=None + self, + location, + product, + quantity, + in_date=None, + package=None, + lot=None, + owner=None, ): self.env["stock.quant"]._update_available_quantity( - product, location, quantity, package_id=package, in_date=in_date + product, + location, + quantity, + lot_id=lot, + package_id=package, + owner_id=owner, + in_date=in_date, ) def _create_rule(self, rule_values, removal_values): diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 8f4af2d0..943345e4 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -1,5 +1,6 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -# Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) +# Copyright 2019 Jacques-Etienne Baudoux (BCIM) +# Copyright 2025 Akretion from odoo import exceptions, fields @@ -806,3 +807,119 @@ def test_rule_removal_location_parent_of_move_location(self): [{"location_id": self.loc_zone1_bin1.id, "quantity": 100.0}], ) self.assertEqual(move.state, "partially_available") + + def test_rule_packaging_do_not_sum_smaller_pack(self): + """Reserve only in case 1 quant matches existing packaging qty""" + self._setup_packagings( + self.product1, + [ + ("Transport Box", 100, self.transport_box), + ], + ) + # Put in stock 2 packs of 50pces + for cnt in range(2): + pack = self.env["stock.quant.package"].create({"name": f"PACK000{cnt}"}) + self._update_qty_in_location( + self.loc_zone1_bin1, + self.product1, + 50, + package=pack, + ) + + picking = self._create_picking(self.wh, [(self.product1, 200)]) + move = picking.move_ids + self._create_rule( + {}, + [ + { + "location_id": self.loc_zone1.id, + "sequence": 1, + "removal_strategy": "packaging", + }, + ], + ) + picking.action_assign() + self.assertEqual(move.state, "confirmed") + + def test_rule_packaging_do_not_sum_different_owner(self): + """ + Reserve only in case 1 quant matches existing packaging qty (consider owner) + """ + self._setup_packagings( + self.product1, + [ + ("Transport Box", 100, self.transport_box), + ], + ) + # Put in stock 2 packs of 50pces + for cnt in range(2): + owner = self.env["res.partner"].create({"name": f"Parner {cnt}"}) + self._update_qty_in_location( + self.loc_zone1_bin1, + self.product1, + 50, + owner=owner, + ) + + picking = self._create_picking(self.wh, [(self.product1, 200)]) + move = picking.move_ids + self._create_rule( + {}, + [ + { + "location_id": self.loc_zone1.id, + "sequence": 1, + "removal_strategy": "packaging", + }, + ], + ) + picking.action_assign() + self.assertEqual(move.state, "confirmed") + + def test_rule_packaging_do_not_sum_different_lots(self): + """ + Reserve only in case 1 quant matches existing packaging qty (consider lot) + """ + self.product1.tracking = "lot" + self._setup_packagings( + self.product1, + [ + ("Transport Box", 100, self.transport_box), + ], + ) + # Put in stock 2 packs of 50pces + lot = self.env["stock.lot"].create( + {"name": "Lot 1", "product_id": self.product1.id} + ) + self._update_qty_in_location( + self.loc_zone1_bin1, + self.product1, + 50, + lot=lot, + ) + lot2 = self.env["stock.lot"].create( + {"name": "Lot 2", "product_id": self.product1.id} + ) + self._update_qty_in_location( + self.loc_zone1_bin1, + self.product1, + 100, + lot=lot2, + ) + + picking = self._create_picking(self.wh, [(self.product1, 200)]) + move = picking.move_ids + self._create_rule( + {}, + [ + { + "location_id": self.loc_zone1.id, + "sequence": 1, + "removal_strategy": "packaging", + }, + ], + ) + picking.action_assign() + self.assertEqual(move.state, "partially_available") + self.assertEqual(move.move_line_ids.lot_id, lot2) + self.assertEqual(move.quantity, 100.0)