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 @@
+