Add stock_reserve_rule

This commit is contained in:
Guewen Baconnier
2019-08-15 11:02:50 +02:00
committed by Sébastien Alix
parent 878eb3d889
commit fdab54b432
22 changed files with 2110 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
=======================
Stock Reservation Rules
=======================
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png
:target: https://odoo-community.org/page/development-status
:alt: Alpha
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github
:target: https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_reserve_rule
:alt: OCA/stock-logistics-warehouse
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_reserve_rule
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
:target: https://runbot.odoo-community.org/runbot/153/12.0
:alt: Try me on Runbot
|badge1| |badge2| |badge3| |badge4| |badge5|
This module adds rules for advanced reservation / removal strategies.
Rules are applied on a location and its sub-locations.
A rule can exclude quants or locations based on configurable criteria,
and based on the selected quants, apply advanced removal strategies.
The rules have a sequence, which will be respected for the reservation.
So even without filter or advanced removal strategies, we can give a priority to
reserve in a location before another.
The advanced removal strategies are applied on top of the default one (fifo,
fefo, ...).
The included advanced removal strategies are:
* Default Removal Strategy: apply the default configured one (fifo, fefo, ...)
* Empty Bins: goods are removed from a bin only if the bin will be empty after
the removal (favor smallest bins first, then apply the default removal
strategy for equal quantities).
* Prefer Full Packaging: tries to remove full packaging (configured on the
products) first, by largest to smallest package (default removal strategy is
then applied for equal quantities).
Examples of scenario:
rules:
* location A: no filter, no advanced removal strategy
* location B: no filter, Empty Bins
* location C: no filter, no advanced removal strategy
result:
* take what is available in location A
* then take in location B if available, only if bin(s) are emptied
* then take what is available in location C
The module is meant to be extensible, with a core mechanism on which new rules
and advanced removal strategies can be added.
.. IMPORTANT::
This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
`More details on development status <https://odoo-community.org/page/development-status>`_
**Table of contents**
.. contents::
:local:
Configuration
=============
The configuration of the rules is done in "Inventory > Configuration > Stock Reservation Rules".
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.
* Rule Domain: The rule is used only if the Stock Move matches the domain.
Removal rules for the locations:
* Quants Domain: this domain includes/excludes quants based on a domain.
* Advanced Removal Strategy: the strategy that will be used for this location
and sub-location when the rule is used.
The sequences have to be sorted in the view list to define the reservation priorities.
Usage
=====
If you are using a database with demo data, you can give a try
to the following scenario to understand how it works.
The demo data created by the module contains:
A product: Funky Socks
3 Locations:
* Stock / Zone A / Bin A1: 200 Funky socks
* Stock / Zone B / Bin B1: 100 Funky socks
* Stock / Zone C / Bin C1: 100 Funky socks
3 Reservation Rules, in the following order
* Zone A must have full quantities
* Zone B
* Zone C
2 Delivery Orders:
* Origin: Outgoing shipment (reservation rules demo 1)
* Origin: Outgoing shipment (reservation rules demo 2)
Scenario:
* Activate Storage Locations and Multi-Warehouses
* You can open Inventory > Configuration > Stock Reservation Rules to see the
rules
* Open Transfer: Outgoing shipment (reservation rules demo 1)
* Check availability: it has 150 units, as it will not empty Zone A, it will not
take products there, it should take 100 in B and 50 in C (following the rules
order)
* Unreserve this transfer (to test the second case)
* Open Transfer: Outgoing shipment (reservation rules demo 2)
* Check availability: it has 250 units, it can empty Zone A, it will take 200 in
Bin A1 and 50 in Bin B1.
* If you want to explore further, you can add a custom domain to exclude rules
(for instance, a product category will not use Zone B).
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/stock-logistics-warehouse/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
`feedback <https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_reserve_rule%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Camptocamp
Contributors
~~~~~~~~~~~~
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
Maintainers
~~~~~~~~~~~
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
This module is part of the `OCA/stock-logistics-warehouse <https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_reserve_rule>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,29 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
'name': 'Stock Reservation Rules',
'summary': 'Configure reservation rules by location',
'version': '12.0.1.0.0',
'author': "Camptocamp, Odoo Community Association (OCA)",
'website': "https://github.com/OCA/stock-logistics-warehouse",
'category': 'Stock Management',
'depends': [
'stock',
'product_packaging_type', # OCA/product-attribute
],
'demo': [
'demo/product_demo.xml',
'demo/stock_location_demo.xml',
'demo/stock_reserve_rule_demo.xml',
'demo/stock_inventory_demo.xml',
'demo/stock_picking_demo.xml',
],
'data': [
'views/stock_reserve_rule_views.xml',
'security/ir.model.access.csv',
'security/stock_reserve_rule_security.xml',
],
'installable': True,
'development_status': 'Alpha',
'license': 'AGPL-3',
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="product_funky_socks" model="product.product">
<field name="default_code">RS700</field>
<field name="name">Funky Socks</field>
<field name="type">product</field>
<field name="categ_id" ref="product.product_category_6"/>
<field name="lst_price">30.0</field>
<field name="standard_price">20.0</field>
<field name="weight">1.0</field>
<field name="tracking">none</field>
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="property_stock_inventory" ref="stock.location_inventory"/>
</record>
</odoo>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="stock_inventory_1_demo" model="stock.inventory">
<field name="name">Funky Socks Demo Inventory</field>
</record>
<record id="stock_inventory_1_line_1_demo" model="stock.inventory.line">
<field name="product_id" ref="product_funky_socks"/>
<field name="product_uom_id" ref="uom.product_uom_unit"/>
<field name="inventory_id" ref="stock_inventory_1_demo"/>
<field name="product_qty">200.0</field>
<field name="location_id" ref="stock_location_zone_a_bin_1_demo"/>
</record>
<record id="stock_inventory_1_line_2_demo" model="stock.inventory.line">
<field name="product_id" ref="product_funky_socks"/>
<field name="product_uom_id" ref="uom.product_uom_unit"/>
<field name="inventory_id" ref="stock_inventory_1_demo"/>
<field name="product_qty">100.0</field>
<field name="location_id" ref="stock_location_zone_b_bin_1_demo"/>
</record>
<record id="stock_inventory_1_line_3_demo" model="stock.inventory.line">
<field name="product_id" ref="product_funky_socks"/>
<field name="product_uom_id" ref="uom.product_uom_unit"/>
<field name="inventory_id" ref="stock_inventory_1_demo"/>
<field name="product_qty">100.0</field>
<field name="location_id" ref="stock_location_zone_c_bin_1_demo"/>
</record>
<function model="stock.inventory" name="action_validate">
<function eval="[[('state','=','draft'),('id', '=', ref('stock_reserve_rule.stock_inventory_1_demo'))]]" model="stock.inventory" name="search"/>
</function>
</odoo>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="stock_location_zone_a_demo" model="stock.location">
<field name="name">Zone A</field>
<field name="location_id" ref="stock.stock_location_stock"/>
</record>
<record id="stock_location_zone_b_demo" model="stock.location">
<field name="name">Zone B</field>
<field name="location_id" ref="stock.stock_location_stock"/>
</record>
<record id="stock_location_zone_c_demo" model="stock.location">
<field name="name">Zone C</field>
<field name="location_id" ref="stock.stock_location_stock"/>
</record>
<record id="stock_location_zone_a_bin_1_demo" model="stock.location">
<field name="name">Bin A1</field>
<field name="location_id" ref="stock_location_zone_a_demo"/>
</record>
<record id="stock_location_zone_b_bin_1_demo" model="stock.location">
<field name="name">Bin B1</field>
<field name="location_id" ref="stock_location_zone_b_demo"/>
</record>
<record id="stock_location_zone_c_bin_1_demo" model="stock.location">
<field name="name">Bin C1</field>
<field name="location_id" ref="stock_location_zone_c_demo"/>
</record>
</odoo>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="stock_picking_out_1_demo" model="stock.picking">
<field name="picking_type_id" ref="stock.picking_type_out"/>
<field name="origin">Outgoing shipment (reservation rules demo 1)</field>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="date" eval="DateTime.today()"/>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="location_dest_id" ref="stock.stock_location_customers"/>
<field name="move_lines" model="stock.move" eval="[(0, 0, {
'name': obj().env.ref('stock_reserve_rule.product_funky_socks').name,
'product_id': ref('stock_reserve_rule.product_funky_socks'),
'product_uom': ref('uom.product_uom_unit'),
'product_uom_qty': 150.0,
'picking_type_id': ref('stock.picking_type_out'),
'location_id': ref('stock.stock_location_stock'),
'location_dest_id': ref('stock.stock_location_customers'),
})]"/>
</record>
<record id="stock_picking_out_2_demo" model="stock.picking">
<field name="picking_type_id" ref="stock.picking_type_out"/>
<field name="origin">Outgoing shipment (reservation rules demo 2)</field>
<field name="partner_id" ref="base.res_partner_1"/>
<field name="date" eval="DateTime.today()"/>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="location_dest_id" ref="stock.stock_location_customers"/>
<field name="move_lines" model="stock.move" eval="[(0, 0, {
'name': obj().env.ref('stock_reserve_rule.product_funky_socks').name,
'product_id': ref('stock_reserve_rule.product_funky_socks'),
'product_uom': ref('uom.product_uom_unit'),
'product_uom_qty': 250.0,
'picking_type_id': ref('stock.picking_type_out'),
'location_id': ref('stock.stock_location_stock'),
'location_dest_id': ref('stock.stock_location_customers'),
})]"/>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="stock_reserve_rule_1_demo" model="stock.reserve.rule">
<field name="name">Stock</field>
<field name="sequence">1</field>
<field name="location_id" ref="stock.stock_location_stock"/>
<field name="company_id" ref="base.main_company"/>
</record>
<record id="stock_reserve_rule_removal_1_demo" model="stock.reserve.rule.removal">
<field name="rule_id" ref="stock_reserve_rule_1_demo"/>
<field name="sequence">1</field>
<field name="location_id" ref="stock_location_zone_a_demo"/>
<field name="removal_strategy">empty_bin</field>
</record>
<record id="stock_reserve_rule_2_removal_demo" model="stock.reserve.rule.removal">
<field name="rule_id" ref="stock_reserve_rule_1_demo"/>
<field name="sequence">2</field>
<field name="location_id" ref="stock_location_zone_b_demo"/>
<field name="removal_strategy">default</field>
</record>
<record id="stock_reserve_rule_3_removal_demo" model="stock.reserve.rule.removal">
<field name="rule_id" ref="stock_reserve_rule_1_demo"/>
<field name="sequence">3</field>
<field name="location_id" ref="stock_location_zone_c_demo"/>
<field name="removal_strategy">default</field>
</record>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import stock_move
from . import stock_quant
from . import stock_reserve_rule

