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..c72fbe438 --- /dev/null +++ b/stock_reserve_rule/README.rst @@ -0,0 +1,182 @@ +======================= +Stock Reservation Rules +======================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--warehouse-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-warehouse/tree/12.0/stock_reserve_rule + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-12-0/stock-logistics-warehouse-12-0-stock_reserve_rule + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds rules for advanced reservation / removal strategies. + +Rules are applied on a location and its sub-locations. + +A rule can exclude quants or locations based on configurable criteria, +and based on the selected quants, apply advanced removal strategies. + +The rules have a sequence, which will be respected for the reservation. +So even without filter or advanced removal strategies, we can give a priority to +reserve in a location before another. + +The advanced removal strategies are applied on top of the default one (fifo, +fefo, ...). + +The included advanced removal strategies are: + +* Default Removal Strategy: apply the default configured one (fifo, fefo, ...) +* Empty Bins: goods are removed from a bin only if the bin will be empty after + the removal (favor smallest bins first, then apply the default removal + strategy for equal quantities). +* Prefer Full Packaging: tries to remove full packaging (configured on the + products) first, by largest to smallest package (default removal strategy is + then applied for equal quantities). + +Examples of scenario: + +rules: + +* location A: no filter, no advanced removal strategy +* location B: no filter, Empty Bins +* location C: no filter, no advanced removal strategy + +result: + +* take what is available in location A +* then take in location B if available, only if bin(s) are emptied +* then take what is available in location C + +The module is meant to be extensible, with a core mechanism on which new rules +and advanced removal strategies can be added. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +The configuration of the rules is done in "Inventory > Configuration > Stock Reservation Rules". + +Creation of a rule: + +Properties that define where the rule will be applied: + +* Location: Define where the rule will look for goods (a parent of the move's source location). +* Fallback Location: Define where the goods are reserved if none of the removal rule could reserve + the goods. If left empty, the goods are reserved in the move's source location / sub-locations. +* Rule Domain: The rule is used only if the Stock Move matches the domain. + +Removal rules for the locations: + +* Quants Domain: this domain includes/excludes quants based on a domain. +* Advanced Removal Strategy: the strategy that will be used for this location + and sub-location when the rule is used. + +The sequences have to be sorted in the view list to define the reservation priorities. + +Usage +===== + +If you are using a database with demo data, you can give a try +to the following scenario to understand how it works. + +The demo data created by the module contains: + +A product: Funky Socks + +3 Locations: + +* Stock / Zone A / Bin A1: 200 Funky socks +* Stock / Zone B / Bin B1: 100 Funky socks +* Stock / Zone C / Bin C1: 100 Funky socks + +3 Reservation Rules, in the following order + +* Zone A must have full quantities +* Zone B +* Zone C + +2 Delivery Orders: + +* Origin: Outgoing shipment (reservation rules demo 1) +* Origin: Outgoing shipment (reservation rules demo 2) + +Scenario: + +* Activate Storage Locations and Multi-Warehouses +* You can open Inventory > Configuration > Stock Reservation Rules to see the + rules +* Open Transfer: Outgoing shipment (reservation rules demo 1) +* Check availability: it has 150 units, as it will not empty Zone A, it will not + take products there, it should take 100 in B and 50 in C (following the rules + order) +* Unreserve this transfer (to test the second case) +* Open Transfer: Outgoing shipment (reservation rules demo 2) +* Check availability: it has 250 units, it can empty Zone A, it will take 200 in + Bin A1 and 50 in Bin B1. +* If you want to explore further, you can add a custom domain to exclude rules + (for instance, a product category will not use Zone B). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +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 + +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..679ece206 --- /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': '12.0.1.0.0', + 'author': "Camptocamp, Odoo Community Association (OCA)", + 'website': "https://github.com/OCA/stock-logistics-warehouse", + 'category': 'Stock Management', + 'depends': [ + 'stock', + 'product_packaging_type', # OCA/product-attribute + ], + 'demo': [ + 'demo/product_demo.xml', + 'demo/stock_location_demo.xml', + 'demo/stock_reserve_rule_demo.xml', + 'demo/stock_inventory_demo.xml', + 'demo/stock_picking_demo.xml', + ], + 'data': [ + 'views/stock_reserve_rule_views.xml', + 'security/ir.model.access.csv', + 'security/stock_reserve_rule_security.xml', + ], + 'installable': True, + 'development_status': 'Alpha', + 'license': 'AGPL-3', +} diff --git a/stock_reserve_rule/demo/product_demo.xml b/stock_reserve_rule/demo/product_demo.xml new file mode 100644 index 000000000..b4618d189 --- /dev/null +++ b/stock_reserve_rule/demo/product_demo.xml @@ -0,0 +1,18 @@ + + + + + RS700 + Funky Socks + product + + 30.0 + 20.0 + 1.0 + none + + + + + + diff --git a/stock_reserve_rule/demo/stock_inventory_demo.xml b/stock_reserve_rule/demo/stock_inventory_demo.xml new file mode 100644 index 000000000..1ab318d57 --- /dev/null +++ b/stock_reserve_rule/demo/stock_inventory_demo.xml @@ -0,0 +1,34 @@ + + + + + Funky Socks Demo Inventory + + + + + + + 200.0 + + + + + + + 100.0 + + + + + + + 100.0 + + + + + + + + diff --git a/stock_reserve_rule/demo/stock_location_demo.xml b/stock_reserve_rule/demo/stock_location_demo.xml new file mode 100644 index 000000000..cec2c6a2a --- /dev/null +++ b/stock_reserve_rule/demo/stock_location_demo.xml @@ -0,0 +1,29 @@ + + + + + Zone A + + + + Zone B + + + + Zone C + + + + Bin A1 + + + + Bin B1 + + + + Bin C1 + + + + diff --git a/stock_reserve_rule/demo/stock_picking_demo.xml b/stock_reserve_rule/demo/stock_picking_demo.xml new file mode 100644 index 000000000..321e40d75 --- /dev/null +++ b/stock_reserve_rule/demo/stock_picking_demo.xml @@ -0,0 +1,40 @@ + + + + + + Outgoing shipment (reservation rules demo 1) + + + + + + + + + + Outgoing shipment (reservation rules demo 2) + + + + + + + + diff --git a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml new file mode 100644 index 000000000..b79e35ec3 --- /dev/null +++ b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml @@ -0,0 +1,32 @@ + + + + + Stock + 1 + + + + + + + 1 + + empty_bin + + + + + 2 + + default + + + + + 3 + + default + + + diff --git a/stock_reserve_rule/models/__init__.py b/stock_reserve_rule/models/__init__.py new file mode 100644 index 000000000..2f1eab9ba --- /dev/null +++ b/stock_reserve_rule/models/__init__.py @@ -0,0 +1,3 @@ +from . import stock_move +from . import stock_quant +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..e7aca4c2e --- /dev/null +++ b/stock_reserve_rule/models/stock_move.py @@ -0,0 +1,139 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models +from odoo.tools.float_utils import float_compare + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _update_reserved_quantity( + self, + need, + available_quantity, + location_id, + lot_id=None, + package_id=None, + owner_id=None, + strict=True, + ): + """Create or update move lines.""" + if strict: + # chained moves must take what was reserved by the previous move + return super()._update_reserved_quantity( + need, + available_quantity, + location_id=location_id, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + rules = self.env["stock.reserve.rule"]._rules_for_location(location_id) + + forced_package_id = self.package_level_id.package_id or None + rounding = self.product_id.uom_id.rounding + + still_need = need + for rule in rules: + # 1st check if rule is applicable from the move + if not rule._is_rule_applicable(self): + continue + + for removal_rule in rule.rule_removal_ids: + quants = self.env["stock.quant"]._gather( + self.product_id, + removal_rule.location_id, + lot_id=lot_id, + package_id=forced_package_id, + owner_id=owner_id, + strict=strict, + ) + + # get quants allowed by the rule + rule_quants = removal_rule._filter_quants(self, quants) + if not rule_quants: + continue + + # Apply the advanced removal strategy, if any. Even within the + # application of the removal strategy, the original company's + # one should be respected (eg. if we remove quants that would + # empty bins first, in case of equality, we should remove the + # fifo or fefo first depending of the configuration). + strategy = removal_rule._apply_strategy(rule_quants) + next(strategy) + while True: + try: + next_quant = strategy.send(still_need) + if not next_quant: + continue + location, location_quantity, to_take = next_quant + taken_in_loc = super()._update_reserved_quantity( + # in this strategy, we take as much as we can + # from this bin + to_take, + location_quantity, + location_id=location, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + still_need -= taken_in_loc + except StopIteration: + break + + need_zero = ( + float_compare(still_need, 0, precision_rounding=rounding) + != 1 + ) + if need_zero: + # useless to eval the other rules when still_need <= 0 + break + + reserved = need - still_need + if rule.fallback_location_id: + quants = self.env["stock.quant"]._gather( + self.product_id, + rule.fallback_location_id, + lot_id=lot_id, + package_id=forced_package_id, + owner_id=owner_id, + strict=strict, + ) + fallback_quantity = sum(quants.mapped("quantity")) - sum( + quants.mapped("reserved_quantity") + ) + return reserved + super()._update_reserved_quantity( + still_need, + fallback_quantity, + location_id=rule.fallback_location_id, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + + else: + # Implicit fallback on the original location + return reserved + super()._update_reserved_quantity( + still_need, + available_quantity - reserved, + location_id=location_id, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + + # We fall here if there is no rule or they have all been + # excluded by 'rule._is_rule_applicable' + return super()._update_reserved_quantity( + need, + available_quantity, + location_id=location_id, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) diff --git a/stock_reserve_rule/models/stock_quant.py b/stock_reserve_rule/models/stock_quant.py new file mode 100644 index 000000000..bb78bd532 --- /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 [(location, quants) for location, 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..41a6a393c --- /dev/null +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -0,0 +1,287 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models +from odoo.tools.safe_eval import safe_eval +from odoo.osv import expression +from odoo.tools.float_utils import float_compare + + +def _default_sequence(record): + maxrule = record.search([], order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 + + +class StockReserveRule(models.Model): + """Rules for stock reservations + + Each rule can have many removal rules, they configure the conditions and + advanced removal strategies to apply on a specific location (sub-location + of the rule). + + The rules are selected for a move based on their source location and a + configurable domain on the rule. + """ + + _name = "stock.reserve.rule" + _description = "Stock Reservation Rule" + _order = "sequence, id" + + name = fields.Char(string="Description", required=True) + sequence = fields.Integer(default=lambda s: _default_sequence(s)) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + comodel_name="res.company", + default=lambda self: self.env.user.company_id.id, + ) + + location_id = fields.Many2one(comodel_name="stock.location", required=True) + fallback_location_id = fields.Many2one( + comodel_name="stock.location", + help="If all removal rules are exhausted, try to reserve in this " + "location. When empty, the fallback happens in any of the move's " + "source sub-locations.", + ) + + rule_removal_ids = fields.One2many( + comodel_name="stock.reserve.rule.removal", inverse_name="rule_id" + ) + + rule_domain = fields.Char( + string="Rule Domain", + default=[], + help="Domain based on Stock Moves, to define if the " + "rule is applicable or not.", + ) + + def _rules_for_location(self, location): + return self.search([("location_id", "parent_of", location.id)]) + + def _eval_rule_domain(self, move, domain): + move_domain = [("id", "=", move.id)] + # Warning: if we build a domain with dotted path such + # as group_id.is_urgent (hypothetic field), can become very + # slow as odoo searches all "procurement.group.is_urgent" first + # then uses "IN group_ids" on the stock move only. + # In such situations, it can be better either to add a related + # field on the stock.move, either extend _eval_rule_domain to + # add your own logic (based on SQL, ...). + return bool( + self.env["stock.move"].search( + expression.AND([move_domain, domain]), limit=1 + ) + ) + + def _is_rule_applicable(self, move): + domain = safe_eval(self.rule_domain) or [] + if domain: + return self._eval_rule_domain(move, domain) + return True + + +class StockReserveRuleRemoval(models.Model): + """Rules for stock reservations removal + + A removal rule does: + + * Filter quants that a removal rule can reserve for the location + (_filter_quants) + * An advanced removal strategy for the preselected quants (_apply_strategy) + + New advanced removal strategies can be added by other modules, see the + method ``_apply_strategy`` and the default methods for more documentation + about their contract. + """ + + _name = "stock.reserve.rule.removal" + _description = "Stock Reservation Rule Removal" + _order = "sequence, id" + + rule_id = fields.Many2one( + comodel_name="stock.reserve.rule", required=True, ondelete="cascade" + ) + name = fields.Char(string="Description") + location_id = fields.Many2one(comodel_name="stock.location", required=True) + + sequence = fields.Integer(default=lambda s: _default_sequence(s)) + + # quants exclusion + quant_domain = fields.Char( + string="Quants Domain", + default=[], + help="Filter Quants allowed to be reserved for this location " + "and sub-locations.", + ) + + # advanced removal strategy + removal_strategy = fields.Selection( + string="Advanced Removal Strategy", + selection=[ + ("default", "Default Removal Strategy"), + ("empty_bin", "Empty Bins"), + ("packaging", "Full Packaging"), + ], + required=True, + default="default", + help="Defines if and how goods are taken from locations." + "Default: take the first ones with the configured Removal Strategy" + "(FIFO, FEFO, ...).\n" + "Empty Bins: take goods from a location only if the bin is" + " empty afterwards.\n" + "Full Packaging: take goods from a location only if the location " + "quantity matches a packaging quantity (do not open boxes).", + ) + + packaging_type_ids = fields.Many2many( + comodel_name="product.packaging.type", + help="Optional packaging when using 'Full Packaging'.\n" + "Only the quantities matching one of the packaging are removed.\n" + "When empty, any packaging can be removed.", + ) + + def _eval_quant_domain(self, quants, domain): + quant_domain = [("id", "in", quants.ids)] + return self.env["stock.quant"].search( + expression.AND([quant_domain, domain]) + ) + + def _filter_quants(self, move, quants): + domain = safe_eval(self.quant_domain) or [] + if domain: + return self._eval_quant_domain(quants, domain) + return quants + + def _apply_strategy(self, quants): + """Apply the advanced removal strategy + + New methods can be added by: + + - Adding a selection in the 'removal_strategy' field. + - adding a method named after the selection value + (_apply_strategy_SELECTION) + + A strategy has to comply with this signature: (self, quants) + Where 'self' is the current rule and 'quants' are the candidate + quants allowed for the rule, sorted by the company's removal + strategy (fifo, fefo, ...). + It has to get the initial need using 'need = yield' once, then, + each time the strategy decides to take quantities in a location, + it has to yield and retrieve the remaining needed using: + + need = yield location, location_quantity, quantity_to_take + + See '_apply_strategy_default' for a short example. + + """ + method_name = "_apply_strategy_%s" % (self.removal_strategy) + yield from getattr(self, method_name)(quants) + + def _apply_strategy_default(self, quants): + need = yield + # Propose quants in the same order than returned originally by + # the _gather method, so based on fifo, fefo, ... + for quant in quants: + need = yield ( + quant.location_id, + quant.quantity - quant.reserved_quantity, + need, + ) + + def _apply_strategy_empty_bin(self, quants): + need = yield + # Group by location (in this removal strategies, we want to consider + # the total quantity held in a location). + quants_per_bin = quants._group_by_location() + + # We want to limit the operations as much as possible. + # We'll sort the quants desc so we can fulfill as much as possible + # from as few as possible locations. The best case being an exact + # match. + # The original ordering (fefo, fifo, ...) must be kept. + + bins = sorted( + [ + ( + sum(quants.mapped("quantity")) + - sum(quants.mapped("reserved_quantity")), + quants, + location, + ) + for location, quants in quants_per_bin + ], + reverse=True, + ) + + # Propose the largest quants first, so we have as less operations + # as possible. We take goods only if we empty the bin. + rounding = fields.first(quants).product_id.uom_id.rounding + for location_quantity, quants, location in bins: + if location_quantity <= 0: + continue + + if float_compare(need, location_quantity, rounding) != -1: + need = yield location, location_quantity, need + + def _apply_strategy_packaging(self, quants): + need = yield + # Group by location (in this removal strategies, we want to consider + # the total quantity held in a location). + quants_per_bin = quants._group_by_location() + + product = fields.first(quants).product_id + + packaging_type_filter = self.packaging_type_ids + + # we'll walk the packagings from largest to smallest to have the + # largest containers as possible (1 pallet rather than 10 boxes) + packaging_quantities = sorted( + product.packaging_ids.filtered( + lambda packaging: ( + packaging.qty > 0 + and ( + packaging.packaging_type_id in packaging_type_filter + if packaging_type_filter + else True + ) + ) + ).mapped("qty"), + reverse=True, + ) + + rounding = product.uom_id.rounding + + def is_greater_eq(value, other): + return ( + float_compare(value, other, precision_rounding=rounding) >= 0 + ) + + for pack_quantity in packaging_quantities: + # Get quants quantity on each loop because they may change. + # Sort by max quant first so we have more chance to take a full + # package. But keep the original ordering for equal quantities! + bins = sorted( + [ + ( + sum(quants.mapped("quantity")) + - sum(quants.mapped("reserved_quantity")), + quants, + location, + ) + for location, quants in quants_per_bin + ], + reverse=True, + ) + + for location_quantity, quants, location in bins: + if location_quantity <= 0: + continue + enough_for_packaging = is_greater_eq( + location_quantity, pack_quantity + ) + asked_more_than_packaging = is_greater_eq(need, pack_quantity) + if enough_for_packaging and asked_more_than_packaging: + # compute how much packaging we can get + take = (need // pack_quantity) * pack_quantity + need = yield location, location_quantity, take diff --git a/stock_reserve_rule/readme/CONFIGURE.rst b/stock_reserve_rule/readme/CONFIGURE.rst new file mode 100644 index 000000000..2b2c96abe --- /dev/null +++ b/stock_reserve_rule/readme/CONFIGURE.rst @@ -0,0 +1,18 @@ +The configuration of the rules is done in "Inventory > Configuration > Stock Reservation Rules". + +Creation of a rule: + +Properties that define where the rule will be applied: + +* Location: Define where the rule will look for goods (a parent of the move's source location). +* Fallback Location: Define where the goods are reserved if none of the removal rule could reserve + the goods. If left empty, the goods are reserved in the move's source location / sub-locations. +* Rule Domain: The rule is used only if the Stock Move matches the domain. + +Removal rules for the locations: + +* Quants Domain: this domain includes/excludes quants based on a domain. +* Advanced Removal Strategy: the strategy that will be used for this location + and sub-location when the rule is used. + +The sequences have to be sorted in the view list to define the reservation priorities. diff --git a/stock_reserve_rule/readme/CONTRIBUTORS.rst b/stock_reserve_rule/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..48286263c --- /dev/null +++ b/stock_reserve_rule/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier 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..37bd4a315 --- /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 see the + rules +* Open Transfer: Outgoing shipment (reservation rules demo 1) +* Check availability: it has 150 units, as it will not empty Zone A, it will not + take products there, it should take 100 in B and 50 in C (following the rules + order) +* Unreserve this transfer (to test the second case) +* Open Transfer: Outgoing shipment (reservation rules demo 2) +* Check availability: it has 250 units, it can empty Zone A, it will take 200 in + Bin A1 and 50 in Bin B1. +* If you want to explore further, you can add a custom domain to exclude rules + (for instance, a product category will not use Zone B). 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..011a80096 --- /dev/null +++ b/stock_reserve_rule/security/stock_reserve_rule_security.xml @@ -0,0 +1,10 @@ + + + + + Stock Reservation Rule + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + diff --git a/stock_reserve_rule/static/description/index.html b/stock_reserve_rule/static/description/index.html new file mode 100644 index 000000000..b410ab119 --- /dev/null +++ b/stock_reserve_rule/static/description/index.html @@ -0,0 +1,519 @@ + + + + + + +Stock Reservation Rules + + + +
+

Stock Reservation Rules

+ + +

Alpha 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 smallest bins first, then apply the default removal +strategy for equal quantities).
  • +
  • Prefer Full Packaging: tries to remove full packaging (configured on the +products) first, by largest to smallest package (default removal strategy is +then applied for equal quantities).
  • +
