mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
This reverts commit 768f186fd2.
Which is not more optimized, the optimization based on parent_path
doesn't make sense here as the ORM will read parent_path in the location
and get the parent ids by splitting the ids, it doesn't need more than
one query on stock_location which is done based on its id and can reuse
the cache, there is no lookup on parent path for parent_of.
>>> env["stock.reserve.rule"].search([("location_id", "parent_of", 3125)])
2020-05-27 05:36:59,938 1 DEBUG log_p odoo.sql_db: query: SELECT "stock_location"."id" as "id","stock_location"."name" as "name","stock_location"."complete_name" as "complete_name","stock_location"."active" as "active","stock_location"."usage" as "usage","stock_location"."location_id" as "location_id","stock_location"."comment" as "comment","stock_location"."parent_path" as "parent_path", <stripped>,"stock_location"."create_uid" as "create_uid","stock_location"."create_date" as "create_date","stock_location"."write_uid" as "write_uid","stock_location"."write_date" as "write_date" FROM "stock_location" WHERE "stock_location".id IN (3125)
2020-05-27 05:36:59,942 1 DEBUG log_p odoo.sql_db: query: SELECT "stock_reserve_rule".id FROM "stock_reserve_rule" WHERE (("stock_reserve_rule"."active" = true) AND ("stock_reserve_rule"."location_id" in (1,7,8,133,134,135,144,207,3125))) ORDER BY "stock_reserve_rule"."sequence" ,"stock_reserve_rule"."id"
306 lines
11 KiB
Python
306 lines
11 KiB
Python
# Copyright 2019 Camptocamp SA
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
|
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
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
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,
|
|
help="Rule applied only in this location and sub-locations.",
|
|
)
|
|
picking_type_id = fields.Many2one(
|
|
comodel_name="stock.picking.type",
|
|
string="Operation Type",
|
|
help="Apply this rule only if the operation type of the move is the same.",
|
|
)
|
|
|
|
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):
|
|
if self.picking_type_id:
|
|
picking_type = move.picking_type_id or move.picking_id.picking_type_id
|
|
if picking_type != self.picking_type_id:
|
|
return False
|
|
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.",
|
|
)
|
|
|
|
@api.constrains("location_id")
|
|
def _constraint_location_id(self):
|
|
"""The location has to be a child of the rule location."""
|
|
for removal_rule in self:
|
|
if not removal_rule.location_id.is_sublocation_of(
|
|
removal_rule.rule_id.location_id
|
|
):
|
|
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,
|
|
)
|
|
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]))
|
|
|
|
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
|