View File

@@ -0,0 +1,139 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import models
from odoo.tools.float_utils import float_compare
class StockMove(models.Model):
_inherit = "stock.move"
def _update_reserved_quantity(
self,
need,
available_quantity,
location_id,
lot_id=None,
package_id=None,
owner_id=None,
strict=True,
):
"""Create or update move lines."""
if strict:
# chained moves must take what was reserved by the previous move
return super()._update_reserved_quantity(
need,
available_quantity,
location_id=location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)
rules = self.env["stock.reserve.rule"]._rules_for_location(location_id)
forced_package_id = self.package_level_id.package_id or None
rounding = self.product_id.uom_id.rounding
still_need = need
for rule in rules:
# 1st check if rule is applicable from the move
if not rule._is_rule_applicable(self):
continue
for removal_rule in rule.rule_removal_ids:
quants = self.env["stock.quant"]._gather(
self.product_id,
removal_rule.location_id,
lot_id=lot_id,
package_id=forced_package_id,
owner_id=owner_id,
strict=strict,
)
# get quants allowed by the rule
rule_quants = removal_rule._filter_quants(self, quants)
if not rule_quants:
continue
# Apply the advanced removal strategy, if any. Even within the
# application of the removal strategy, the original company's
# one should be respected (eg. if we remove quants that would
# empty bins first, in case of equality, we should remove the
# fifo or fefo first depending of the configuration).
strategy = removal_rule._apply_strategy(rule_quants)
next(strategy)
while True:
try:
next_quant = strategy.send(still_need)
if not next_quant:
continue
location, location_quantity, to_take = next_quant
taken_in_loc = super()._update_reserved_quantity(
# in this strategy, we take as much as we can
# from this bin
to_take,
location_quantity,
location_id=location,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)
still_need -= taken_in_loc
except StopIteration:
break
need_zero = (
float_compare(still_need, 0, precision_rounding=rounding)
!= 1
)
if need_zero:
# useless to eval the other rules when still_need <= 0
break
reserved = need - still_need
if rule.fallback_location_id:
quants = self.env["stock.quant"]._gather(
self.product_id,
rule.fallback_location_id,
lot_id=lot_id,
package_id=forced_package_id,
owner_id=owner_id,
strict=strict,
)
fallback_quantity = sum(quants.mapped("quantity")) - sum(
quants.mapped("reserved_quantity")
)
return reserved + super()._update_reserved_quantity(
still_need,
fallback_quantity,
location_id=rule.fallback_location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)
else:
# Implicit fallback on the original location
return reserved + super()._update_reserved_quantity(
still_need,
available_quantity - reserved,
location_id=location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)
# We fall here if there is no rule or they have all been
# excluded by 'rule._is_rule_applicable'
return super()._update_reserved_quantity(
need,
available_quantity,
location_id=location_id,
lot_id=lot_id,
package_id=package_id,
owner_id=owner_id,
strict=strict,
)

