Merge PR #1794 into 16.0

Signed-off-by jbaudoux
This commit is contained in:
OCA-git-bot
2024-10-07 08:58:39 +00:00
26 changed files with 2558 additions and 0 deletions

View File

@@ -0,0 +1 @@
../../../../stock_reserve_rule

View File

@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)

View 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.

View File

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

View File

@@ -0,0 +1,29 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "Stock Reservation Rules",
"summary": "Configure reservation rules by location",
"version": "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",
}

View 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>

View 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>

View 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>

View 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>

View 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 ""

View File

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

View 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,
)

View 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",
)

View File

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

View 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

View 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.

View File

@@ -0,0 +1,3 @@
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
* Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
* Denis Roussel <denis.roussel@acsone.eu>

View File

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

View File

@@ -0,0 +1,39 @@
If you are using a database with demo data, you can give a try
to the following scenario to understand how it works.
The demo data created by the module contains:
A product: Funky Socks
3 Locations:
* Stock / Zone A / Bin A1: 200 Funky socks
* Stock / Zone B / Bin B1: 100 Funky socks
* Stock / Zone C / Bin C1: 100 Funky socks
3 Reservation Rules, in the following order
* Zone A must have full quantities
* Zone B
* Zone C
2 Delivery Orders:
* Origin: Outgoing shipment (reservation rules demo 1)
* Origin: Outgoing shipment (reservation rules demo 2)
Scenario:
* Activate Storage Locations and Multi-Warehouses
* You can open Inventory > Configuration > Stock Reservation Rules to 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).

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View 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 &gt; Configuration &gt; Stock Reservation Rules”.</p>
<p>Creation of a rule:</p>
<p>Properties that define where the rule will be applied:</p>
<ul class="simple">
<li>Location: Define where the rule will look for goods (a parent of the moves source location).</li>
<li>Rule Domain: The rule is used only if the Stock Move matches the domain.</li>
</ul>
<p>Removal rules for the locations:</p>
<ul class="simple">
<li>Quants Domain: this domain includes/excludes quants based on a domain.</li>
<li>Advanced Removal Strategy: the strategy that will be used for this location
and sub-location when the rule is used.</li>
</ul>
<p>The sequences have to be sorted in the view list to define the reservation priorities.</p>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#id2">Usage</a></h1>
<p>If you are using a database with demo data, you can give a try
to the following scenario to understand how it works.</p>
<p>The demo data created by the module contains:</p>
<p>A product: Funky Socks</p>
<p>3 Locations:</p>
<ul class="simple">
<li>Stock / Zone A / Bin A1: 200 Funky socks</li>
<li>Stock / Zone B / Bin B1: 100 Funky socks</li>
<li>Stock / Zone C / Bin C1: 100 Funky socks</li>
</ul>
<p>3 Reservation Rules, in the following order</p>
<ul class="simple">
<li>Zone A must have full quantities</li>
<li>Zone B</li>
<li>Zone C</li>
</ul>
<p>2 Delivery Orders:</p>
<ul class="simple">
<li>Origin: Outgoing shipment (reservation rules demo 1)</li>
<li>Origin: Outgoing shipment (reservation rules demo 2)</li>
</ul>
<p>Scenario:</p>
<ul class="simple">
<li>Activate Storage Locations and Multi-Warehouses</li>
<li>You can open Inventory &gt; Configuration &gt; Stock Reservation Rules to 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 &lt;<a class="reference external" href="mailto:guewen.baconnier&#64;camptocamp.com">guewen.baconnier&#64;camptocamp.com</a>&gt;</li>
<li>Jacques-Etienne Baudoux (BCIM) &lt;<a class="reference external" href="mailto:je&#64;bcim.be">je&#64;bcim.be</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#id7">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org"><img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" /></a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/stock-logistics-warehouse/tree/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>

View File

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

View 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()

View 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>