diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 17c83cb0e..129c1ba88 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -114,24 +114,33 @@ 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 + reserved += reserved_fallback + still_need = self.product_uom_qty - reserved 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 + if not reserved: + # nothing could be reserved, however, we want to source + # the move on the specific fallback location (for + # replenishment), so update it's origin and return 0 + # reserved to leave the move confirmed + self.location_id = rule.fallback_location_id + return 0 + else: + # 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 for replenishment + 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 + new_move.product_uom_qty + return reserved 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 2dde06d14..48c519a2b 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -3,10 +3,10 @@ import logging from odoo import _, api, fields, models +from odoo.exceptions import ValidationError 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__) @@ -45,8 +45,10 @@ class StockReserveRule(models.Model): fallback_location_id = fields.Many2one( comodel_name="stock.location", help="If all removal rules are exhausted, try to reserve in this " - "location. When empty, the fallback happens in any of the move's " - "source sub-locations.", + "location. Use it for replenishment. The source location move will be " + "changed to this location if the move is not available. If the move is " + "partially available, it is split and the unavailable quantity is sourced " + "in this location for replenishment.", ) rule_removal_ids = fields.One2many( @@ -69,7 +71,8 @@ class StockReserveRule(models.Model): def _compute_missing_reserve_rule(self): for rule in self: rule.missing_reserve_rule = any( - rule.rule_removal_ids.mapped("missing_reserve_rule")) + rule.rule_removal_ids.mapped("missing_reserve_rule") + ) @api.constrains("fallback_location_id") def _constraint_fallback_location_id(self): @@ -85,8 +88,7 @@ class StockReserveRule(models.Model): ) if not is_child: msg = _( - "Fallback location has to be a child of the " - "location '{}'." + "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) @@ -212,8 +214,7 @@ class StockReserveRuleRemoval(models.Model): "Removal rule '{}' location has to be a child " "of the rule location '{}'." ).format( - removal_rule.name, - removal_rule.rule_id.location_id.display_name, + 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) diff --git a/stock_reserve_rule/readme/CONFIGURE.rst b/stock_reserve_rule/readme/CONFIGURE.rst index 2b2c96abe..03965177b 100644 --- a/stock_reserve_rule/readme/CONFIGURE.rst +++ b/stock_reserve_rule/readme/CONFIGURE.rst @@ -5,8 +5,12 @@ Creation of a rule: Properties that define where the rule will be applied: * Location: Define where the rule will look for goods (a parent of the move's source location). -* Fallback Location: Define where the goods are reserved if none of the removal rule could reserve - the goods. If left empty, the goods are reserved in the move's source location / sub-locations. +* Fallback Location: Define where the goods are reserved if none of the removal + rule could reserve the goods. Use it for replenishment. The source location + move will be changed to this location if the move is not available. If the + move is partially available, it is split and the unavailable quantity is + sourced in this location for replenishment. If left empty, the goods are + reserved in the move's source location / sub-locations. * Rule Domain: The rule is used only if the Stock Move matches the domain. Removal rules for the locations: diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 549805e99..48ef9cec5 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -1,7 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -from odoo.tests import common from odoo import exceptions +from odoo.tests import common class TestReserveRule(common.SavepointCase): @@ -134,39 +134,27 @@ 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_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, + "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}], - ) + 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, - }, - ], + {}, [{"location_id": self.env.ref("stock.stock_location_locations").id}] ) def test_rule_fallback_partial_assign(self): @@ -180,23 +168,64 @@ class TestReserveRule(common.SavepointCase): 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)]) + fallback = self.loc_zone2_bin1 self._create_rule( - { - "fallback_location_id": self.loc_zone2_bin1.id, - }, - [ - {"location_id": self.loc_zone1_bin1.id, "sequence": 1}, - ], + {"fallback_location_id": fallback.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") + move_assigned = picking.move_lines.filtered(lambda m: m.state == "assigned") + move_unassigned = picking.move_lines.filtered(lambda m: m.state == "confirmed") + self.assertRecordValues( + move_assigned, + [ + { + "state": "assigned", + "location_id": picking.location_id.id, + "product_uom_qty": 120, + } + ], + ) + self.assertRecordValues( + move_unassigned, + [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 30}], + ) + + def test_rule_fallback_unavailable(self): + """Assign move unavailable. + + The move source location should be changed to be the fallback location. + """ + self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) + picking = self._create_picking(self.wh, [(self.product1, 150)]) + fallback = self.loc_zone2_bin1 + self._create_rule( + {"fallback_location_id": fallback.id}, + [ + { + "location_id": self.loc_zone1_bin1.id, + "sequence": 1, + # FIXME check if this isn't an issue? + # for the test: StockQuant._get_available_quantity should + # return something otherwise we won't enter in + # StockMove._update_reserved_quantity + # and the fallback will not be applied, so to reproduce a + # case where the quants are not allowed to be taken, + # use a domain that always resolves to false + "quant_domain": [("id", "=", 0)], + } + ], + ) + self.assertEqual(len(picking.move_lines), 1) + picking.action_assign() + self.assertEqual(len(picking.move_lines), 1) + move = picking.move_lines + self.assertRecordValues( + move, + [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 150}], + ) def test_rule_take_all_in_2(self): all_locs = ( diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index f9015341d..966dbfaed 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -35,12 +35,15 @@ - + - +
@@ -63,8 +66,11 @@
- - + + 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.
@@ -95,7 +101,7 @@ - +