View File

@@ -0,0 +1,30 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from collections import OrderedDict
from odoo import models
class StockQuant(models.Model):
_inherit = "stock.quant"
def _group_by_location(self):
"""Return quants grouped by locations
Group by location, but keeping the order of the quants (if we have more
than one quant per location, the order is based on the first quant seen
in the location). Thus, it can be used on a recordset returned by
_gather.
The returned format is: [(location, quants)]
"""
seen = OrderedDict()
for quant in self:
location = quant.location_id
if location in seen:
seen[location] = seen[location] | quant
else:
seen[location] = quant
return [(location, quants) for location, quants in seen.items()]

View File

@@ -0,0 +1,287 @@
# Copyright 2019 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
from odoo.tools.safe_eval import safe_eval
from odoo.osv import expression
from odoo.tools.float_utils import float_compare
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")),
quants,
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, quants, 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")),
quants,
location,
)
for location, quants in quants_per_bin
],
reverse=True,
)
for location_quantity, quants, 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

View File

@@ -0,0 +1,18 @@
The configuration of the rules is done in "Inventory > Configuration > Stock Reservation Rules".
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.
* Rule Domain: The rule is used only if the Stock Move matches the domain.
Removal rules for the locations:
* Quants Domain: this domain includes/excludes quants based on a domain.
* Advanced Removal Strategy: the strategy that will be used for this location
and sub-location when the rule is used.
The sequences have to be sorted in the view list to define the reservation priorities.

