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 @@ + - + - + +
@@ -61,6 +63,10 @@
+ + + 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 @@ +