mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
[IMP] stock_reserve_rule: add constraints on fallback locations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user