[IMP] stock_reserve_rule: add constraints on fallback locations

This commit is contained in:
sebalix
2020-04-23 17:02:26 +02:00
parent 13a274e8f0
commit 4e4d2d7130
4 changed files with 178 additions and 5 deletions

View File

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

View File

@@ -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]))

View File

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

View File

@@ -22,6 +22,7 @@
<field name="active" invisible="1" />
<field name="location_id" />
<field name="fallback_location_id" />
<field name="sequence" />
</group>
<group>
<field
@@ -32,13 +33,14 @@
<field name="company_id" groups="base.group_multi_company" />
</group>
</group>
<group string="Removal Rules" name="rule">
<group string="Removal Rules" name="rule" col="1">
<field name="rule_removal_ids" nolabel="1">
<tree string="Removal Rules">
<tree string="Removal Rules" decoration-warning="missing_reserve_rule">
<field name="sequence" widget="handle" />
<field name="name" />
<field name="location_id" />
<field name="removal_strategy" />
<field name="missing_reserve_rule" invisible="1"/>
</tree>
<form string="Removal Rule">
<group>
@@ -61,6 +63,10 @@
</group>
</form>
</field>
<field name="missing_reserve_rule" invisible="1"/>
<strong attrs="{'invisible': [('missing_reserve_rule', '=', False)]}" style="color:red;">
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.
</strong>
</group>
</form>
</field>
@@ -89,6 +95,7 @@
<field name="sequence" widget="handle" />
<field name="name" />
<field name="location_id" />
<field name="rule_domain"/>
</tree>
</field>
</record>