View File

@@ -0,0 +1 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>

View File

@@ -0,0 +1,40 @@
This module adds rules for advanced reservation / removal strategies.
Rules are applied on a location and its sub-locations.
A rule can exclude quants or locations based on configurable criteria,
and based on the selected quants, apply advanced removal strategies.
The rules have a sequence, which will be respected for the reservation.
So even without filter or advanced removal strategies, we can give a priority to
reserve in a location before another.
The advanced removal strategies are applied on top of the default one (fifo,
fefo, ...).
The included advanced removal strategies are:
* Default Removal Strategy: apply the default configured one (fifo, fefo, ...)
* Empty Bins: goods are removed from a bin only if the bin will be empty after
the removal (favor largest bins first to minimize the number of operations,
then apply the default removal strategy for equal quantities).
* Full Packaging: tries to remove full packaging (configured on the products)
first, by largest to smallest package or based on a pre-selected package
(default removal strategy is then applied for equal quantities).
Examples of scenario:
rules:
* location A: no filter, no advanced removal strategy
* location B: no filter, Empty Bins
* location C: no filter, no advanced removal strategy
result:
* take what is available in location A
* then take in location B if available, only if bin(s) are emptied
* then take what is available in location C
The module is meant to be extensible, with a core mechanism on which new rules
and advanced removal strategies can be added.

View File

@@ -0,0 +1,39 @@
If you are using a database with demo data, you can give a try
to the following scenario to understand how it works.
The demo data created by the module contains:
A product: Funky Socks
3 Locations:
* Stock / Zone A / Bin A1: 200 Funky socks
* Stock / Zone B / Bin B1: 100 Funky socks
* Stock / Zone C / Bin C1: 100 Funky socks
3 Reservation Rules, in the following order
* Zone A must have full quantities
* Zone B
* Zone C
2 Delivery Orders:
* Origin: Outgoing shipment (reservation rules demo 1)
* Origin: Outgoing shipment (reservation rules demo 2)
Scenario:
* Activate Storage Locations and Multi-Warehouses
* You can open Inventory > Configuration > Stock Reservation Rules to see the
rules
* Open Transfer: Outgoing shipment (reservation rules demo 1)
* Check availability: it has 150 units, as it will not empty Zone A, it will not
take products there, it should take 100 in B and 50 in C (following the rules
order)
* Unreserve this transfer (to test the second case)
* Open Transfer: Outgoing shipment (reservation rules demo 2)
* Check availability: it has 250 units, it can empty Zone A, it will take 200 in
Bin A1 and 50 in Bin B1.
* If you want to explore further, you can add a custom domain to exclude rules
(for instance, a product category will not use Zone B).

View File

@@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_stock_reserve_rule_stock_user,access_stock_reserve_rule stock user,model_stock_reserve_rule,stock.group_stock_user,1,0,0,0
access_stock_reserve_rule_manager,access_stock_reserve_rule stock manager,model_stock_reserve_rule,stock.group_stock_manager,1,1,1,1
access_stock_reserve_rule_removal_stock_user,access_stock_reserve_rule_removal stock user,model_stock_reserve_rule_removal,stock.group_stock_user,1,0,0,0
access_stock_reserve_rule_removal_manager,access_stock_reserve_rule_removal stock manager,model_stock_reserve_rule_removal,stock.group_stock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_stock_reserve_rule_stock_user access_stock_reserve_rule stock user model_stock_reserve_rule stock.group_stock_user 1 0 0 0
3 access_stock_reserve_rule_manager access_stock_reserve_rule stock manager model_stock_reserve_rule stock.group_stock_manager 1 1 1 1
4 access_stock_reserve_rule_removal_stock_user access_stock_reserve_rule_removal stock user model_stock_reserve_rule_removal stock.group_stock_user 1 0 0 0
5 access_stock_reserve_rule_removal_manager access_stock_reserve_rule_removal stock manager model_stock_reserve_rule_removal stock.group_stock_manager 1 1 1 1

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="rule_stock_reserve_rule_company" model="ir.rule">
<field name="name">Stock Reservation Rule</field>
<field name="model_id" ref="model_stock_reserve_rule"/>
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
</odoo>

View File

