diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py
index 3fb0b3c1b..17c83cb0e 100644
--- a/stock_reserve_rule/models/stock_move.py
+++ b/stock_reserve_rule/models/stock_move.py
@@ -103,7 +103,9 @@ class StockMove(models.Model):
fallback_quantity = sum(quants.mapped("quantity")) - sum(
quants.mapped("reserved_quantity")
)
- return reserved + super()._update_reserved_quantity(
+ # If there is some qties to reserve in the fallback location,
+ # reserve them
+ reserved_fallback = super()._update_reserved_quantity(
still_need,
fallback_quantity,
location_id=rule.fallback_location_id,
@@ -112,6 +114,24 @@ class StockMove(models.Model):
owner_id=owner_id,
strict=strict,
)
+ # Then if there is still a need, we split the current move to
+ # get a new one targetting the fallback location with the
+ # remaining qties
+ still_need = self.product_uom_qty - self.reserved_availability
+ if still_need:
+ qty_split = self.product_uom._compute_quantity(
+ still_need,
+ self.product_id.uom_id,
+ rounding_method="HALF-UP",
+ )
+ new_move_id = self._split(qty_split)
+ new_move = self.browse(new_move_id)
+ new_move.location_id = rule.fallback_location_id
+ # Shunt the caller '_action_assign' by telling that all
+ # the need has been reserved to get the current move
+ # updated to the state 'assigned'
+ return reserved + reserved_fallback + new_move.product_uom_qty
+ return reserved + reserved_fallback
else:
# Implicit fallback on the original location
diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py
index 2081962f4..2dde06d14 100644
--- a/stock_reserve_rule/models/stock_reserve_rule.py
+++ b/stock_reserve_rule/models/stock_reserve_rule.py
@@ -1,9 +1,14 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from odoo import fields, models
+import logging
+
+from odoo import _, api, fields, models
from odoo.osv import expression
from odoo.tools.float_utils import float_compare
from odoo.tools.safe_eval import safe_eval
+from odoo.exceptions import ValidationError
+
+_logger = logging.getLogger(__name__)
def _default_sequence(record):
@@ -55,6 +60,37 @@ class StockReserveRule(models.Model):
"rule is applicable or not.",
)
+ missing_reserve_rule = fields.Boolean(
+ string="Miss a reserve rule with higher priority?",
+ compute="_compute_missing_reserve_rule",
+ )
+
+ @api.depends()
+ def _compute_missing_reserve_rule(self):
+ for rule in self:
+ rule.missing_reserve_rule = any(
+ rule.rule_removal_ids.mapped("missing_reserve_rule"))
+
+ @api.constrains("fallback_location_id")
+ def _constraint_fallback_location_id(self):
+ """The fallback location has to be a child of the main location."""
+ location_model = self.env["stock.location"]
+ for rule in self:
+ if rule.fallback_location_id:
+ is_child = location_model.search_count(
+ [
+ ("id", "=", rule.fallback_location_id.id),
+ ("id", "child_of", rule.location_id.id),
+ ],
+ )
+ if not is_child:
+ msg = _(
+ "Fallback location has to be a child of the "
+ "location '{}'."
+ ).format(rule.location_id.display_name)
+ _logger.error("Rule '%s' - %s", rule.name, msg)
+ raise ValidationError(msg)
+
def _rules_for_location(self, location):
return self.search([("location_id", "parent_of", location.id)])
@@ -140,6 +176,48 @@ class StockReserveRuleRemoval(models.Model):
"When empty, any packaging can be removed.",
)
+ missing_reserve_rule = fields.Boolean(
+ string="Miss a reserve rule with higher priority?",
+ compute="_compute_missing_reserve_rule",
+ )
+
+ @api.depends()
+ def _compute_missing_reserve_rule(self):
+ for removal_rule in self:
+ removal_rule.missing_reserve_rule = False
+ # The current rule could satisfies the need already
+ if removal_rule.location_id == removal_rule.rule_id.location_id:
+ break
+ rules = self.env["stock.reserve.rule"].search(
+ [
+ ("location_id", "=", removal_rule.location_id.id),
+ ("sequence", "<", removal_rule.rule_id.sequence),
+ ]
+ )
+ removal_rule.missing_reserve_rule = not rules
+
+ @api.constrains("location_id")
+ def _constraint_location_id(self):
+ """The location has to be a child of the rule location."""
+ location_model = self.env["stock.location"]
+ for removal_rule in self:
+ is_child = location_model.search_count(
+ [
+ ("id", "=", removal_rule.location_id.id),
+ ("id", "child_of", removal_rule.rule_id.location_id.id),
+ ],
+ )
+ if not is_child:
+ msg = _(
+ "Removal rule '{}' location has to be a child "
+ "of the rule location '{}'."
+ ).format(
+ removal_rule.name,
+ removal_rule.rule_id.location_id.display_name,
+ )
+ _logger.error("Rule '%s' - %s", removal_rule.rule_id.name, msg)
+ raise ValidationError(msg)
+
def _eval_quant_domain(self, quants, domain):
quant_domain = [("id", "in", quants.ids)]
return self.env["stock.quant"].search(expression.AND([quant_domain, domain]))
diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py
index 124098890..549805e99 100644
--- a/stock_reserve_rule/tests/test_reserve_rule.py
+++ b/stock_reserve_rule/tests/test_reserve_rule.py
@@ -1,6 +1,7 @@
# Copyright 2019 Camptocamp (https://www.camptocamp.com)
from odoo.tests import common
+from odoo import exceptions
class TestReserveRule(common.SavepointCase):
@@ -130,6 +131,73 @@ class TestReserveRule(common.SavepointCase):
]
)
+ def test_rule_fallback_child_of_location(self):
+ # fallback is a child
+ self._create_rule(
+ {
+ "fallback_location_id": self.loc_zone1.id,
+ },
+ [
+ {"location_id": self.loc_zone1.id},
+ ],
+ )
+ # fallback is not a child
+ with self.assertRaises(exceptions.ValidationError):
+ self._create_rule(
+ {
+ "fallback_location_id": self.env.ref(
+ "stock.stock_location_locations").id,
+ },
+ [{"location_id": self.loc_zone1.id}],
+ )
+
+ def test_removal_rule_location_child_of_rule_location(self):
+ # removal rule location is a child
+ self._create_rule(
+ {},
+ [{"location_id": self.loc_zone1.id}],
+ )
+ # removal rule location is not a child
+ with self.assertRaises(exceptions.ValidationError):
+ self._create_rule(
+ {},
+ [
+ {
+ "location_id": self.env.ref(
+ "stock.stock_location_locations").id,
+ },
+ ],
+ )
+
+ def test_rule_fallback_partial_assign(self):
+ """Assign move partially available.
+
+ The move should be splitted in two:
+ - one move assigned with reserved goods
+ - one move for remaining goods targetting the fallback location
+ """
+ # Need 150 and 120 available => new move with 30 waiting qties
+ self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
+ self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 20)
+ picking = self._create_picking(self.wh, [(self.product1, 150)])
+ self._create_rule(
+ {
+ "fallback_location_id": self.loc_zone2_bin1.id,
+ },
+ [
+ {"location_id": self.loc_zone1_bin1.id, "sequence": 1},
+ ],
+ )
+ self.assertEqual(len(picking.move_lines), 1)
+ picking.action_assign()
+ self.assertEqual(len(picking.move_lines), 2)
+ move_assigned = picking.move_lines.filtered(
+ lambda m: m.state == "assigned")
+ move_unassigned = picking.move_lines.filtered(
+ lambda m: m.state == "confirmed")
+ self.assertEqual(move_assigned.state, "assigned")
+ self.assertEqual(move_unassigned.state, "confirmed")
+
def test_rule_take_all_in_2(self):
all_locs = (
self.loc_zone1_bin1,
@@ -223,7 +291,7 @@ class TestReserveRule(common.SavepointCase):
def test_rule_fallback(self):
reserve = self.env["stock.location"].create(
- {"name": "Reserve", "location_id": self.wh.view_location_id.id}
+ {"name": "Reserve", "location_id": self.wh.lot_stock_id.id}
)
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml
index 9878a5159..f9015341d 100644
--- a/stock_reserve_rule/views/stock_reserve_rule_views.xml
+++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml
@@ -22,6 +22,7 @@
+
-
+
-
+
+
+
+
+ WARNING: some of the removal rules are missing a reservation rule with a lower sequence. If it is desirable you should create them to ensure that the reservation on the related locations will use these rules first instead of this one.
+
@@ -89,6 +95,7 @@
+