diff --git a/setup/stock_reserve_rule/odoo/addons/stock_reserve_rule b/setup/stock_reserve_rule/odoo/addons/stock_reserve_rule new file mode 120000 index 000000000..72a8b8a03 --- /dev/null +++ b/setup/stock_reserve_rule/odoo/addons/stock_reserve_rule @@ -0,0 +1 @@ +../../../../stock_reserve_rule \ No newline at end of file diff --git a/setup/stock_reserve_rule/setup.py b/setup/stock_reserve_rule/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_reserve_rule/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_reserve_rule/README.rst b/stock_reserve_rule/README.rst new file mode 100644 index 000000000..47468c95f --- /dev/null +++ b/stock_reserve_rule/README.rst @@ -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 `_. +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier +* Jacques-Etienne Baudoux (BCIM) + +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 `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/stock_reserve_rule/__init__.py b/stock_reserve_rule/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_reserve_rule/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_reserve_rule/__manifest__.py b/stock_reserve_rule/__manifest__.py new file mode 100644 index 000000000..465b91500 --- /dev/null +++ b/stock_reserve_rule/__manifest__.py @@ -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", +} diff --git a/stock_reserve_rule/data/demo/product_demo.xml b/stock_reserve_rule/data/demo/product_demo.xml new file mode 100644 index 000000000..2b897a3f8 --- /dev/null +++ b/stock_reserve_rule/data/demo/product_demo.xml @@ -0,0 +1,15 @@ + + + + RS700 + Funky Socks + product + + 30.0 + 20.0 + 1.0 + none + + + + diff --git a/stock_reserve_rule/data/demo/stock_location_demo.xml b/stock_reserve_rule/data/demo/stock_location_demo.xml new file mode 100644 index 000000000..b9327b429 --- /dev/null +++ b/stock_reserve_rule/data/demo/stock_location_demo.xml @@ -0,0 +1,27 @@ + + + + Zone A + + + + Zone B + + + + Zone C + + + + Bin A1 + + + + Bin B1 + + + + Bin C1 + + + diff --git a/stock_reserve_rule/data/demo/stock_picking_demo.xml b/stock_reserve_rule/data/demo/stock_picking_demo.xml new file mode 100644 index 000000000..e32fa35a5 --- /dev/null +++ b/stock_reserve_rule/data/demo/stock_picking_demo.xml @@ -0,0 +1,45 @@ + + + + + Outgoing shipment (reservation rules demo 1) + + + + + + + + + Outgoing shipment (reservation rules demo 2) + + + + + + + diff --git a/stock_reserve_rule/data/demo/stock_reserve_rule_demo.xml b/stock_reserve_rule/data/demo/stock_reserve_rule_demo.xml new file mode 100644 index 000000000..e3edc0aa4 --- /dev/null +++ b/stock_reserve_rule/data/demo/stock_reserve_rule_demo.xml @@ -0,0 +1,28 @@ + + + + Stock + 1 + + + + + + + 1 + + empty_bin + + + + 2 + + default + + + + 3 + + default + + diff --git a/stock_reserve_rule/i18n/stock_reserve_rule.pot b/stock_reserve_rule/i18n/stock_reserve_rule.pot new file mode 100644 index 000000000..caaace3ff --- /dev/null +++ b/stock_reserve_rule/i18n/stock_reserve_rule.pot @@ -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 "" diff --git a/stock_reserve_rule/models/__init__.py b/stock_reserve_rule/models/__init__.py new file mode 100644 index 000000000..2dc701116 --- /dev/null +++ b/stock_reserve_rule/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_move +from . import stock_quant +from . import stock_picking_type +from . import stock_reserve_rule diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py new file mode 100644 index 000000000..34902032f --- /dev/null +++ b/stock_reserve_rule/models/stock_move.py @@ -0,0 +1,133 @@ +# Copyright 2019 Camptocamp SA +# Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) +# 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, + ) diff --git a/stock_reserve_rule/models/stock_picking_type.py b/stock_reserve_rule/models/stock_picking_type.py new file mode 100644 index 000000000..79cccf387 --- /dev/null +++ b/stock_reserve_rule/models/stock_picking_type.py @@ -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", + ) diff --git a/stock_reserve_rule/models/stock_quant.py b/stock_reserve_rule/models/stock_quant.py new file mode 100644 index 000000000..1236b6883 --- /dev/null +++ b/stock_reserve_rule/models/stock_quant.py @@ -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()] diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py new file mode 100644 index 000000000..fbbf62690 --- /dev/null +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -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 diff --git a/stock_reserve_rule/readme/CONFIGURE.rst b/stock_reserve_rule/readme/CONFIGURE.rst new file mode 100644 index 000000000..3052a5e99 --- /dev/null +++ b/stock_reserve_rule/readme/CONFIGURE.rst @@ -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. diff --git a/stock_reserve_rule/readme/CONTRIBUTORS.rst b/stock_reserve_rule/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..5d7dbd491 --- /dev/null +++ b/stock_reserve_rule/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Guewen Baconnier +* Jacques-Etienne Baudoux (BCIM) +* Denis Roussel diff --git a/stock_reserve_rule/readme/DESCRIPTION.rst b/stock_reserve_rule/readme/DESCRIPTION.rst new file mode 100644 index 000000000..0a2b7b680 --- /dev/null +++ b/stock_reserve_rule/readme/DESCRIPTION.rst @@ -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. diff --git a/stock_reserve_rule/readme/USAGE.rst b/stock_reserve_rule/readme/USAGE.rst new file mode 100644 index 000000000..f36f876e1 --- /dev/null +++ b/stock_reserve_rule/readme/USAGE.rst @@ -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). diff --git a/stock_reserve_rule/security/ir.model.access.csv b/stock_reserve_rule/security/ir.model.access.csv new file mode 100644 index 000000000..198cc7e7c --- /dev/null +++ b/stock_reserve_rule/security/ir.model.access.csv @@ -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 diff --git a/stock_reserve_rule/security/stock_reserve_rule_security.xml b/stock_reserve_rule/security/stock_reserve_rule_security.xml new file mode 100644 index 000000000..9ae20fadb --- /dev/null +++ b/stock_reserve_rule/security/stock_reserve_rule_security.xml @@ -0,0 +1,10 @@ + + + + Stock Reservation Rule + + ['|',('company_id', 'in', company_ids),('company_id','=',False)] + + diff --git a/stock_reserve_rule/static/description/icon.png b/stock_reserve_rule/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/stock_reserve_rule/static/description/icon.png differ diff --git a/stock_reserve_rule/static/description/index.html b/stock_reserve_rule/static/description/index.html new file mode 100644 index 000000000..1b66272da --- /dev/null +++ b/stock_reserve_rule/static/description/index.html @@ -0,0 +1,512 @@ + + + + + + +Stock Reservation Rules + + + +
+

Stock Reservation Rules

+ + +

Beta License: AGPL-3 OCA/stock-logistics-warehouse Translate me on Weblate Try me on Runbot

+

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

+ +
+

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

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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 project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/stock_reserve_rule/tests/__init__.py b/stock_reserve_rule/tests/__init__.py new file mode 100644 index 000000000..2515a764e --- /dev/null +++ b/stock_reserve_rule/tests/__init__.py @@ -0,0 +1 @@ +from . import test_reserve_rule diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py new file mode 100644 index 000000000..7047db89f --- /dev/null +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -0,0 +1,755 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# Copyright 2019-2021 Jacques-Etienne Baudoux (BCIM) + +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() diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml new file mode 100644 index 000000000..bb2638c9f --- /dev/null +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -0,0 +1,131 @@ + + + + stock.reserve.rule.form + stock.reserve.rule + +
+ +
+
+ +
+ +
+
+ + stock.reserve.rule.search + stock.reserve.rule + + + + + + + + + + + + stock.reserve.rule + stock.reserve.rule + + + + + + + + + + + + Reservation Rules + stock.reserve.rule + ir.actions.act_window + + + + +

+ Add a Reservation Rule +

+
+
+ +