@@ -0,0 +1,519 @@
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils 0.14: http://docutils.sourceforge.net/" />
<title>Stock Reservation Rules</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 7952 2016-07-26 18:15:59Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
See http://docutils.sf.net/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: grey; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="stock-reservation-rules">
<h1 class="title">Stock Reservation Rules</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external" href="https://odoo-community.org/page/development-status"><img alt="Alpha" src="https://img.shields.io/badge/maturity-Alpha-red.png" /></a> <a class="reference external" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_reserve_rule"><img alt="OCA/stock-logistics-warehouse" src="https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github" /></a> <a class="reference external" href="https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_reserve_rule"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external" href="https://runbot.odoo-community.org/runbot/153/12.0"><img alt="Try me on Runbot" src="https://img.shields.io/badge/runbot-Try%20me-875A7B.png" /></a></p>
<p>This module adds rules for advanced reservation / removal strategies.</p>
<p>Rules are applied on a location and its sub-locations.</p>
<p>A rule can exclude quants or locations based on configurable criteria,
and based on the selected quants, apply advanced removal strategies.</p>
<p>The rules have a sequence, which will be respected for the reservation.
So even without filter or advanced removal strategies, we can give a priority to
reserve in a location before another.</p>
<p>The advanced removal strategies are applied on top of the default one (fifo,
fefo, …).</p>
<p>The included advanced removal strategies are:</p>
<ul class="simple">
<li>Default Removal Strategy: apply the default configured one (fifo, fefo, …)</li>
<li>Empty Bins: goods are removed from a bin only if the bin will be empty after
the removal (favor smallest bins first, then apply the default removal
strategy for equal quantities).</li>
<li>Prefer Full Packaging: tries to remove full packaging (configured on the
products) first, by largest to smallest package (default removal strategy is
then applied for equal quantities).</li>
</ul>
<p>Examples of scenario:</p>
<p>rules:</p>
<ul class="simple">
<li>location A: no filter, no advanced removal strategy</li>
<li>location B: no filter, Empty Bins</li>
<li>location C: no filter, no advanced removal strategy</li>
</ul>
<p>result:</p>
<ul class="simple">
<li>take what is available in location A</li>
<li>then take in location B if available, only if bin(s) are emptied</li>
<li>then take what is available in location C</li>
</ul>
<p>The module is meant to be extensible, with a core mechanism on which new rules
and advanced removal strategies can be added.</p>
<div class="admonition important">
<p class="first admonition-title">Important</p>
<p class="last">This is an alpha version, the data model and design can change at any time without warning.
Only for development or testing purpose, do not use in production.
<a class="reference external" href="https://odoo-community.org/page/development-status">More details on development status</a></p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#configuration" id="id1">Configuration</a></li>
<li><a class="reference internal" href="#usage" id="id2">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="id3">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="id4">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="id5">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="id6">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="id7">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="configuration">
<h1><a class="toc-backref" href="#id1">Configuration</a></h1>
<p>The configuration of the rules is done in “Inventory &gt; Configuration &gt; Stock Reservation Rules”.</p>
<p>Creation of a rule:</p>
<p>Properties that define where the rule will be applied:</p>
<ul class="simple">
<li>Location: Define where the rule will look for goods (a parent of the moves source location).</li>
<li>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 moves source location / sub-locations.</li>
<li>Rule Domain: The rule is used only if the Stock Move matches the domain.</li>
</ul>
<p>Removal rules for the locations:</p>
<ul class="simple">
<li>Quants Domain: this domain includes/excludes quants based on a domain.</li>
<li>Advanced Removal Strategy: the strategy that will be used for this location
and sub-location when the rule is used.</li>
</ul>
<p>The sequences have to be sorted in the view list to define the reservation priorities.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id2">Usage</a></h1>
<p>If you are using a database with demo data, you can give a try
to the following scenario to understand how it works.</p>
<p>The demo data created by the module contains:</p>
<p>A product: Funky Socks</p>
<p>3 Locations:</p>
<ul class="simple">
<li>Stock / Zone A / Bin A1: 200 Funky socks</li>
<li>Stock / Zone B / Bin B1: 100 Funky socks</li>
<li>Stock / Zone C / Bin C1: 100 Funky socks</li>
</ul>
<p>3 Reservation Rules, in the following order</p>
<ul class="simple">
<li>Zone A must have full quantities</li>
<li>Zone B</li>
<li>Zone C</li>
</ul>
<p>2 Delivery Orders:</p>
<ul class="simple">
<li>Origin: Outgoing shipment (reservation rules demo 1)</li>
<li>Origin: Outgoing shipment (reservation rules demo 2)</li>
</ul>
<p>Scenario:</p>
<ul class="simple">
<li>Activate Storage Locations and Multi-Warehouses</li>
<li>You can open Inventory &gt; Configuration &gt; Stock Reservation Rules to see the
rules</li>
<li>Open Transfer: Outgoing shipment (reservation rules demo 1)</li>
<li>Check availability: it has 150 units, as it will not empty Zone A, it will not
take products there, it should take 100 in B and 50 in C (following the rules
order)</li>
<li>Unreserve this transfer (to test the second case)</li>
<li>Open Transfer: Outgoing shipment (reservation rules demo 2)</li>
<li>Check availability: it has 250 units, it can empty Zone A, it will take 200 in
Bin A1 and 50 in Bin B1.</li>
<li>If you want to explore further, you can add a custom domain to exclude rules
(for instance, a product category will not use Zone B).</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#id3">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us smashing it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/issues/new?body=module:%20stock_reserve_rule%0Aversion:%2012.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#id4">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#id5">Authors</a></h2>
<ul class="simple">
<li>Camptocamp</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#id6">Contributors</a></h2>
<ul class="simple">
<li>Guewen Baconnier &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id7">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_reserve_rule">OCA/stock-logistics-warehouse</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1 @@
from . import test_reserve_rule

