mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
279 lines
10 KiB
Python
279 lines
10 KiB
Python
# Copyright 2019 Camptocamp SA
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
|
from odoo import fields, models
|
|
from odoo.osv import expression
|
|
from odoo.tools.float_utils import float_compare
|
|
from odoo.tools.safe_eval import safe_eval
|
|
|
|
|
|
def _default_sequence(record):
|
|
maxrule = record.search([], order="sequence desc", limit=1)
|
|
if maxrule:
|
|
return maxrule.sequence + 10
|
|
else:
|
|
return 0
|
|
|
|
|
|
class StockReserveRule(models.Model):
|
|
"""Rules for stock reservations
|
|
|
|
Each rule can have many removal rules, they configure the conditions and
|
|
advanced removal strategies to apply on a specific location (sub-location
|
|
of the rule).
|
|
|
|
The rules are selected for a move based on their source location and a
|
|
configurable domain on the rule.
|
|
"""
|
|
|
|
_name = "stock.reserve.rule"
|
|
_description = "Stock Reservation Rule"
|
|
_order = "sequence, id"
|
|
|
|
name = fields.Char(string="Description", required=True)
|
|
sequence = fields.Integer(default=lambda s: _default_sequence(s))
|
|
active = fields.Boolean(default=True)
|
|
company_id = fields.Many2one(
|
|
comodel_name="res.company", default=lambda self: self.env.user.company_id.id
|
|
)
|
|
|
|
location_id = fields.Many2one(comodel_name="stock.location", required=True)
|
|
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.",
|
|
)
|
|
|
|
rule_removal_ids = fields.One2many(
|
|
comodel_name="stock.reserve.rule.removal", inverse_name="rule_id"
|
|
)
|
|
|
|
rule_domain = fields.Char(
|
|
string="Rule Domain",
|
|
default=[],
|
|
help="Domain based on Stock Moves, to define if the "
|
|
"rule is applicable or not.",
|
|
)
|
|
|
|
def _rules_for_location(self, location):
|
|
return self.search([("location_id", "parent_of", location.id)])
|
|
|
|
def _eval_rule_domain(self, move, domain):
|
|
move_domain = [("id", "=", move.id)]
|
|
# Warning: if we build a domain with dotted path such
|
|
# as group_id.is_urgent (hypothetic field), can become very
|
|
# slow as odoo searches all "procurement.group.is_urgent" first
|
|
# then uses "IN group_ids" on the stock move only.
|
|
# In such situations, it can be better either to add a related
|
|
# field on the stock.move, either extend _eval_rule_domain to
|
|
# add your own logic (based on SQL, ...).
|
|
return bool(
|
|
self.env["stock.move"].search(
|
|
expression.AND([move_domain, domain]), limit=1
|
|
)
|
|
)
|
|
|
|
def _is_rule_applicable(self, move):
|
|
domain = safe_eval(self.rule_domain) or []
|
|
if domain:
|
|
return self._eval_rule_domain(move, domain)
|
|
return True
|
|
|
|
|
|
class StockReserveRuleRemoval(models.Model):
|
|
"""Rules for stock reservations removal
|
|
|
|
A removal rule does:
|
|
|
|
* Filter quants that a removal rule can reserve for the location
|
|
(_filter_quants)
|
|
* An advanced removal strategy for the preselected quants (_apply_strategy)
|
|
|
|
New advanced removal strategies can be added by other modules, see the
|
|
method ``_apply_strategy`` and the default methods for more documentation
|
|
about their contract.
|
|
"""
|
|
|
|
_name = "stock.reserve.rule.removal"
|
|
_description = "Stock Reservation Rule Removal"
|
|
_order = "sequence, id"
|
|
|
|
rule_id = fields.Many2one(
|
|
comodel_name="stock.reserve.rule", required=True, ondelete="cascade"
|
|
)
|
|
name = fields.Char(string="Description")
|
|
location_id = fields.Many2one(comodel_name="stock.location", required=True)
|
|
|
|
sequence = fields.Integer(default=lambda s: _default_sequence(s))
|
|
|
|
# quants exclusion
|
|
quant_domain = fields.Char(
|
|
string="Quants Domain",
|
|
default=[],
|
|
help="Filter Quants allowed to be reserved for this location "
|
|
"and sub-locations.",
|
|
)
|
|
|
|
# advanced removal strategy
|
|
removal_strategy = fields.Selection(
|
|
string="Advanced Removal Strategy",
|
|
selection=[
|
|
("default", "Default Removal Strategy"),
|
|
("empty_bin", "Empty Bins"),
|
|
("packaging", "Full Packaging"),
|
|
],
|
|
required=True,
|
|
default="default",
|
|
help="Defines if and how goods are taken from locations."
|
|
"Default: take the first ones with the configured Removal Strategy"
|
|
"(FIFO, FEFO, ...).\n"
|
|
"Empty Bins: take goods from a location only if the bin is"
|
|
" empty afterwards.\n"
|
|
"Full Packaging: take goods from a location only if the location "
|
|
"quantity matches a packaging quantity (do not open boxes).",
|
|
)
|
|
|
|
packaging_type_ids = fields.Many2many(
|
|
comodel_name="product.packaging.type",
|
|
help="Optional packaging when using 'Full Packaging'.\n"
|
|
"Only the quantities matching one of the packaging are removed.\n"
|
|
"When empty, any packaging can be removed.",
|
|
)
|
|
|
|
def _eval_quant_domain(self, quants, domain):
|
|
quant_domain = [("id", "in", quants.ids)]
|
|
return self.env["stock.quant"].search(expression.AND([quant_domain, domain]))
|
|
|
|
def _filter_quants(self, move, quants):
|
|
domain = safe_eval(self.quant_domain) or []
|
|
if domain:
|
|
return self._eval_quant_domain(quants, domain)
|
|
return quants
|
|
|
|
def _apply_strategy(self, quants):
|
|
"""Apply the advanced removal strategy
|
|
|
|
New methods can be added by:
|
|
|
|
- Adding a selection in the 'removal_strategy' field.
|
|
- adding a method named after the selection value
|
|
(_apply_strategy_SELECTION)
|
|
|
|
A strategy has to comply with this signature: (self, quants)
|
|
Where 'self' is the current rule and 'quants' are the candidate
|
|
quants allowed for the rule, sorted by the company's removal
|
|
strategy (fifo, fefo, ...).
|
|
It has to get the initial need using 'need = yield' once, then,
|
|
each time the strategy decides to take quantities in a location,
|
|
it has to yield and retrieve the remaining needed using:
|
|
|
|
need = yield location, location_quantity, quantity_to_take
|
|
|
|
See '_apply_strategy_default' for a short example.
|
|
|
|
"""
|
|
method_name = "_apply_strategy_%s" % (self.removal_strategy)
|
|
yield from getattr(self, method_name)(quants)
|
|
|
|
def _apply_strategy_default(self, quants):
|
|
need = yield
|
|
# Propose quants in the same order than returned originally by
|
|
# the _gather method, so based on fifo, fefo, ...
|
|
for quant in quants:
|
|
need = yield (
|
|
quant.location_id,
|
|
quant.quantity - quant.reserved_quantity,
|
|
need,
|
|
)
|
|
|
|
def _apply_strategy_empty_bin(self, quants):
|
|
need = yield
|
|
# Group by location (in this removal strategies, we want to consider
|
|
# the total quantity held in a location).
|
|
quants_per_bin = quants._group_by_location()
|
|
|
|
# We want to limit the operations as much as possible.
|
|
# We'll sort the quants desc so we can fulfill as much as possible
|
|
# from as few as possible locations. The best case being an exact
|
|
# match.
|
|
# The original ordering (fefo, fifo, ...) must be kept.
|
|
|
|
bins = sorted(
|
|
[
|
|
(
|
|
sum(quants.mapped("quantity"))
|
|
- sum(quants.mapped("reserved_quantity")),
|
|
location,
|
|
)
|
|
for location, quants in quants_per_bin
|
|
],
|
|
reverse=True,
|
|
)
|
|
|
|
# Propose the largest quants first, so we have as less operations
|
|
# as possible. We take goods only if we empty the bin.
|
|
rounding = fields.first(quants).product_id.uom_id.rounding
|
|
for location_quantity, location in bins:
|
|
if location_quantity <= 0:
|
|
continue
|
|
|
|
if float_compare(need, location_quantity, rounding) != -1:
|
|
need = yield location, location_quantity, need
|
|
|
|
def _apply_strategy_packaging(self, quants):
|
|
need = yield
|
|
# Group by location (in this removal strategies, we want to consider
|
|
# the total quantity held in a location).
|
|
quants_per_bin = quants._group_by_location()
|
|
|
|
product = fields.first(quants).product_id
|
|
|
|
packaging_type_filter = self.packaging_type_ids
|
|
|
|
# we'll walk the packagings from largest to smallest to have the
|
|
# largest containers as possible (1 pallet rather than 10 boxes)
|
|
packaging_quantities = sorted(
|
|
product.packaging_ids.filtered(
|
|
lambda packaging: (
|
|
packaging.qty > 0
|
|
and (
|
|
packaging.packaging_type_id in packaging_type_filter
|
|
if packaging_type_filter
|
|
else True
|
|
)
|
|
)
|
|
).mapped("qty"),
|
|
reverse=True,
|
|
)
|
|
|
|
rounding = product.uom_id.rounding
|
|
|
|
def is_greater_eq(value, other):
|
|
return float_compare(value, other, precision_rounding=rounding) >= 0
|
|
|
|
for pack_quantity in packaging_quantities:
|
|
# Get quants quantity on each loop because they may change.
|
|
# Sort by max quant first so we have more chance to take a full
|
|
# package. But keep the original ordering for equal quantities!
|
|
bins = sorted(
|
|
[
|
|
(
|
|
sum(quants.mapped("quantity"))
|
|
- sum(quants.mapped("reserved_quantity")),
|
|
location,
|
|
)
|
|
for location, quants in quants_per_bin
|
|
],
|
|
reverse=True,
|
|
)
|
|
|
|
for location_quantity, location in bins:
|
|
if location_quantity <= 0:
|
|
continue
|
|
enough_for_packaging = is_greater_eq(location_quantity, pack_quantity)
|
|
asked_more_than_packaging = is_greater_eq(need, pack_quantity)
|
|
if enough_for_packaging and asked_more_than_packaging:
|
|
# compute how much packaging we can get
|
|
take = (need // pack_quantity) * pack_quantity
|
|
need = yield location, location_quantity, take
|