+

Examples of scenario:

+

rules:

+
    +
  • location A: no filter, no advanced removal strategy
  • +
  • location B: no filter, Empty Bins
  • +
  • location C: no filter, no advanced removal strategy
  • +
+

result:

+
    +
  • take what is available in location A
  • +
  • then take in location B if available, only if bin(s) are emptied
  • +
  • then take what is available in location C
  • +
+

The module is meant to be extensible, with a core mechanism on which new rules +and advanced removal strategies can be added.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

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).
  • +
  • Fallback Location: Define where the goods are reserved if none of the removal rule could reserve +the goods. If left empty, the goods are reserved in the move’s source location / sub-locations.
  • +
  • Rule Domain: The rule is used only if the Stock Move matches the domain.
  • +
+

Removal rules for the locations:

+
    +
  • Quants Domain: this domain includes/excludes quants based on a domain.
  • +
  • Advanced Removal Strategy: the strategy that will be used for this location +and sub-location when the rule is used.
  • +
+

The sequences have to be sorted in the view list to define the reservation priorities.

+
+
+

Usage

+

If you are using a database with demo data, you can give a try +to the following scenario to understand how it works.

+

The demo data created by the module contains:

+

A product: Funky Socks

+

3 Locations:

+
    +
  • Stock / Zone A / Bin A1: 200 Funky socks
  • +
  • Stock / Zone B / Bin B1: 100 Funky socks
  • +
  • Stock / Zone C / Bin C1: 100 Funky socks
  • +
+

3 Reservation Rules, in the following order

+
    +
  • Zone A must have full quantities
  • +
  • Zone B
  • +
  • Zone C
  • +
+

2 Delivery Orders:

+
    +
  • Origin: Outgoing shipment (reservation rules demo 1)
  • +
  • Origin: Outgoing shipment (reservation rules demo 2)
  • +
+

Scenario:

+
    +
  • Activate Storage Locations and Multi-Warehouses
  • +
  • You can open Inventory > Configuration > Stock Reservation Rules to see the +rules
  • +
  • Open Transfer: Outgoing shipment (reservation rules demo 1)
  • +
  • Check availability: it has 150 units, as it will not empty Zone A, it will not +take products there, it should take 100 in B and 50 in C (following the rules +order)
  • +
  • Unreserve this transfer (to test the second case)
  • +
  • Open Transfer: Outgoing shipment (reservation rules demo 2)
  • +
  • Check availability: it has 250 units, it can empty Zone A, it will take 200 in +Bin A1 and 50 in Bin B1.
  • +
  • If you want to explore further, you can add a custom domain to exclude rules +(for instance, a product category will not use Zone B).
  • +
+
+
+

Bug Tracker

+

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

+ Add a Stock Reservation Rule +

+
+
+ + + +