View File

@@ -0,0 +1,561 @@
# Copyright 2019 Camptocamp (https://www.camptocamp.com)
from odoo.tests import common
class TestReserveRule(common.SavepointCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner_delta = cls.env.ref("base.res_partner_4")
cls.wh = cls.env["stock.warehouse"].create(
{
"name": "Base Warehouse",
"reception_steps": "one_step",
"delivery_steps": "pick_ship",
"code": "WHTEST",
}
)
cls.customer_loc = cls.env.ref("stock.stock_location_customers")
cls.loc_zone1 = cls.env["stock.location"].create(
{"name": "Zone1", "location_id": cls.wh.lot_stock_id.id}
)
cls.loc_zone1_bin1 = cls.env["stock.location"].create(
{"name": "Zone1 Bin1", "location_id": cls.loc_zone1.id}
)
cls.loc_zone1_bin2 = cls.env["stock.location"].create(
{"name": "Zone1 Bin2", "location_id": cls.loc_zone1.id}
)
cls.loc_zone2 = cls.env["stock.location"].create(
{"name": "Zone2", "location_id": cls.wh.lot_stock_id.id}
)
cls.loc_zone2_bin1 = cls.env["stock.location"].create(
{"name": "Zone2 Bin1", "location_id": cls.loc_zone2.id}
)
cls.loc_zone2_bin2 = cls.env["stock.location"].create(
{"name": "Zone2 Bin2", "location_id": cls.loc_zone2.id}
)
cls.loc_zone3 = cls.env["stock.location"].create(
{"name": "Zone3", "location_id": cls.wh.lot_stock_id.id}
)
cls.loc_zone3_bin1 = cls.env["stock.location"].create(
{"name": "Zone3 Bin1", "location_id": cls.loc_zone3.id}
)
cls.loc_zone3_bin2 = cls.env["stock.location"].create(
{"name": "Zone3 Bin2", "location_id": cls.loc_zone3.id}
)
cls.product1 = cls.env["product.product"].create(
{"name": "Product 1", "type": "product"}
)
cls.product2 = cls.env["product.product"].create(
{"name": "Product 2", "type": "product"}
)
cls.unit = cls.env["product.packaging.type"].create(
{"name": "Unit", "code": "UNIT", "sequence": 0}
)
cls.retail_box = cls.env["product.packaging.type"].create(
{"name": "Retail Box", "code": "PACK", "sequence": 3}
)
cls.transport_box = cls.env["product.packaging.type"].create(
{"name": "Transport Box", "code": "CASE", "sequence": 4}
)
cls.pallet = cls.env["product.packaging.type"].create(
{"name": "Pallet", "code": "PALLET", "sequence": 5}
)
def _create_picking(self, wh, products=None):
"""Create picking
Products must be a list of tuples (product, quantity).
One stock move will be created for each tuple.
"""
if products is None:
products = []
picking = self.env["stock.picking"].create(
{
"location_id": wh.lot_stock_id.id,
"location_dest_id": wh.wh_output_stock_loc_id.id,
"partner_id": self.partner_delta.id,
"picking_type_id": wh.pick_type_id.id,
}
)
for product, qty in products:
self.env["stock.move"].create(
{
"name": product.name,
"product_id": product.id,
"product_uom_qty": qty,
"product_uom": product.uom_id.id,
"picking_id": picking.id,
"location_id": wh.lot_stock_id.id,
"location_dest_id": wh.wh_output_stock_loc_id.id,
"state": "confirmed",
}
)
return picking
def _update_qty_in_location(self, location, product, quantity):
self.env["stock.quant"]._update_available_quantity(
product, location, quantity
)
def _create_rule(self, rule_values, removal_values):
rule_config = {
"name": "Test Rule",
"location_id": self.wh.lot_stock_id.id,
"rule_removal_ids": [(0, 0, values) for values in removal_values],
}
rule_config.update(rule_values)
self.env["stock.reserve.rule"].create(rule_config)
def _setup_packagings(self, product, packagings):
"""Create packagings on a product
packagings is a list [(name, qty, packaging_type)]
"""
self.env["product.packaging"].create(
[
{
"name": name,
"qty": qty,
"product_id": product.id,
"packaging_type_id": packaging_type.id,
}
for name, qty, packaging_type in packagings
]
)
def test_rule_take_all_in_2(self):
all_locs = (
self.loc_zone1_bin1,
self.loc_zone1_bin2,
self.loc_zone2_bin1,
self.loc_zone2_bin2,
self.loc_zone3_bin1,
self.loc_zone3_bin2,
)
for loc in all_locs:
self._update_qty_in_location(loc, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 200)])
self._create_rule(
{},
[
{"location_id": self.loc_zone1.id, "sequence": 2},
{"location_id": self.loc_zone2.id, "sequence": 1},
{"location_id": self.loc_zone3.id, "sequence": 3},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone2_bin2.id, "product_qty": 100},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_take_all_in_2_and_3(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 150)])
self._create_rule(
{},
[
{"location_id": self.loc_zone1.id, "sequence": 3},
{"location_id": self.loc_zone2.id, "sequence": 1},
{"location_id": self.loc_zone3.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 50},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_remaining(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 400)])
self._create_rule(
{},
[
{"location_id": self.loc_zone1.id, "sequence": 3},
{"location_id": self.loc_zone2.id, "sequence": 1},
{"location_id": self.loc_zone3.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone1_bin1.id, "product_qty": 100},
],
)
self.assertEqual(move.state, "partially_available")
self.assertEqual(move.reserved_availability, 300.)
def test_rule_fallback(self):
reserve = self.env["stock.location"].create(
{"name": "Reserve", "location_id": self.wh.view_location_id.id}
)
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
self._update_qty_in_location(reserve, self.product1, 300)
picking = self._create_picking(self.wh, [(self.product1, 400)])
self._create_rule(
{"fallback_location_id": reserve.id},
[
{"location_id": self.loc_zone1.id, "sequence": 3},
{"location_id": self.loc_zone2.id, "sequence": 1},
{"location_id": self.loc_zone3.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone1_bin1.id, "product_qty": 100},
{"location_id": reserve.id, "product_qty": 100},
],
)
self.assertEqual(move.state, "assigned")
self.assertEqual(move.reserved_availability, 400.)
def test_rule_domain(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 200)])
domain = [("product_id", "!=", self.product1.id)]
self._create_rule(
{"rule_domain": domain, "sequence": 1},
[
# this rule should be excluded by the domain
{"location_id": self.loc_zone1.id, "sequence": 1}
],
)
self._create_rule(
{"sequence": 2},
[
{"location_id": self.loc_zone2.id, "sequence": 1},
{"location_id": self.loc_zone3.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 100},
],
)
self.assertEqual(move.state, "assigned")
def test_quant_domain(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 200)])
domain = [("quantity", ">", 200)]
self._create_rule(
{},
[
# This rule is not excluded by the domain,
# but the quant will be as the quantity is less than 200.
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"quant_domain": domain,
},
{"location_id": self.loc_zone2.id, "sequence": 2},
{"location_id": self.loc_zone3.id, "sequence": 3},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone2_bin1.id, "product_qty": 100},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 100},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_empty_bin(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 300)
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 150)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 50)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 250)])
self._create_rule(
{},
[
# This rule should be excluded for zone1 / bin1 because the
# bin would not be empty, but applied on zone1 / bin2.
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "empty_bin",
},
# this rule should be applied because we will empty the bin
{
"location_id": self.loc_zone2.id,
"sequence": 2,
"removal_strategy": "empty_bin",
},
{"location_id": self.loc_zone3.id, "sequence": 3},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone1_bin2.id, "product_qty": 150.},
{"location_id": self.loc_zone2_bin1.id, "product_qty": 50.},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 50.},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_empty_bin_partial(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 50)
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 50)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 50)
picking = self._create_picking(self.wh, [(self.product1, 80)])
self._create_rule(
{},
[
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "empty_bin",
},
{"location_id": self.loc_zone2.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
# We expect to take 50 in zone1/bin1 as it will empty a bin,
# but zone1/bin2 must not be used as it would not empty it.
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone1_bin1.id, "product_qty": 50.},
{"location_id": self.loc_zone2_bin1.id, "product_qty": 30.},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_empty_bin_largest_first(self):
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 30)
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 60)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 50)
picking = self._create_picking(self.wh, [(self.product1, 80)])
self._create_rule(
{},
[
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "empty_bin",
},
{"location_id": self.loc_zone2.id, "sequence": 2},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
# We expect to take 60 in zone1/bin2 as it will empty a bin,
# and we prefer to take in the largest empty bins first to minimize
# the number of operations.
# Then we cannot take in zone1/bin1 as it would not be empty afterwards
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone1_bin2.id, "product_qty": 60.},
{"location_id": self.loc_zone2_bin1.id, "product_qty": 20.},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_packaging(self):
self._setup_packagings(
self.product1,
[
("Pallet", 500, self.pallet),
("Retail Box", 50, self.retail_box),
],
)
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 40)
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 510)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 60)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 100)
picking = self._create_picking(self.wh, [(self.product1, 590)])
self._create_rule(
{},
[
# due to this rule and the packaging size of 500, we will
# not use zone1/bin1, but zone1/bin2 will be used.
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "packaging",
},
# zone2/bin2 will match the second packaging size of 50
{
"location_id": self.loc_zone2.id,
"sequence": 2,
"removal_strategy": "packaging",
},
# the rest should be taken here
{"location_id": self.loc_zone3.id, "sequence": 3},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone1_bin2.id, "product_qty": 500.},
{"location_id": self.loc_zone2_bin1.id, "product_qty": 50.},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 40.},
],
)
self.assertEqual(move.state, "assigned")
def test_rule_packaging_0_packaging(self):
# a packaging mistakenly created with a 0 qty should be ignored,
# not make the reservation fail
self._setup_packagings(
self.product1,
[
("Pallet", 500, self.pallet),
("Retail Box", 50, self.retail_box),
("DivisionByZero", 0, self.unit),
],
)
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 40)
picking = self._create_picking(self.wh, [(self.product1, 590)])
self._create_rule(
{},
[
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "packaging",
}
],
)
# Here, it will try to reserve a pallet of 500, then an outer box of
# 50, then should ignore the one with 0 not to fail because of division
# by zero
picking.action_assign()
def test_rule_packaging_type(self):
# only take one kind of packaging
self._setup_packagings(
self.product1,
[
("Pallet", 500, self.pallet),
("Transport Box", 50, self.transport_box),
("Retail Box", 10, self.retail_box),
("Unit", 1, self.unit),
],
)
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 40)
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 600)
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 30)
self._update_qty_in_location(self.loc_zone2_bin2, self.product1, 500)
self._update_qty_in_location(self.loc_zone3_bin1, self.product1, 500)
picking = self._create_picking(self.wh, [(self.product1, 560)])
self._create_rule(
{},
[
# we'll take one pallet (500) from zone1/bin2, but as we filter
# on pallets only, we won't take the 600 out of it (if the rule
# had no type, we would have taken 100 of transport boxes).
{
"location_id": self.loc_zone1.id,
"sequence": 1,
"removal_strategy": "packaging",
"packaging_type_ids": [(6, 0, self.pallet.ids)],
},
# zone2/bin2 will match the second packaging size of 50,
# but won't take 60 because it doesn't take retail boxes
{
"location_id": self.loc_zone2.id,
"sequence": 2,
"removal_strategy": "packaging",
"packaging_type_ids": [(6, 0, self.transport_box.ids)],
},
# the rest should be taken here
{"location_id": self.loc_zone3.id, "sequence": 3},
],
)
picking.action_assign()
move = picking.move_lines
ml = move.move_line_ids
self.assertRecordValues(
ml,
[
{"location_id": self.loc_zone1_bin2.id, "product_qty": 500.},
{"location_id": self.loc_zone2_bin2.id, "product_qty": 50.},
{"location_id": self.loc_zone3_bin1.id, "product_qty": 10.},
],
)
self.assertEqual(move.state, "assigned")

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_stock_reserve_rule_form" model="ir.ui.view">
<field name="name">stock.reserve.rule.form</field>
<field name="model">stock.reserve.rule</field>
<field name="arch" type="xml">
<form string="Stock Reservation Rule">
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
</button>
</div>
<label for="name" class="oe_edit_only"/>
<h1><field name="name"/></h1>
<group string="Rule Applicability" name="configuration">
<field name="location_id"/>
<field name="fallback_location_id"/>
<field name="rule_domain" widget="domain" options="{'model': 'stock.move'}" />
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="Removal Rules" name="rule">
<field name="rule_removal_ids" nolabel="1">
<tree string="Removal Rules">
<field name="sequence" widget="handle" />
<field name="name"/>
<field name="location_id"/>
<field name="removal_strategy"/>
</tree>
<form string="Removal Rule">
<group>
<field name="name"/>
<field name="location_id" domain="[('id', 'child_of', parent.location_id)]" />
<field name="removal_strategy"/>
<field name="packaging_type_ids"
widget="many2many_tags"
attrs="{'invisible': [('removal_strategy', '!=', 'packaging')]}"
/>
<field name="quant_domain" widget="domain" options="{'model': 'stock.quant'}" />
</group>
</form>
</field>
</group>
</form>
</field>
</record>
<record id="view_stock_reserve_rule_search" model="ir.ui.view">
<field name="name">stock.reserve.rule.search</field>
<field name="model">stock.reserve.rule</field>
<field name="arch" type="xml">
<search string="Stock Reservation Rule">
<field name="name"/>
<field name="location_id"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
</search>
</field>
</record>
<record id="view_stock_reserve_rule_tree" model="ir.ui.view">
<field name="name">stock.reserve.rule</field>
<field name="model">stock.reserve.rule</field>
<field name="arch" type="xml">
<tree string="Stock Reservation Rule">
<field name="sequence" widget="handle" />
<field name="name"/>
<field name="location_id"/>
</tree>
</field>
</record>
<record id="action_stock_reserve_rule" model="ir.actions.act_window">
<field name="name">Stock Reservation Rules</field>
<field name="res_model">stock.reserve.rule</field>
<field name="type">ir.actions.act_window</field>
<field name="view_type">form</field>
<field name="view_id" ref="view_stock_reserve_rule_tree"/>
<field name="search_view_id" ref="view_stock_reserve_rule_search"/>
<field name="context"></field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add a Stock Reservation Rule
</p>
</field>
</record>
<menuitem action="action_stock_reserve_rule"
id="menu_stock_reserve_rule"
parent="stock.menu_warehouse_config" sequence="10"/>
</odoo>