mirror of
https://github.com/OCA/stock-logistics-warehouse.git
synced 2025-01-21 14:27:28 +02:00
1
setup/stock_reserve_rule/odoo/addons/stock_reserve_rule
Symbolic link
1
setup/stock_reserve_rule/odoo/addons/stock_reserve_rule
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../stock_reserve_rule
|
||||
6
setup/stock_reserve_rule/setup.py
Normal file
6
setup/stock_reserve_rule/setup.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup(
|
||||
setup_requires=['setuptools-odoo'],
|
||||
odoo_addon=True,
|
||||
)
|
||||
176
stock_reserve_rule/README.rst
Normal file
176
stock_reserve_rule/README.rst
Normal file
@@ -0,0 +1,176 @@
|
||||
=======================
|
||||
Stock Reservation Rules
|
||||
=======================
|
||||
|
||||
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
!! This file is generated by oca-gen-addon-readme !!
|
||||
!! changes will be overwritten. !!
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |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/15.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-15-0/stock-logistics-warehouse-15-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/15.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 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.
|
||||
|
||||
**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).
|
||||
* 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 activate
|
||||
and see the rules (by default in demo, the rules are created inactive)
|
||||
* 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:%2015.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>
|
||||
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
|
||||
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/15.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": "16.0.1.0.0",
|
||||
"author": "Camptocamp, Odoo Community Association (OCA)",
|
||||
"website": "https://github.com/OCA/stock-logistics-warehouse",
|
||||
"category": "Stock Management",
|
||||
"depends": [
|
||||
"stock",
|
||||
"stock_helper",
|
||||
"product_packaging_level",
|
||||
],
|
||||
"demo": [
|
||||
"data/demo/product_demo.xml",
|
||||
"data/demo/stock_location_demo.xml",
|
||||
"data/demo/stock_reserve_rule_demo.xml",
|
||||
"data/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": "Beta",
|
||||
"license": "AGPL-3",
|
||||
}
|
||||
15
stock_reserve_rule/data/demo/product_demo.xml
Normal file
15
stock_reserve_rule/data/demo/product_demo.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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" />
|
||||
</record>
|
||||
</odoo>
|
||||
27
stock_reserve_rule/data/demo/stock_location_demo.xml
Normal file
27
stock_reserve_rule/data/demo/stock_location_demo.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?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>
|
||||
45
stock_reserve_rule/data/demo/stock_picking_demo.xml
Normal file
45
stock_reserve_rule/data/demo/stock_picking_demo.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?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_ids"
|
||||
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_ids"
|
||||
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>
|
||||
28
stock_reserve_rule/data/demo/stock_reserve_rule_demo.xml
Normal file
28
stock_reserve_rule/data/demo/stock_reserve_rule_demo.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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" />
|
||||
<field name="active" eval="False" />
|
||||
</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>
|
||||
247
stock_reserve_rule/i18n/stock_reserve_rule.pot
Normal file
247
stock_reserve_rule/i18n/stock_reserve_rule.pot
Normal file
@@ -0,0 +1,247 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * stock_reserve_rule
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 15.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__active
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.actions.act_window,help:stock_reserve_rule.action_stock_reserve_rule
|
||||
msgid "Add a Reservation Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__removal_strategy
|
||||
msgid "Advanced Removal Strategy"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule__picking_type_ids
|
||||
msgid "Apply this rule only if the operation type of the move is the same."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_form
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_search
|
||||
msgid "Archived"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__company_id
|
||||
msgid "Company"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__create_uid
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__create_date
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields.selection,name:stock_reserve_rule.selection__stock_reserve_rule_removal__removal_strategy__default
|
||||
msgid "Default Removal Strategy"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule_removal__removal_strategy
|
||||
msgid ""
|
||||
"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)."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__name
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__name
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__display_name
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule__rule_domain
|
||||
msgid ""
|
||||
"Domain based on Stock Moves, to define if the rule is applicable or not."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields.selection,name:stock_reserve_rule.selection__stock_reserve_rule_removal__removal_strategy__empty_bin
|
||||
msgid "Empty Bins"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule_removal__quant_domain
|
||||
msgid ""
|
||||
"Filter Quants allowed to be reserved for this location and sub-locations."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields.selection,name:stock_reserve_rule.selection__stock_reserve_rule_removal__removal_strategy__packaging
|
||||
msgid "Full Packaging"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__id
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule____last_update
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__write_uid
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__write_date
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__location_id
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__location_id
|
||||
msgid "Location"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__picking_type_ids
|
||||
msgid "Operation Types"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule_removal__packaging_type_ids
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__packaging_type_ids
|
||||
msgid "Packaging Type"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model,name:stock_reserve_rule.model_stock_picking_type
|
||||
msgid "Picking Type"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model,name:stock_reserve_rule.model_stock_quant
|
||||
msgid "Quants"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__quant_domain
|
||||
msgid "Quants Domain"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_form
|
||||
msgid "Removal Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_form
|
||||
msgid "Removal Rules"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: code:addons/stock_reserve_rule/models/stock_reserve_rule.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Removal rule '{}' location has to be a child of the rule location '{}'."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_form
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_search
|
||||
msgid "Reservation Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.actions.act_window,name:stock_reserve_rule.action_stock_reserve_rule
|
||||
#: model:ir.ui.menu,name:stock_reserve_rule.menu_stock_reserve_rule
|
||||
msgid "Reservation Rules"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_picking_type__reserve_rule_ids
|
||||
msgid "Reserve Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__rule_id
|
||||
msgid "Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_form
|
||||
msgid "Rule Applicability"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__rule_domain
|
||||
msgid "Rule Domain"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__rule_removal_ids
|
||||
msgid "Rule Removal"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,help:stock_reserve_rule.field_stock_reserve_rule__location_id
|
||||
msgid "Rule applied only in this location and sub-locations."
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule__sequence
|
||||
#: model:ir.model.fields,field_description:stock_reserve_rule.field_stock_reserve_rule_removal__sequence
|
||||
msgid "Sequence"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model,name:stock_reserve_rule.model_stock_move
|
||||
msgid "Stock Move"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model,name:stock_reserve_rule.model_stock_reserve_rule
|
||||
msgid "Stock Reservation Rule"
|
||||
msgstr ""
|
||||
|
||||
#. module: stock_reserve_rule
|
||||
#: model:ir.model,name:stock_reserve_rule.model_stock_reserve_rule_removal
|
||||
msgid "Stock Reservation Rule Removal"
|
||||
msgstr ""
|
||||
4
stock_reserve_rule/models/__init__.py
Normal file
4
stock_reserve_rule/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import stock_move
|
||||
from . import stock_quant
|
||||
from . import stock_picking_type
|
||||
from . import stock_reserve_rule
|
||||
133
stock_reserve_rule/models/stock_move.py
Normal file
133
stock_reserve_rule/models/stock_move.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# Copyright 2019 Camptocamp SA
|
||||
# Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
# 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:
|
||||
# Exclude any rule which does not share the same path as the
|
||||
# move's location. Example:
|
||||
# Rule location: Stock
|
||||
# Removal rule 1: Stock/Zone1
|
||||
# Removal rule 2: Stock/Zone2
|
||||
# If we have a stock.move with "Stock" as source location,
|
||||
# it can use both rules.
|
||||
# If we have a stock.move with "Stock/Zone2" as source location,
|
||||
# it should never use "Stock/Zone1"
|
||||
# If we have a stock.move with "Stock/Zone1/A" as source location,
|
||||
# it should use "Stock/Zone1" rule
|
||||
if not (
|
||||
removal_rule.location_id.is_sublocation_of(location_id)
|
||||
or location_id.is_sublocation_of(removal_rule.location_id)
|
||||
):
|
||||
continue
|
||||
|
||||
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
|
||||
# We should break between quants if original needs is fulfilled
|
||||
# TODO: Check if float_is_zero should be more appropriate
|
||||
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
|
||||
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
|
||||
return reserved
|
||||
|
||||
# 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,
|
||||
)
|
||||
12
stock_reserve_rule/models/stock_picking_type.py
Normal file
12
stock_reserve_rule/models/stock_picking_type.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# Copyright 2020 Camptocamp (https://www.camptocamp.com)
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class StockPickingType(models.Model):
|
||||
_inherit = "stock.picking.type"
|
||||
|
||||
reserve_rule_ids = fields.Many2many(
|
||||
comodel_name="stock.reserve.rule",
|
||||
)
|
||||
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 [(loc, quants) for loc, quants in seen.items()]
|
||||
292
stock_reserve_rule/models/stock_reserve_rule.py
Normal file
292
stock_reserve_rule/models/stock_reserve_rule.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# Copyright 2019 Camptocamp SA
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_sequence(record):
|
||||
maxrule = record.search([], order="sequence desc", limit=1)
|
||||
if maxrule:
|
||||
return maxrule.sequence + 10
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
class StockReserveRule(models.Model):
|
||||
"""Rules for stock reservations
|
||||
|
||||
Each rule can have many removal rules, they configure the conditions and
|
||||
advanced removal strategies to apply on a specific location (sub-location
|
||||
of the rule).
|
||||
|
||||
The rules are selected for a move based on their source location and a
|
||||
configurable domain on the rule.
|
||||
"""
|
||||
|
||||
_name = "stock.reserve.rule"
|
||||
_description = "Stock Reservation Rule"
|
||||
_order = "sequence, id"
|
||||
|
||||
name = fields.Char(string="Description", required=True)
|
||||
sequence = fields.Integer(default=lambda s: _default_sequence(s))
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
comodel_name="res.company", default=lambda self: self.env.company.id
|
||||
)
|
||||
|
||||
location_id = fields.Many2one(
|
||||
comodel_name="stock.location",
|
||||
required=True,
|
||||
help="Rule applied only in this location and sub-locations.",
|
||||
)
|
||||
picking_type_ids = fields.Many2many(
|
||||
comodel_name="stock.picking.type",
|
||||
string="Operation Types",
|
||||
help="Apply this rule only if the operation type of the move is the same.",
|
||||
)
|
||||
|
||||
rule_removal_ids = fields.One2many(
|
||||
comodel_name="stock.reserve.rule.removal", inverse_name="rule_id"
|
||||
)
|
||||
|
||||
rule_domain = fields.Char(
|
||||
default=[],
|
||||
help="Domain based on Stock Moves, to define if the "
|
||||
"rule is applicable or not.",
|
||||
)
|
||||
|
||||
def _rules_for_location(self, location):
|
||||
return self.search([("location_id", "parent_of", location.id)])
|
||||
|
||||
def _eval_rule_domain(self, move, domain):
|
||||
move_domain = [("id", "=", move.id)]
|
||||
# Warning: if we build a domain with dotted path such
|
||||
# as group_id.is_urgent (hypothetic field), can become very
|
||||
# slow as odoo searches all "procurement.group.is_urgent" first
|
||||
# then uses "IN group_ids" on the stock move only.
|
||||
# In such situations, it can be better either to add a related
|
||||
# field on the stock.move, either extend _eval_rule_domain to
|
||||
# add your own logic (based on SQL, ...).
|
||||
return bool(
|
||||
self.env["stock.move"].search(
|
||||
expression.AND([move_domain, domain]), limit=1
|
||||
)
|
||||
)
|
||||
|
||||
def _is_rule_applicable(self, move):
|
||||
if self.picking_type_ids:
|
||||
picking_type = move.picking_type_id or move.picking_id.picking_type_id
|
||||
if picking_type not in self.picking_type_ids:
|
||||
return False
|
||||
domain = safe_eval(self.rule_domain) or []
|
||||
if domain:
|
||||
return self._eval_rule_domain(move, domain)
|
||||
return True
|
||||
|
||||
|
||||
class StockReserveRuleRemoval(models.Model):
|
||||
"""Rules for stock reservations removal
|
||||
|
||||
A removal rule does:
|
||||
|
||||
* Filter quants that a removal rule can reserve for the location
|
||||
(_filter_quants)
|
||||
* An advanced removal strategy for the preselected quants (_apply_strategy)
|
||||
|
||||
New advanced removal strategies can be added by other modules, see the
|
||||
method ``_apply_strategy`` and the default methods for more documentation
|
||||
about their contract.
|
||||
"""
|
||||
|
||||
_name = "stock.reserve.rule.removal"
|
||||
_description = "Stock Reservation Rule Removal"
|
||||
_order = "sequence, id"
|
||||
|
||||
rule_id = fields.Many2one(
|
||||
comodel_name="stock.reserve.rule", required=True, ondelete="cascade"
|
||||
)
|
||||
name = fields.Char(string="Description")
|
||||
location_id = fields.Many2one(comodel_name="stock.location", required=True)
|
||||
|
||||
sequence = fields.Integer(default=lambda s: _default_sequence(s))
|
||||
|
||||
# quants exclusion
|
||||
quant_domain = fields.Char(
|
||||
string="Quants Domain",
|
||||
default=[],
|
||||
help="Filter Quants allowed to be reserved for this location "
|
||||
"and sub-locations.",
|
||||
)
|
||||
|
||||
# advanced removal strategy
|
||||
removal_strategy = fields.Selection(
|
||||
string="Advanced Removal Strategy",
|
||||
selection=[
|
||||
("default", "Default Removal Strategy"),
|
||||
("empty_bin", "Empty Bins"),
|
||||
("packaging", "Full Packaging"),
|
||||
],
|
||||
required=True,
|
||||
default="default",
|
||||
help="Defines if and how goods are taken from locations."
|
||||
"Default: take the first ones with the configured Removal Strategy"
|
||||
"(FIFO, FEFO, ...).\n"
|
||||
"Empty Bins: take goods from a location only if the bin is"
|
||||
" empty afterwards.\n"
|
||||
"Full Packaging: take goods from a location only if the location "
|
||||
"quantity matches a packaging quantity (do not open boxes).",
|
||||
)
|
||||
|
||||
packaging_level_ids = fields.Many2many(
|
||||
comodel_name="product.packaging.level",
|
||||
help="Optional packaging level when using 'Full Packaging'.\n",
|
||||
)
|
||||
|
||||
@api.constrains("location_id")
|
||||
def _constraint_location_id(self):
|
||||
"""The location has to be a child of the rule location."""
|
||||
for removal_rule in self:
|
||||
if not removal_rule.location_id.is_sublocation_of(
|
||||
removal_rule.rule_id.location_id
|
||||
):
|
||||
msg = _(
|
||||
"Removal rule '%(removal_name)s' location has to be a child "
|
||||
"of the rule location '%(child_rule)s'.",
|
||||
removal_name=removal_rule.name,
|
||||
child_rule=removal_rule.rule_id.location_id.display_name,
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
def _eval_quant_domain(self, quants, domain):
|
||||
quant_domain = [("id", "in", quants.ids)]
|
||||
return self.env["stock.quant"].search(expression.AND([quant_domain, domain]))
|
||||
|
||||
def _filter_quants(self, move, quants):
|
||||
domain = safe_eval(self.quant_domain) or []
|
||||
if domain:
|
||||
return self._eval_quant_domain(quants, domain)
|
||||
return quants
|
||||
|
||||
def _apply_strategy(self, quants):
|
||||
"""Apply the advanced removal strategy
|
||||
|
||||
New methods can be added by:
|
||||
|
||||
- Adding a selection in the 'removal_strategy' field.
|
||||
- adding a method named after the selection value
|
||||
(_apply_strategy_SELECTION)
|
||||
|
||||
A strategy has to comply with this signature: (self, quants)
|
||||
Where 'self' is the current rule and 'quants' are the candidate
|
||||
quants allowed for the rule, sorted by the company's removal
|
||||
strategy (fifo, fefo, ...).
|
||||
It has to get the initial need using 'need = yield' once, then,
|
||||
each time the strategy decides to take quantities in a location,
|
||||
it has to yield and retrieve the remaining needed using:
|
||||
|
||||
need = yield location, location_quantity, quantity_to_take
|
||||
|
||||
See '_apply_strategy_default' for a short example.
|
||||
|
||||
"""
|
||||
method_name = "_apply_strategy_%s" % (self.removal_strategy)
|
||||
yield from getattr(self, method_name)(quants)
|
||||
|
||||
def _apply_strategy_default(self, quants):
|
||||
need = yield
|
||||
# Propose quants in the same order than returned originally by
|
||||
# the _gather method, so based on fifo, fefo, ...
|
||||
for quant in quants:
|
||||
need = yield (
|
||||
quant.location_id,
|
||||
quant.quantity - quant.reserved_quantity,
|
||||
need,
|
||||
)
|
||||
|
||||
def _apply_strategy_empty_bin(self, quants):
|
||||
need = yield
|
||||
# Group by location (in this removal strategies, we want to consider
|
||||
# the total quantity held in a location).
|
||||
quants_per_bin = quants._group_by_location()
|
||||
# We take goods only if we empty the bin.
|
||||
# The original ordering (fefo, fifo, ...) must be kept.
|
||||
product = fields.first(quants).product_id
|
||||
rounding = product.uom_id.rounding
|
||||
locations_with_other_quants = [
|
||||
group["location_id"][0]
|
||||
for group in quants.read_group(
|
||||
[
|
||||
("location_id", "in", quants.location_id.ids),
|
||||
("product_id", "not in", quants.product_id.ids),
|
||||
("quantity", ">", 0),
|
||||
],
|
||||
["location_id"],
|
||||
"location_id",
|
||||
)
|
||||
]
|
||||
for location, location_quants in quants_per_bin:
|
||||
if location.id in locations_with_other_quants:
|
||||
continue
|
||||
|
||||
location_quantity = sum(location_quants.mapped("quantity")) - sum(
|
||||
location_quants.mapped("reserved_quantity")
|
||||
)
|
||||
|
||||
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_level_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_level_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 location, location_quants in quants_per_bin:
|
||||
location_quantity = sum(location_quants.mapped("quantity")) - sum(
|
||||
location_quants.mapped("reserved_quantity")
|
||||
)
|
||||
if location_quantity <= 0:
|
||||
continue
|
||||
|
||||
for pack_quantity in packaging_quantities:
|
||||
enough_for_packaging = is_greater_eq(location_quantity, pack_quantity)
|
||||
asked_at_least_packaging_qty = is_greater_eq(need, pack_quantity)
|
||||
if enough_for_packaging and asked_at_least_packaging_qty:
|
||||
# compute how much packaging we can get
|
||||
take = (need // pack_quantity) * pack_quantity
|
||||
need = yield location, location_quantity, take
|
||||
16
stock_reserve_rule/readme/CONFIGURE.rst
Normal file
16
stock_reserve_rule/readme/CONFIGURE.rst
Normal file
@@ -0,0 +1,16 @@
|
||||
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).
|
||||
* 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.
|
||||
3
stock_reserve_rule/readme/CONTRIBUTORS.rst
Normal file
3
stock_reserve_rule/readme/CONTRIBUTORS.rst
Normal file
@@ -0,0 +1,3 @@
|
||||
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
|
||||
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
* Denis Roussel <denis.roussel@acsone.eu>
|
||||
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 activate
|
||||
and see the rules (by default in demo, the rules are created inactive)
|
||||
* 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', 'in', company_ids),('company_id','=',False)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
BIN
stock_reserve_rule/static/description/icon.png
Normal file
BIN
stock_reserve_rule/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
512
stock_reserve_rule/static/description/index.html
Normal file
512
stock_reserve_rule/static/description/index.html
Normal file
@@ -0,0 +1,512 @@
|
||||
<?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.15.1: 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="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.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/15.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-15-0/stock-logistics-warehouse-15-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/15.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 largest bins first to minimize the number of operations,
|
||||
then apply the default removal strategy for equal quantities).</li>
|
||||
<li>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).</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>
|
||||
<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>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 activate
|
||||
and see the rules (by default in demo, the rules are created inactive)</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:%2015.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>
|
||||
<li>Jacques-Etienne Baudoux (BCIM) <<a class="reference external" href="mailto:je@bcim.be">je@bcim.be</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/15.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
|
||||
755
stock_reserve_rule/tests/test_reserve_rule.py
Normal file
755
stock_reserve_rule/tests/test_reserve_rule.py
Normal file
@@ -0,0 +1,755 @@
|
||||
# Copyright 2019 Camptocamp (https://www.camptocamp.com)
|
||||
# Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
|
||||
|
||||
from odoo import exceptions, fields
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestReserveRule(common.TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
|
||||
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.level"].create(
|
||||
{"name": "Unit", "code": "UNIT", "sequence": 0}
|
||||
)
|
||||
cls.retail_box = cls.env["product.packaging.level"].create(
|
||||
{"name": "Retail Box", "code": "RET", "sequence": 3}
|
||||
)
|
||||
cls.transport_box = cls.env["product.packaging.level"].create(
|
||||
{"name": "Transport Box", "code": "BOX", "sequence": 4}
|
||||
)
|
||||
cls.pallet = cls.env["product.packaging.level"].create(
|
||||
{"name": "Pallet", "code": "PAL", "sequence": 5}
|
||||
)
|
||||
|
||||
def _create_picking(self, wh, products=None, location_src_id=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": location_src_id or 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": location_src_id or 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, in_date=None):
|
||||
self.env["stock.quant"]._update_available_quantity(
|
||||
product, location, quantity, in_date=in_date
|
||||
)
|
||||
|
||||
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)
|
||||
# workaround for https://github.com/odoo/odoo/pull/41900
|
||||
self.env["stock.reserve.rule"].invalidate_cache()
|
||||
|
||||
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 if qty else 1,
|
||||
"product_id": product.id,
|
||||
"packaging_level_id": packaging_level.id,
|
||||
}
|
||||
for name, qty, packaging_level in packagings
|
||||
]
|
||||
)
|
||||
|
||||
def test_removal_rule_location_child_of_rule_location(self):
|
||||
# removal rule location is a child
|
||||
self._create_rule({}, [{"location_id": self.loc_zone1.id}])
|
||||
# removal rule location is not a child
|
||||
with self.assertRaises(exceptions.ValidationError):
|
||||
self._create_rule(
|
||||
{}, [{"location_id": self.env.ref("stock.stock_location_locations").id}]
|
||||
)
|
||||
|
||||
def test_rule_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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone2_bin2.id, "reserved_uom_qty": 100},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_rule_match_parent(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.loc_zone1.id
|
||||
)
|
||||
|
||||
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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone1_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone1_bin2.id, "reserved_uom_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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone1_bin1.id, "reserved_uom_qty": 100},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "partially_available")
|
||||
self.assertEqual(move.reserved_availability, 300.0)
|
||||
|
||||
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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_qty": 100},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_picking_type(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)])
|
||||
|
||||
self._create_rule(
|
||||
# different picking, should be excluded
|
||||
{"picking_type_ids": [(6, 0, self.wh.int_type_id.ids)], "sequence": 1},
|
||||
[{"location_id": self.loc_zone1.id, "sequence": 1}],
|
||||
)
|
||||
self._create_rule(
|
||||
# same picking type as the move
|
||||
{"picking_type_ids": [(6, 0, self.wh.pick_type_id.ids)], "sequence": 2},
|
||||
[
|
||||
{"location_id": self.loc_zone2.id, "sequence": 1},
|
||||
{"location_id": self.loc_zone3.id, "sequence": 2},
|
||||
],
|
||||
)
|
||||
picking.action_assign()
|
||||
move = picking.move_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 100},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_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_ids
|
||||
ml = move.move_line_ids
|
||||
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 150.0},
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 50.0},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_qty": 50.0},
|
||||
],
|
||||
)
|
||||
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_ids
|
||||
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, "reserved_uom_qty": 50.0},
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 30.0},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_rule_empty_bin_fifo(self):
|
||||
self._update_qty_in_location(
|
||||
self.loc_zone1_bin1,
|
||||
self.product1,
|
||||
30,
|
||||
in_date=fields.Datetime.to_datetime("2021-01-04 12:00:00"),
|
||||
)
|
||||
self._update_qty_in_location(
|
||||
self.loc_zone1_bin2,
|
||||
self.product1,
|
||||
60,
|
||||
in_date=fields.Datetime.to_datetime("2021-01-02 12:00:00"),
|
||||
)
|
||||
self._update_qty_in_location(
|
||||
self.loc_zone2_bin1,
|
||||
self.product1,
|
||||
50,
|
||||
in_date=fields.Datetime.to_datetime("2021-01-05 12:00:00"),
|
||||
)
|
||||
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_ids
|
||||
ml = move.move_line_ids
|
||||
|
||||
# We expect to take 60 in zone1/bin2 as it will empty a bin and
|
||||
# respecting fifo, the 60 of zone2 should be taken before the 30 of
|
||||
# zone1. Then, as zone1/bin1 would not be empty, it is discarded. The
|
||||
# remaining is taken in zone2 which has no rule.
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 60.0},
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 20.0},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_rule_empty_bin_multiple_allocation(self):
|
||||
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 10)
|
||||
self._update_qty_in_location(self.loc_zone1_bin1, self.product2, 10)
|
||||
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 10)
|
||||
picking = self._create_picking(self.wh, [(self.product1, 10)])
|
||||
|
||||
self._create_rule(
|
||||
{},
|
||||
[
|
||||
# This rule should be excluded for zone1 / bin1 because the
|
||||
# bin would not be empty
|
||||
{
|
||||
"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_ids
|
||||
ml = move.move_line_ids
|
||||
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 10.0},
|
||||
],
|
||||
)
|
||||
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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 500.0},
|
||||
{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 50.0},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_qty": 40.0},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_rule_packaging_fifo(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,
|
||||
500,
|
||||
in_date=fields.Datetime.to_datetime("2021-01-04 12:00:00"),
|
||||
)
|
||||
self._update_qty_in_location(
|
||||
self.loc_zone1_bin2,
|
||||
self.product1,
|
||||
500,
|
||||
in_date=fields.Datetime.to_datetime("2021-01-02 12:00:00"),
|
||||
)
|
||||
self._create_rule(
|
||||
{},
|
||||
[
|
||||
{
|
||||
"location_id": self.loc_zone1.id,
|
||||
"sequence": 1,
|
||||
"removal_strategy": "packaging",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# take in bin2 to respect fifo
|
||||
picking = self._create_picking(self.wh, [(self.product1, 50)])
|
||||
picking.action_assign()
|
||||
self.assertRecordValues(
|
||||
picking.move_ids.move_line_ids,
|
||||
[{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 50.0}],
|
||||
)
|
||||
picking2 = self._create_picking(self.wh, [(self.product1, 50)])
|
||||
picking2.action_assign()
|
||||
self.assertRecordValues(
|
||||
picking2.move_ids.move_line_ids,
|
||||
[{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 50.0}],
|
||||
)
|
||||
|
||||
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_level(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_level_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_level_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_ids
|
||||
ml = move.move_line_ids
|
||||
self.assertRecordValues(
|
||||
ml,
|
||||
[
|
||||
{"location_id": self.loc_zone1_bin2.id, "reserved_uom_qty": 500.0},
|
||||
{"location_id": self.loc_zone2_bin2.id, "reserved_uom_qty": 50.0},
|
||||
{"location_id": self.loc_zone3_bin1.id, "reserved_uom_qty": 10.0},
|
||||
],
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_rule_excluded_not_child_location(self):
|
||||
self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100)
|
||||
self._update_qty_in_location(self.loc_zone1_bin2, self.product1, 100)
|
||||
self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 100)
|
||||
picking = self._create_picking(self.wh, [(self.product1, 80)])
|
||||
|
||||
self._create_rule(
|
||||
{},
|
||||
[
|
||||
{"location_id": self.loc_zone1.id, "sequence": 1},
|
||||
{"location_id": self.loc_zone2.id, "sequence": 2},
|
||||
],
|
||||
)
|
||||
move = picking.move_ids
|
||||
|
||||
move.location_id = self.loc_zone2
|
||||
picking.action_assign()
|
||||
ml = move.move_line_ids
|
||||
|
||||
# As the source location of the stock.move is loc_zone2, we should
|
||||
# never take any quantity in zone1.
|
||||
|
||||
self.assertRecordValues(
|
||||
ml, [{"location_id": self.loc_zone2_bin1.id, "reserved_uom_qty": 80.0}]
|
||||
)
|
||||
self.assertEqual(move.state, "assigned")
|
||||
|
||||
def test_several_rules_same_loc_negative(self):
|
||||
"""
|
||||
We have several rules for the same location
|
||||
We have two quants in the location with one negative
|
||||
|
||||
"""
|
||||
|
||||
self.env["stock.quant"].create(
|
||||
{
|
||||
"location_id": self.loc_zone1_bin1.id,
|
||||
"quantity": 10.0,
|
||||
"product_id": self.product1.id,
|
||||
}
|
||||
)
|
||||
self.env["stock.quant"].create(
|
||||
{
|
||||
"location_id": self.loc_zone1_bin1.id,
|
||||
"quantity": -2.0,
|
||||
"product_id": self.product1.id,
|
||||
}
|
||||
)
|
||||
|
||||
picking = self._create_picking(self.wh, [(self.product1, 1.0)])
|
||||
self._create_rule(
|
||||
{},
|
||||
[
|
||||
{
|
||||
"location_id": self.loc_zone1_bin1.id,
|
||||
"removal_strategy": "packaging",
|
||||
"sequence": 1,
|
||||
},
|
||||
{"location_id": self.loc_zone1_bin1.id, "sequence": 2},
|
||||
],
|
||||
)
|
||||
picking.action_assign()
|
||||
131
stock_reserve_rule/views/stock_reserve_rule_views.xml
Normal file
131
stock_reserve_rule/views/stock_reserve_rule_views.xml
Normal file
@@ -0,0 +1,131 @@
|
||||
<?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="Reservation Rule">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
</div>
|
||||
<widget
|
||||
name="web_ribbon"
|
||||
title="Archived"
|
||||
bg_color="bg-danger"
|
||||
attrs="{'invisible': [('active', '=', True)]}"
|
||||
/>
|
||||
<label for="name" class="oe_edit_only" />
|
||||
<h1>
|
||||
<field name="name" />
|
||||
</h1>
|
||||
<group string="Rule Applicability" name="configuration">
|
||||
<group>
|
||||
<field name="active" invisible="1" />
|
||||
<field name="location_id" />
|
||||
<field
|
||||
name="picking_type_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': 1}"
|
||||
/>
|
||||
<field name="sequence" />
|
||||
</group>
|
||||
<group>
|
||||
<field
|
||||
name="rule_domain"
|
||||
widget="domain"
|
||||
options="{'model': 'stock.move', 'in_dialog': true}"
|
||||
/>
|
||||
<field
|
||||
name="company_id"
|
||||
groups="base.group_multi_company"
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Removal Rules" name="rule" col="1">
|
||||
<field name="rule_removal_ids" nolabel="1">
|
||||
<tree name="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_level_ids"
|
||||
widget="many2many_tags"
|
||||
attrs="{'invisible': [('removal_strategy', '!=', 'packaging')]}"
|
||||
/>
|
||||
<field
|
||||
name="quant_domain"
|
||||
widget="domain"
|
||||
options="{'model': 'stock.quant', 'in_dialog': true}"
|
||||
/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</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="Reservation Rule">
|
||||
<field name="name" />
|
||||
<field name="location_id" />
|
||||
<field name="picking_type_ids" />
|
||||
<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 name="Reservation Rule">
|
||||
<field name="sequence" widget="handle" />
|
||||
<field name="name" />
|
||||
<field name="location_id" />
|
||||
<field
|
||||
name="picking_type_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': 1}"
|
||||
/>
|
||||
<field name="rule_domain" />
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_stock_reserve_rule" model="ir.actions.act_window">
|
||||
<field name="name">Reservation Rules</field>
|
||||
<field name="res_model">stock.reserve.rule</field>
|
||||
<field name="type">ir.actions.act_window</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 name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add a 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