mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
Add stock_reserve_rule
This commit is contained in:
committed by
Sébastien Alix
parent
878eb3d889
commit
fdab54b432
182
stock_reserve_rule/README.rst
Normal file
182
stock_reserve_rule/README.rst
Normal 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.
|
||||
1
stock_reserve_rule/__init__.py
Normal file
1
stock_reserve_rule/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
29
stock_reserve_rule/__manifest__.py
Normal file
29
stock_reserve_rule/__manifest__.py
Normal 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',
|
||||
}
|
||||
18
stock_reserve_rule/demo/product_demo.xml
Normal file
18
stock_reserve_rule/demo/product_demo.xml
Normal 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>
|
||||
34
stock_reserve_rule/demo/stock_inventory_demo.xml
Normal file
34
stock_reserve_rule/demo/stock_inventory_demo.xml
Normal 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>
|
||||
29
stock_reserve_rule/demo/stock_location_demo.xml
Normal file
29
stock_reserve_rule/demo/stock_location_demo.xml
Normal 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>
|
||||
40
stock_reserve_rule/demo/stock_picking_demo.xml
Normal file
40
stock_reserve_rule/demo/stock_picking_demo.xml
Normal 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>
|
||||
32
stock_reserve_rule/demo/stock_reserve_rule_demo.xml
Normal file
32
stock_reserve_rule/demo/stock_reserve_rule_demo.xml
Normal 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>
|
||||
3
stock_reserve_rule/models/__init__.py
Normal file
3
stock_reserve_rule/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import stock_move
|
||||
from . import stock_quant
|
||||
from . import stock_reserve_rule
|
||||
139
stock_reserve_rule/models/stock_move.py
Normal file
139
stock_reserve_rule/models/stock_move.py
Normal 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,
|
||||
)
|
||||
30
stock_reserve_rule/models/stock_quant.py
Normal file
30
stock_reserve_rule/models/stock_quant.py
Normal 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()]
|
||||
287
stock_reserve_rule/models/stock_reserve_rule.py
Normal file
287
stock_reserve_rule/models/stock_reserve_rule.py
Normal 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
|
||||
18
stock_reserve_rule/readme/CONFIGURE.rst
Normal file
18
stock_reserve_rule/readme/CONFIGURE.rst
Normal 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.
|
||||
1
stock_reserve_rule/readme/CONTRIBUTORS.rst
Normal file
1
stock_reserve_rule/readme/CONTRIBUTORS.rst
Normal file
@@ -0,0 +1 @@
|
||||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
40
stock_reserve_rule/readme/DESCRIPTION.rst
Normal file
40
stock_reserve_rule/readme/DESCRIPTION.rst
Normal 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.
|
||||
39
stock_reserve_rule/readme/USAGE.rst
Normal file
39
stock_reserve_rule/readme/USAGE.rst
Normal 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).
|
||||
5
stock_reserve_rule/security/ir.model.access.csv
Normal file
5
stock_reserve_rule/security/ir.model.access.csv
Normal 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
|
||||
|
10
stock_reserve_rule/security/stock_reserve_rule_security.xml
Normal file
10
stock_reserve_rule/security/stock_reserve_rule_security.xml
Normal 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>
|
||||
519
stock_reserve_rule/static/description/index.html
Normal file
519
stock_reserve_rule/static/description/index.html
Normal 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 > Configuration > 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 move’s 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 move’s 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 > Configuration > 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 <<a class="reference external" href="mailto:guewen.baconnier@camptocamp.com">guewen.baconnier@camptocamp.com</a>></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>
|
||||
1
stock_reserve_rule/tests/__init__.py
Normal file
1
stock_reserve_rule/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_reserve_rule
|
||||
561
stock_reserve_rule/tests/test_reserve_rule.py
Normal file
561
stock_reserve_rule/tests/test_reserve_rule.py
Normal 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")
|
||||
92
stock_reserve_rule/views/stock_reserve_rule_views.xml
Normal file
92
stock_reserve_rule/views/stock_reserve_rule_views.xml
Normal 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>
|
||||
Reference in New Issue
Block a user