diff --git a/stock_reserve_rule/__manifest__.py b/stock_reserve_rule/__manifest__.py index 570c32091996..b56b8dfc03f9 100644 --- a/stock_reserve_rule/__manifest__.py +++ b/stock_reserve_rule/__manifest__.py @@ -4,7 +4,7 @@ "name": "Stock Reservation Rules", "summary": "Configure reservation rules by location", "version": "14.0.1.2.0", - "author": "Camptocamp, Odoo Community Association (OCA)", + "author": "Cetmix, Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/stock-logistics-warehouse", "category": "Stock Management", "depends": [ diff --git a/stock_reserve_rule/demo/stock_location_demo.xml b/stock_reserve_rule/demo/stock_location_demo.xml index b9327b429956..8abc7feacf9d 100644 --- a/stock_reserve_rule/demo/stock_location_demo.xml +++ b/stock_reserve_rule/demo/stock_location_demo.xml @@ -12,6 +12,10 @@ Zone C + + Zone D + + Bin A1 @@ -24,4 +28,8 @@ Bin C1 + + Bin D1 + + diff --git a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml index e3edc0aa4b4d..aeb547af4284 100644 --- a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml +++ b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml @@ -25,4 +25,10 @@ default + + + 4 + + single_lot + diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index a7c39e5595d9..76f0becbf9c2 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -133,6 +133,7 @@ class StockReserveRuleRemoval(models.Model): ("default", "Default Removal Strategy"), ("empty_bin", "Empty Bins"), ("packaging", "Full Packaging"), + ("single_lot", "Single lot"), ], required=True, default="default", @@ -142,7 +143,8 @@ class StockReserveRuleRemoval(models.Model): "Empty Bins: take goods from a location only if the bin is" " empty afterwards.\n" "Full Packaging: take goods from a location only if the location " - "quantity matches a packaging quantity (do not open boxes).", + "quantity matches a packaging quantity (do not open boxes)." + "By lot: ", ) packaging_type_ids = fields.Many2many( @@ -152,6 +154,62 @@ class StockReserveRuleRemoval(models.Model): "When empty, any packaging can be removed.", ) + TOLERANCE_LIMIT = [ + ("upper_limit", "Upper Limit"), + ("lower_limit", "Lower Limit"), + ] + + tolerance_requested_limit = fields.Selection( + selection=TOLERANCE_LIMIT, + string="Tolerance on", + ) + + tolerance_requested_computation = fields.Selection( + selection=[ + ("percentage", "Percentage (%)"), + ("absolute", "Absolute Value"), + ], + string="Tolerance computation", + ) + + tolerance_requested_value = fields.Float(string="Tolerance value", default=0.0) + + tolerance_display = fields.Char( + compute="_compute_tolerance_display", store=True, string="Tolerance" + ) + + @api.depends( + "tolerance_requested_limit", + "tolerance_requested_computation", + "tolerance_requested_value", + ) + def _compute_tolerance_display(self): + for rec in self: + tolerance_on = rec.tolerance_requested_limit + tolerance_computation = rec.tolerance_requested_computation + if not tolerance_computation or not tolerance_on: + rec.tolerance_display = "" + continue + value = rec.tolerance_requested_value + if value == 0.0: + rec.tolerance_display = "Requested Qty = Lot Qty" + continue + limit = "-" if tolerance_on == "lower_limit" else "" + computation = "%" if tolerance_computation == "percentage" else "" + tolerance_on_dict = dict(self.TOLERANCE_LIMIT) + rec.tolerance_display = "{} ({}{}{})".format( + tolerance_on_dict.get(tolerance_on), limit, value, computation + ) + + @api.onchange("tolerance_requested_value") + def _onchange_tolerance_limit(self): + if self.tolerance_requested_value < 0.0: + raise models.UserError( + _( + "Tolerance from requested qty value must be more than or equal to 0.0" + ) + ) + @api.constrains("location_id") def _constraint_location_id(self): """The location has to be a child of the rule location.""" @@ -296,3 +354,66 @@ def is_greater_eq(value, other): # compute how much packaging we can get take = (need // pack_quantity) * pack_quantity need = yield location, location_quantity, take, None, None + + @api.model + def _get_requested_comparisons(self, rounding): + value = self.tolerance_requested_value + if value == 0.0: + return lambda product_qty, need: product_qty == need + computation = self.tolerance_requested_computation + if self.tolerance_requested_limit == "upper_limit": + + def wrap(product_qty, need): + if computation == "percentage": + return need + rounding < product_qty <= need * (100 + value) / 100 + # computation == "absolute" + else: + return need + rounding < product_qty <= need + value + + return wrap + + if self.tolerance_requested_limit == "lower_limit": + + def wrap(product_qty, need): + if computation == "percentage": + return need * (100 - value) / 100 <= product_qty < need - rounding + # computation == "absolute" + else: + return need - value <= product_qty < need - rounding + + return wrap + + def _apply_strategy_single_lot(self, quants): + need = yield + # We take goods only if we empty the bin. + # The original ordering (fefo, fifo, ...) must be kept. + product = fields.first(quants).product_id + rounding = product.uom_id.rounding + comparison = self._get_requested_comparisons(rounding) + if product.tracking == "lot" or comparison is not None: + for location, location_quants in quants._group_by_location(): + grouped_quants = self.env["stock.quant"].read_group( + [ + ("lot_id", "!=", False), + ("location_id", "in", location.ids), + ("product_id", "=", product.id), + ("quantity", ">", 0), + ], + ["lot_id", "quantity"], + ["lot_id"], + orderby="id", + ) + lot_ids_with_quantity = { + group["lot_id"][0]: group["quantity"] for group in grouped_quants + } + location_quantity = sum(location_quants.mapped("quantity")) - sum( + location_quants.mapped("reserved_quantity") + ) + lot_id = False + for rec_id, product_qty in lot_ids_with_quantity.items(): + if comparison(product_qty, need): + lot_id = rec_id + break + if location_quantity > 0 and lot_id: + lot_id = self.env["stock.production.lot"].browse(lot_id) + need = yield location, location_quantity, need, lot_id, None diff --git a/stock_reserve_rule/readme/CONTRIBUTORS.rst b/stock_reserve_rule/readme/CONTRIBUTORS.rst index 3bdce7f9f121..afeeb457f171 100644 --- a/stock_reserve_rule/readme/CONTRIBUTORS.rst +++ b/stock_reserve_rule/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Guewen Baconnier * Jacques-Etienne Baudoux (BCIM) +* Cetmix diff --git a/stock_reserve_rule/readme/DESCRIPTION.rst b/stock_reserve_rule/readme/DESCRIPTION.rst index 0a2b7b68003a..ca9fab19d248 100644 --- a/stock_reserve_rule/readme/DESCRIPTION.rst +++ b/stock_reserve_rule/readme/DESCRIPTION.rst @@ -21,6 +21,9 @@ The included advanced removal strategies are: * Full Packaging: tries to remove full packaging (configured on the products) first, by largest to smallest package or based on a pre-selected package (default removal strategy is then applied for equal quantities). +* By lot: tries to remove a lot. A specific field allows to set whether to reserve + a lot with qty matching requested qty, or a lot with qty >= requested quantity, + or only lots with qty > requested qty. Examples of scenario: diff --git a/stock_reserve_rule/readme/USAGE.rst b/stock_reserve_rule/readme/USAGE.rst index f36f876e18a0..562b1183dbd0 100644 --- a/stock_reserve_rule/readme/USAGE.rst +++ b/stock_reserve_rule/readme/USAGE.rst @@ -29,8 +29,7 @@ Scenario: and see the rules (by default in demo, the rules are created inactive) * Open Transfer: Outgoing shipment (reservation rules demo 1) * Check availability: it has 150 units, as it will not empty Zone A, it will not - take products there, it should take 100 in B and 50 in C (following the rules - order) + take products there, it should take 100 in B and 50 in C (following the rules order) * Unreserve this transfer (to test the second case) * Open Transfer: Outgoing shipment (reservation rules demo 2) * Check availability: it has 250 units, it can empty Zone A, it will take 200 in diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 7df3c6d9274d..0bf2cc964a78 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -2,7 +2,7 @@ # Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) from odoo import exceptions, fields -from odoo.tests import common +from odoo.tests import Form, common class TestReserveRule(common.SavepointCase): @@ -51,6 +51,15 @@ def setUpClass(cls): cls.loc_zone3_bin2 = cls.env["stock.location"].create( {"name": "Zone3 Bin2", "location_id": cls.loc_zone3.id} ) + cls.loc_zone4 = cls.env["stock.location"].create( + {"name": "Zone4", "location_id": cls.wh.lot_stock_id.id} + ) + cls.loc_zone4_bin1 = cls.env["stock.location"].create( + {"name": "Zone4 Bin1", "location_id": cls.loc_zone4.id} + ) + cls.loc_zone4_bin2 = cls.env["stock.location"].create( + {"name": "Zone4 Bin2", "location_id": cls.loc_zone4.id} + ) cls.product1 = cls.env["product.product"].create( {"name": "Product 1", "type": "product"} @@ -58,6 +67,9 @@ def setUpClass(cls): cls.product2 = cls.env["product.product"].create( {"name": "Product 2", "type": "product"} ) + cls.product3 = cls.env["product.product"].create( + {"name": "Product 3", "type": "product", "tracking": "lot"} + ) cls.unit = cls.env["product.packaging.type"].create( {"name": "Unit", "code": "UNIT", "sequence": 0} @@ -71,8 +83,16 @@ def setUpClass(cls): cls.pallet = cls.env["product.packaging.type"].create( {"name": "Pallet", "code": "PALLET", "sequence": 5} ) + cls.lot0_id = cls.env["stock.production.lot"].create( + {"name": "0101", "product_id": cls.product3.id} + ) + cls.lot1_id = cls.env["stock.production.lot"].create( + {"name": "0102", "product_id": cls.product3.id} + ) - def _create_picking(self, wh, products=None, location_src_id=None): + def _create_picking( + self, wh, products=None, location_src_id=None, picking_type_id=None + ): """Create picking Products must be a list of tuples (product, quantity). @@ -86,7 +106,7 @@ def _create_picking(self, wh, products=None, location_src_id=None): "location_id": location_src_id or wh.lot_stock_id.id, "location_dest_id": wh.wh_output_stock_loc_id.id, "partner_id": self.partner_delta.id, - "picking_type_id": wh.pick_type_id.id, + "picking_type_id": picking_type_id or wh.pick_type_id.id, } ) @@ -124,9 +144,10 @@ def _create_rule(self, rule_values, removal_values): "rule_removal_ids": [(0, 0, values) for values in removal_values], } rule_config.update(rule_values) - self.env["stock.reserve.rule"].create(rule_config) + record = self.env["stock.reserve.rule"].create(rule_config) # workaround for https://github.com/odoo/odoo/pull/41900 self.env["stock.reserve.rule"].invalidate_cache() + return record def _setup_packagings(self, product, packagings): """Create packagings on a product @@ -783,3 +804,194 @@ def test_rule_excluded_not_child_location(self): ml, [{"location_id": self.loc_zone2_bin1.id, "product_qty": 80.0}] ) self.assertEqual(move.state, "assigned") + + def test_rule_single_lot_equals(self): + self._update_qty_in_location( + self.loc_zone4_bin1, self.product3, 100, lot_id=self.lot0_id + ) + # Rule by lot + self._create_rule( + # different picking, should be excluded + {"picking_type_ids": [(6, 0, self.wh.out_type_id.ids)], "sequence": 1}, + [ + { + "location_id": self.loc_zone4.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "upper_limit", + "tolerance_requested_computation": "absolute", + "tolerance_requested_value": 0.0, + } + ], + ) + + # Rule qty 100 != 50 + picking = self._create_picking( + self.wh, [(self.product3, 50)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertFalse(move.move_line_ids) + self.assertEqual(move.state, "confirmed") + + # Rule qty 100 == 100 + picking = self._create_picking( + self.wh, [(self.product3, 100)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertRecordValues( + move.move_line_ids, + [ + { + "location_id": self.loc_zone4_bin1.id, + "product_qty": 100, + "lot_id": self.lot0_id.id, + }, + ], + ) + self.assertEqual(move.state, "assigned") + wizard_data = picking.button_validate() + wizard = Form( + self.env[wizard_data["res_model"]].with_context(wizard_data["context"]) + ).save() + wizard.process() + + def test_rule_validation(self): + rule = self._create_rule( + # different picking, should be excluded + {"picking_type_ids": [(6, 0, self.wh.out_type_id.ids)], "sequence": 1}, + [ + { + "location_id": self.loc_zone4.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "lower_limit", + "tolerance_requested_computation": "absolute", + } + ], + ) + with Form( + rule, view="stock_reserve_rule.view_stock_reserve_rule_form" + ) as form, form.rule_removal_ids.edit(0) as line: + with self.assertRaises(exceptions.UserError): + line.tolerance_requested_value = -1 + + def test_rule_tolerance_absolute(self): + self._update_qty_in_location( + self.loc_zone4_bin1, self.product3, 4, lot_id=self.lot0_id + ) + self._create_rule( + # different picking, should be excluded + {"picking_type_ids": [(6, 0, self.wh.out_type_id.ids)], "sequence": 1}, + [ + { + "location_id": self.loc_zone4.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "upper_limit", + "tolerance_requested_computation": "absolute", + "tolerance_requested_value": 1.0, + } + ], + ) + picking = self._create_picking( + self.wh, [(self.product3, 4)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertFalse(move.move_line_ids) + + picking = self._create_picking( + self.wh, [(self.product3, 3)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertRecordValues( + move.move_line_ids, + [ + { + "location_id": self.loc_zone4_bin1.id, + "product_qty": 3, + "lot_id": self.lot0_id.id, + }, + ], + ) + + def test_rule_tolerance_percent(self): + self._update_qty_in_location( + self.loc_zone4_bin1, self.product3, 5, lot_id=self.lot0_id + ) + self._create_rule( + # different picking, should be excluded + {"picking_type_ids": [(6, 0, self.wh.out_type_id.ids)], "sequence": 1}, + [ + { + "location_id": self.loc_zone4.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "upper_limit", + "tolerance_requested_computation": "percentage", + "tolerance_requested_value": 50.0, + } + ], + ) + picking = self._create_picking( + self.wh, [(self.product3, 4)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertRecordValues( + move.move_line_ids, + [ + { + "location_id": self.loc_zone4_bin1.id, + "product_qty": 4, + "lot_id": self.lot0_id.id, + }, + ], + ) + + def test_rule_tolerance_lower_limit(self): + self._update_qty_in_location( + self.loc_zone4_bin1, self.product3, 3, lot_id=self.lot0_id + ) + self._update_qty_in_location( + self.loc_zone1_bin1, self.product3, 5, lot_id=self.lot1_id + ) + self._create_rule( + # different picking, should be excluded + {"picking_type_ids": [(6, 0, self.wh.out_type_id.ids)], "sequence": 1}, + [ + { + "location_id": self.loc_zone1.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "lower_limit", + "tolerance_requested_computation": "percentage", + "tolerance_requested_value": 50.0, + }, + { + "location_id": self.loc_zone4.id, + "removal_strategy": "single_lot", + "tolerance_requested_limit": "upper_limit", + "tolerance_requested_computation": "percentage", + "tolerance_requested_value": 0.0, + }, + ], + ) + picking = self._create_picking( + self.wh, [(self.product3, 8)], picking_type_id=self.wh.out_type_id.id + ) + picking.action_assign() + move = picking.move_lines + self.assertRecordValues( + move.move_line_ids, + [ + { + "location_id": self.loc_zone1_bin1.id, + "product_qty": 5, + "lot_id": self.lot1_id.id, + }, + { + "location_id": self.loc_zone4_bin1.id, + "product_qty": 3, + "lot_id": self.lot0_id.id, + }, + ], + ) diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index 5f3b45fea08e..329268485ec4 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -48,6 +48,7 @@ +
@@ -62,6 +63,19 @@ widget="many2many_tags" attrs="{'invisible': [('removal_strategy', '!=', 'packaging')]}" /> + + + +