From c683bb3251d8356b8d9355bee5951af418c4dfb9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 15 Aug 2019 11:02:50 +0200 Subject: [PATCH 01/45] Add stock_reserve_rule --- stock_reserve_rule/README.rst | 182 ++++++ stock_reserve_rule/__init__.py | 1 + stock_reserve_rule/__manifest__.py | 29 + stock_reserve_rule/demo/product_demo.xml | 18 + .../demo/stock_inventory_demo.xml | 34 ++ .../demo/stock_location_demo.xml | 29 + .../demo/stock_picking_demo.xml | 40 ++ .../demo/stock_reserve_rule_demo.xml | 32 + stock_reserve_rule/models/__init__.py | 3 + stock_reserve_rule/models/stock_move.py | 139 +++++ stock_reserve_rule/models/stock_quant.py | 30 + .../models/stock_reserve_rule.py | 287 +++++++++ stock_reserve_rule/readme/CONFIGURE.rst | 18 + stock_reserve_rule/readme/CONTRIBUTORS.rst | 1 + stock_reserve_rule/readme/DESCRIPTION.rst | 40 ++ stock_reserve_rule/readme/USAGE.rst | 39 ++ .../security/ir.model.access.csv | 5 + .../security/stock_reserve_rule_security.xml | 10 + .../static/description/index.html | 519 ++++++++++++++++ stock_reserve_rule/tests/__init__.py | 1 + stock_reserve_rule/tests/test_reserve_rule.py | 561 ++++++++++++++++++ .../views/stock_reserve_rule_views.xml | 92 +++ 22 files changed, 2110 insertions(+) create mode 100644 stock_reserve_rule/README.rst create mode 100644 stock_reserve_rule/__init__.py create mode 100644 stock_reserve_rule/__manifest__.py create mode 100644 stock_reserve_rule/demo/product_demo.xml create mode 100644 stock_reserve_rule/demo/stock_inventory_demo.xml create mode 100644 stock_reserve_rule/demo/stock_location_demo.xml create mode 100644 stock_reserve_rule/demo/stock_picking_demo.xml create mode 100644 stock_reserve_rule/demo/stock_reserve_rule_demo.xml create mode 100644 stock_reserve_rule/models/__init__.py create mode 100644 stock_reserve_rule/models/stock_move.py create mode 100644 stock_reserve_rule/models/stock_quant.py create mode 100644 stock_reserve_rule/models/stock_reserve_rule.py create mode 100644 stock_reserve_rule/readme/CONFIGURE.rst create mode 100644 stock_reserve_rule/readme/CONTRIBUTORS.rst create mode 100644 stock_reserve_rule/readme/DESCRIPTION.rst create mode 100644 stock_reserve_rule/readme/USAGE.rst create mode 100644 stock_reserve_rule/security/ir.model.access.csv create mode 100644 stock_reserve_rule/security/stock_reserve_rule_security.xml create mode 100644 stock_reserve_rule/static/description/index.html create mode 100644 stock_reserve_rule/tests/__init__.py create mode 100644 stock_reserve_rule/tests/test_reserve_rule.py create mode 100644 stock_reserve_rule/views/stock_reserve_rule_views.xml 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 +

+
+
+ + + +
From 086db87a38fbbc083330ec6ebba58f5aee5872f0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 16 Dec 2019 08:30:12 +0100 Subject: [PATCH 02/45] Migrate stock_reserve_rule to 13.0 --- stock_reserve_rule/__manifest__.py | 43 +++++++++---------- stock_reserve_rule/demo/product_demo.xml | 1 - .../demo/stock_inventory_demo.xml | 5 ++- stock_reserve_rule/models/stock_move.py | 3 +- stock_reserve_rule/models/stock_quant.py | 2 +- .../models/stock_reserve_rule.py | 23 +++------- stock_reserve_rule/tests/test_reserve_rule.py | 41 ++++++++---------- .../views/stock_reserve_rule_views.xml | 20 +++++---- 8 files changed, 63 insertions(+), 75 deletions(-) diff --git a/stock_reserve_rule/__manifest__.py b/stock_reserve_rule/__manifest__.py index 679ece206..80d6f7033 100644 --- a/stock_reserve_rule/__manifest__.py +++ b/stock_reserve_rule/__manifest__.py @@ -1,29 +1,26 @@ # 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 + "name": "Stock Reservation Rules", + "summary": "Configure reservation rules by location", + "version": "13.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", ], - '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", ], - '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', + "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 index b4618d189..0d0190fb1 100644 --- a/stock_reserve_rule/demo/product_demo.xml +++ b/stock_reserve_rule/demo/product_demo.xml @@ -12,7 +12,6 @@ none - diff --git a/stock_reserve_rule/demo/stock_inventory_demo.xml b/stock_reserve_rule/demo/stock_inventory_demo.xml index 1ab318d57..41ebda18f 100644 --- a/stock_reserve_rule/demo/stock_inventory_demo.xml +++ b/stock_reserve_rule/demo/stock_inventory_demo.xml @@ -27,8 +27,11 @@ - + + + + diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index e7aca4c2e..3fb0b3c1b 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -84,8 +84,7 @@ class StockMove(models.Model): break need_zero = ( - float_compare(still_need, 0, precision_rounding=rounding) - != 1 + float_compare(still_need, 0, precision_rounding=rounding) != 1 ) if need_zero: # useless to eval the other rules when still_need <= 0 diff --git a/stock_reserve_rule/models/stock_quant.py b/stock_reserve_rule/models/stock_quant.py index bb78bd532..1236b6883 100644 --- a/stock_reserve_rule/models/stock_quant.py +++ b/stock_reserve_rule/models/stock_quant.py @@ -27,4 +27,4 @@ class StockQuant(models.Model): seen[location] = seen[location] | quant else: seen[location] = quant - return [(location, quants) for location, quants in seen.items()] + 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 index 41a6a393c..2081962f4 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -1,9 +1,9 @@ # 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 +from odoo.tools.safe_eval import safe_eval def _default_sequence(record): @@ -33,8 +33,7 @@ class StockReserveRule(models.Model): 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, + comodel_name="res.company", default=lambda self: self.env.user.company_id.id ) location_id = fields.Many2one(comodel_name="stock.location", required=True) @@ -143,9 +142,7 @@ class StockReserveRuleRemoval(models.Model): def _eval_quant_domain(self, quants, domain): quant_domain = [("id", "in", quants.ids)] - return self.env["stock.quant"].search( - expression.AND([quant_domain, domain]) - ) + return self.env["stock.quant"].search(expression.AND([quant_domain, domain])) def _filter_quants(self, move, quants): domain = safe_eval(self.quant_domain) or [] @@ -206,7 +203,6 @@ class StockReserveRuleRemoval(models.Model): ( sum(quants.mapped("quantity")) - sum(quants.mapped("reserved_quantity")), - quants, location, ) for location, quants in quants_per_bin @@ -217,7 +213,7 @@ class StockReserveRuleRemoval(models.Model): # 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: + for location_quantity, location in bins: if location_quantity <= 0: continue @@ -253,9 +249,7 @@ class StockReserveRuleRemoval(models.Model): rounding = product.uom_id.rounding def is_greater_eq(value, other): - return ( - float_compare(value, other, precision_rounding=rounding) >= 0 - ) + 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. @@ -266,7 +260,6 @@ class StockReserveRuleRemoval(models.Model): ( sum(quants.mapped("quantity")) - sum(quants.mapped("reserved_quantity")), - quants, location, ) for location, quants in quants_per_bin @@ -274,12 +267,10 @@ class StockReserveRuleRemoval(models.Model): reverse=True, ) - for location_quantity, quants, location in bins: + for location_quantity, location in bins: if location_quantity <= 0: continue - enough_for_packaging = is_greater_eq( - location_quantity, pack_quantity - ) + 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 diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 732a436c9..124098890 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -101,9 +101,7 @@ class TestReserveRule(common.SavepointCase): return picking def _update_qty_in_location(self, location, product, quantity): - self.env["stock.quant"]._update_available_quantity( - product, location, quantity - ) + self.env["stock.quant"]._update_available_quantity(product, location, quantity) def _create_rule(self, rule_values, removal_values): rule_config = { @@ -113,6 +111,8 @@ class TestReserveRule(common.SavepointCase): } 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 @@ -219,7 +219,7 @@ class TestReserveRule(common.SavepointCase): ], ) self.assertEqual(move.state, "partially_available") - self.assertEqual(move.reserved_availability, 300.) + self.assertEqual(move.reserved_availability, 300.0) def test_rule_fallback(self): reserve = self.env["stock.location"].create( @@ -254,7 +254,7 @@ class TestReserveRule(common.SavepointCase): ], ) self.assertEqual(move.state, "assigned") - self.assertEqual(move.reserved_availability, 400.) + self.assertEqual(move.reserved_availability, 400.0) def test_rule_domain(self): self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) @@ -355,9 +355,9 @@ class TestReserveRule(common.SavepointCase): 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.}, + {"location_id": self.loc_zone1_bin2.id, "product_qty": 150.0}, + {"location_id": self.loc_zone2_bin1.id, "product_qty": 50.0}, + {"location_id": self.loc_zone3_bin1.id, "product_qty": 50.0}, ], ) self.assertEqual(move.state, "assigned") @@ -389,8 +389,8 @@ class TestReserveRule(common.SavepointCase): self.assertRecordValues( ml, [ - {"location_id": self.loc_zone1_bin1.id, "product_qty": 50.}, - {"location_id": self.loc_zone2_bin1.id, "product_qty": 30.}, + {"location_id": self.loc_zone1_bin1.id, "product_qty": 50.0}, + {"location_id": self.loc_zone2_bin1.id, "product_qty": 30.0}, ], ) self.assertEqual(move.state, "assigned") @@ -424,8 +424,8 @@ class TestReserveRule(common.SavepointCase): self.assertRecordValues( ml, [ - {"location_id": self.loc_zone1_bin2.id, "product_qty": 60.}, - {"location_id": self.loc_zone2_bin1.id, "product_qty": 20.}, + {"location_id": self.loc_zone1_bin2.id, "product_qty": 60.0}, + {"location_id": self.loc_zone2_bin1.id, "product_qty": 20.0}, ], ) self.assertEqual(move.state, "assigned") @@ -433,10 +433,7 @@ class TestReserveRule(common.SavepointCase): def test_rule_packaging(self): self._setup_packagings( self.product1, - [ - ("Pallet", 500, self.pallet), - ("Retail Box", 50, self.retail_box), - ], + [("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) @@ -470,9 +467,9 @@ class TestReserveRule(common.SavepointCase): 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.}, + {"location_id": self.loc_zone1_bin2.id, "product_qty": 500.0}, + {"location_id": self.loc_zone2_bin1.id, "product_qty": 50.0}, + {"location_id": self.loc_zone3_bin1.id, "product_qty": 40.0}, ], ) self.assertEqual(move.state, "assigned") @@ -553,9 +550,9 @@ class TestReserveRule(common.SavepointCase): 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.}, + {"location_id": self.loc_zone1_bin2.id, "product_qty": 500.0}, + {"location_id": self.loc_zone2_bin2.id, "product_qty": 50.0}, + {"location_id": self.loc_zone3_bin1.id, "product_qty": 10.0}, ], ) 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 index 6e8d36b26..68c81b262 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -7,17 +7,20 @@
-
+
@@ -74,7 +77,6 @@ Stock Reservation Rules stock.reserve.rule ir.actions.act_window - form From 4fd7ef9d2e2d157ae315b2a9bcd5256d2bb417e0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 17 Mar 2020 10:52:59 +0100 Subject: [PATCH 03/45] Run pre-commit with xml prettier --- stock_reserve_rule/demo/product_demo.xml | 10 +- .../demo/stock_inventory_demo.xml | 42 +++++---- .../demo/stock_location_demo.xml | 16 ++-- .../demo/stock_picking_demo.xml | 41 ++++---- .../demo/stock_reserve_rule_demo.xml | 23 ++--- .../security/stock_reserve_rule_security.xml | 10 +- .../views/stock_reserve_rule_views.xml | 94 +++++++++++-------- 7 files changed, 128 insertions(+), 108 deletions(-) diff --git a/stock_reserve_rule/demo/product_demo.xml b/stock_reserve_rule/demo/product_demo.xml index 0d0190fb1..2b897a3f8 100644 --- a/stock_reserve_rule/demo/product_demo.xml +++ b/stock_reserve_rule/demo/product_demo.xml @@ -1,17 +1,15 @@ - + - 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 index 41ebda18f..4641c76fe 100644 --- a/stock_reserve_rule/demo/stock_inventory_demo.xml +++ b/stock_reserve_rule/demo/stock_inventory_demo.xml @@ -1,37 +1,41 @@ - + - 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 index cec2c6a2a..b9327b429 100644 --- a/stock_reserve_rule/demo/stock_location_demo.xml +++ b/stock_reserve_rule/demo/stock_location_demo.xml @@ -1,29 +1,27 @@ - + - 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 index 321e40d75..c475aca26 100644 --- a/stock_reserve_rule/demo/stock_picking_demo.xml +++ b/stock_reserve_rule/demo/stock_picking_demo.xml @@ -1,14 +1,16 @@ - + - - + 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 index b79e35ec3..e73a1a425 100644 --- a/stock_reserve_rule/demo/stock_reserve_rule_demo.xml +++ b/stock_reserve_rule/demo/stock_reserve_rule_demo.xml @@ -1,32 +1,27 @@ - + - Stock 1 - - + + - - + 1 - + empty_bin - - + 2 - + default - - + 3 - + default - diff --git a/stock_reserve_rule/security/stock_reserve_rule_security.xml b/stock_reserve_rule/security/stock_reserve_rule_security.xml index 011a80096..fcdba014e 100644 --- a/stock_reserve_rule/security/stock_reserve_rule_security.xml +++ b/stock_reserve_rule/security/stock_reserve_rule_security.xml @@ -1,10 +1,10 @@ - + - Stock Reservation Rule - - ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] - diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index 68c81b262..9878a5159 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -1,6 +1,5 @@ - + - stock.reserve.rule.form stock.reserve.rule @@ -8,38 +7,57 @@
- -
- stock.reserve.rule.search stock.reserve.rule - - - - + + + + - stock.reserve.rule stock.reserve.rule - - + + - Stock Reservation Rules stock.reserve.rule ir.actions.act_window - - - + + +

Add a Stock Reservation Rule

- - - + From 9481c6061b44b8d997bf13d93a2d62352f6b60c1 Mon Sep 17 00:00:00 2001 From: sebalix Date: Thu, 23 Apr 2020 17:02:26 +0200 Subject: [PATCH 04/45] [IMP] stock_reserve_rule: add constraints on fallback locations --- stock_reserve_rule/models/stock_move.py | 22 ++++- .../models/stock_reserve_rule.py | 80 ++++++++++++++++++- stock_reserve_rule/tests/test_reserve_rule.py | 70 +++++++++++++++- .../views/stock_reserve_rule_views.xml | 11 ++- 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 3fb0b3c1b..17c83cb0e 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -103,7 +103,9 @@ class StockMove(models.Model): fallback_quantity = sum(quants.mapped("quantity")) - sum( quants.mapped("reserved_quantity") ) - return reserved + super()._update_reserved_quantity( + # If there is some qties to reserve in the fallback location, + # reserve them + reserved_fallback = super()._update_reserved_quantity( still_need, fallback_quantity, location_id=rule.fallback_location_id, @@ -112,6 +114,24 @@ class StockMove(models.Model): owner_id=owner_id, strict=strict, ) + # Then if there is still a need, we split the current move to + # get a new one targetting the fallback location with the + # remaining qties + still_need = self.product_uom_qty - self.reserved_availability + if still_need: + qty_split = self.product_uom._compute_quantity( + still_need, + self.product_id.uom_id, + rounding_method="HALF-UP", + ) + new_move_id = self._split(qty_split) + new_move = self.browse(new_move_id) + new_move.location_id = rule.fallback_location_id + # Shunt the caller '_action_assign' by telling that all + # the need has been reserved to get the current move + # updated to the state 'assigned' + return reserved + reserved_fallback + new_move.product_uom_qty + return reserved + reserved_fallback else: # Implicit fallback on the original location diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index 2081962f4..2dde06d14 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -1,9 +1,14 @@ # Copyright 2019 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import fields, models +import logging + +from odoo import _, api, fields, models from odoo.osv import expression from odoo.tools.float_utils import float_compare from odoo.tools.safe_eval import safe_eval +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) def _default_sequence(record): @@ -55,6 +60,37 @@ class StockReserveRule(models.Model): "rule is applicable or not.", ) + missing_reserve_rule = fields.Boolean( + string="Miss a reserve rule with higher priority?", + compute="_compute_missing_reserve_rule", + ) + + @api.depends() + def _compute_missing_reserve_rule(self): + for rule in self: + rule.missing_reserve_rule = any( + rule.rule_removal_ids.mapped("missing_reserve_rule")) + + @api.constrains("fallback_location_id") + def _constraint_fallback_location_id(self): + """The fallback location has to be a child of the main location.""" + location_model = self.env["stock.location"] + for rule in self: + if rule.fallback_location_id: + is_child = location_model.search_count( + [ + ("id", "=", rule.fallback_location_id.id), + ("id", "child_of", rule.location_id.id), + ], + ) + if not is_child: + msg = _( + "Fallback location has to be a child of the " + "location '{}'." + ).format(rule.location_id.display_name) + _logger.error("Rule '%s' - %s", rule.name, msg) + raise ValidationError(msg) + def _rules_for_location(self, location): return self.search([("location_id", "parent_of", location.id)]) @@ -140,6 +176,48 @@ class StockReserveRuleRemoval(models.Model): "When empty, any packaging can be removed.", ) + missing_reserve_rule = fields.Boolean( + string="Miss a reserve rule with higher priority?", + compute="_compute_missing_reserve_rule", + ) + + @api.depends() + def _compute_missing_reserve_rule(self): + for removal_rule in self: + removal_rule.missing_reserve_rule = False + # The current rule could satisfies the need already + if removal_rule.location_id == removal_rule.rule_id.location_id: + break + rules = self.env["stock.reserve.rule"].search( + [ + ("location_id", "=", removal_rule.location_id.id), + ("sequence", "<", removal_rule.rule_id.sequence), + ] + ) + removal_rule.missing_reserve_rule = not rules + + @api.constrains("location_id") + def _constraint_location_id(self): + """The location has to be a child of the rule location.""" + location_model = self.env["stock.location"] + for removal_rule in self: + is_child = location_model.search_count( + [ + ("id", "=", removal_rule.location_id.id), + ("id", "child_of", removal_rule.rule_id.location_id.id), + ], + ) + if not is_child: + msg = _( + "Removal rule '{}' location has to be a child " + "of the rule location '{}'." + ).format( + removal_rule.name, + removal_rule.rule_id.location_id.display_name, + ) + _logger.error("Rule '%s' - %s", removal_rule.rule_id.name, msg) + 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])) diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 124098890..549805e99 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -1,6 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) from odoo.tests import common +from odoo import exceptions class TestReserveRule(common.SavepointCase): @@ -130,6 +131,73 @@ class TestReserveRule(common.SavepointCase): ] ) + def test_rule_fallback_child_of_location(self): + # fallback is a child + self._create_rule( + { + "fallback_location_id": self.loc_zone1.id, + }, + [ + {"location_id": self.loc_zone1.id}, + ], + ) + # fallback is not a child + with self.assertRaises(exceptions.ValidationError): + self._create_rule( + { + "fallback_location_id": self.env.ref( + "stock.stock_location_locations").id, + }, + [{"location_id": self.loc_zone1.id}], + ) + + 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_fallback_partial_assign(self): + """Assign move partially available. + + The move should be splitted in two: + - one move assigned with reserved goods + - one move for remaining goods targetting the fallback location + """ + # Need 150 and 120 available => new move with 30 waiting qties + self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) + self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 20) + picking = self._create_picking(self.wh, [(self.product1, 150)]) + self._create_rule( + { + "fallback_location_id": self.loc_zone2_bin1.id, + }, + [ + {"location_id": self.loc_zone1_bin1.id, "sequence": 1}, + ], + ) + self.assertEqual(len(picking.move_lines), 1) + picking.action_assign() + self.assertEqual(len(picking.move_lines), 2) + move_assigned = picking.move_lines.filtered( + lambda m: m.state == "assigned") + move_unassigned = picking.move_lines.filtered( + lambda m: m.state == "confirmed") + self.assertEqual(move_assigned.state, "assigned") + self.assertEqual(move_unassigned.state, "confirmed") + def test_rule_take_all_in_2(self): all_locs = ( self.loc_zone1_bin1, @@ -223,7 +291,7 @@ class TestReserveRule(common.SavepointCase): def test_rule_fallback(self): reserve = self.env["stock.location"].create( - {"name": "Reserve", "location_id": self.wh.view_location_id.id} + {"name": "Reserve", "location_id": self.wh.lot_stock_id.id} ) self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index 9878a5159..f9015341d 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -22,6 +22,7 @@ + - + - + +
@@ -61,6 +63,10 @@
+ + + WARNING: some of the removal rules are missing a reservation rule with a lower sequence. If it is desirable you should create them to ensure that the reservation on the related locations will use these rules first instead of this one. +
@@ -89,6 +95,7 @@ + From 5f596a5775d875a857340b484ce99af8c5972a53 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 14 May 2020 14:27:20 +0200 Subject: [PATCH 05/45] Fix bug in fallback when no quantity could be reserved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the change, the implementation of the fallback goes like this: If I reserve a move of 3000 and it finds 600 units, it splits the move to create a new move of 2400 and pretend to the caller that 3000 was reserved so the initial move is changed to 'assigned'. Now, if we have a move of 2400 and finds zero, it still splits the move, and pretend to the caller that 2400 was reserved → the initial move has no move line but is assigned. In this case, we should not split the move but only update the source location of the move. --- stock_reserve_rule/models/stock_move.py | 43 +++++---- .../models/stock_reserve_rule.py | 17 ++-- stock_reserve_rule/readme/CONFIGURE.rst | 8 +- stock_reserve_rule/tests/test_reserve_rule.py | 91 ++++++++++++------- .../views/stock_reserve_rule_views.xml | 16 +++- 5 files changed, 112 insertions(+), 63 deletions(-) diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 17c83cb0e..129c1ba88 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -114,24 +114,33 @@ class StockMove(models.Model): owner_id=owner_id, strict=strict, ) - # Then if there is still a need, we split the current move to - # get a new one targetting the fallback location with the - # remaining qties - still_need = self.product_uom_qty - self.reserved_availability + reserved += reserved_fallback + still_need = self.product_uom_qty - reserved if still_need: - qty_split = self.product_uom._compute_quantity( - still_need, - self.product_id.uom_id, - rounding_method="HALF-UP", - ) - new_move_id = self._split(qty_split) - new_move = self.browse(new_move_id) - new_move.location_id = rule.fallback_location_id - # Shunt the caller '_action_assign' by telling that all - # the need has been reserved to get the current move - # updated to the state 'assigned' - return reserved + reserved_fallback + new_move.product_uom_qty - return reserved + reserved_fallback + if not reserved: + # nothing could be reserved, however, we want to source + # the move on the specific fallback location (for + # replenishment), so update it's origin and return 0 + # reserved to leave the move confirmed + self.location_id = rule.fallback_location_id + return 0 + else: + # Then if there is still a need, we split the current move to + # get a new one targetting the fallback location with the + # remaining qties for replenishment + qty_split = self.product_uom._compute_quantity( + still_need, + self.product_id.uom_id, + rounding_method="HALF-UP", + ) + new_move_id = self._split(qty_split) + new_move = self.browse(new_move_id) + new_move.location_id = rule.fallback_location_id + # Shunt the caller '_action_assign' by telling that all + # the need has been reserved to get the current move + # updated to the state 'assigned' + return reserved + new_move.product_uom_qty + return reserved else: # Implicit fallback on the original location diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index 2dde06d14..48c519a2b 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -3,10 +3,10 @@ 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 -from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -45,8 +45,10 @@ class StockReserveRule(models.Model): 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.", + "location. Use it for replenishment. The source location move will be " + "changed to this location if the move is not available. If the move is " + "partially available, it is split and the unavailable quantity is sourced " + "in this location for replenishment.", ) rule_removal_ids = fields.One2many( @@ -69,7 +71,8 @@ class StockReserveRule(models.Model): def _compute_missing_reserve_rule(self): for rule in self: rule.missing_reserve_rule = any( - rule.rule_removal_ids.mapped("missing_reserve_rule")) + rule.rule_removal_ids.mapped("missing_reserve_rule") + ) @api.constrains("fallback_location_id") def _constraint_fallback_location_id(self): @@ -85,8 +88,7 @@ class StockReserveRule(models.Model): ) if not is_child: msg = _( - "Fallback location has to be a child of the " - "location '{}'." + "Fallback location has to be a child of the location '{}'." ).format(rule.location_id.display_name) _logger.error("Rule '%s' - %s", rule.name, msg) raise ValidationError(msg) @@ -212,8 +214,7 @@ class StockReserveRuleRemoval(models.Model): "Removal rule '{}' location has to be a child " "of the rule location '{}'." ).format( - removal_rule.name, - removal_rule.rule_id.location_id.display_name, + removal_rule.name, removal_rule.rule_id.location_id.display_name, ) _logger.error("Rule '%s' - %s", removal_rule.rule_id.name, msg) raise ValidationError(msg) diff --git a/stock_reserve_rule/readme/CONFIGURE.rst b/stock_reserve_rule/readme/CONFIGURE.rst index 2b2c96abe..03965177b 100644 --- a/stock_reserve_rule/readme/CONFIGURE.rst +++ b/stock_reserve_rule/readme/CONFIGURE.rst @@ -5,8 +5,12 @@ 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. +* Fallback Location: Define where the goods are reserved if none of the removal + rule could reserve the goods. Use it for replenishment. The source location + move will be changed to this location if the move is not available. If the + move is partially available, it is split and the unavailable quantity is + sourced in this location for replenishment. 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: diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 549805e99..48ef9cec5 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -1,7 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) -from odoo.tests import common from odoo import exceptions +from odoo.tests import common class TestReserveRule(common.SavepointCase): @@ -134,39 +134,27 @@ class TestReserveRule(common.SavepointCase): def test_rule_fallback_child_of_location(self): # fallback is a child self._create_rule( - { - "fallback_location_id": self.loc_zone1.id, - }, - [ - {"location_id": self.loc_zone1.id}, - ], + {"fallback_location_id": self.loc_zone1.id}, + [{"location_id": self.loc_zone1.id}], ) # fallback is not a child with self.assertRaises(exceptions.ValidationError): self._create_rule( { "fallback_location_id": self.env.ref( - "stock.stock_location_locations").id, + "stock.stock_location_locations" + ).id }, [{"location_id": self.loc_zone1.id}], ) 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}], - ) + 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, - }, - ], + {}, [{"location_id": self.env.ref("stock.stock_location_locations").id}] ) def test_rule_fallback_partial_assign(self): @@ -180,23 +168,64 @@ class TestReserveRule(common.SavepointCase): self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 20) picking = self._create_picking(self.wh, [(self.product1, 150)]) + fallback = self.loc_zone2_bin1 self._create_rule( - { - "fallback_location_id": self.loc_zone2_bin1.id, - }, - [ - {"location_id": self.loc_zone1_bin1.id, "sequence": 1}, - ], + {"fallback_location_id": fallback.id}, + [{"location_id": self.loc_zone1_bin1.id, "sequence": 1}], ) self.assertEqual(len(picking.move_lines), 1) picking.action_assign() self.assertEqual(len(picking.move_lines), 2) - move_assigned = picking.move_lines.filtered( - lambda m: m.state == "assigned") - move_unassigned = picking.move_lines.filtered( - lambda m: m.state == "confirmed") - self.assertEqual(move_assigned.state, "assigned") - self.assertEqual(move_unassigned.state, "confirmed") + move_assigned = picking.move_lines.filtered(lambda m: m.state == "assigned") + move_unassigned = picking.move_lines.filtered(lambda m: m.state == "confirmed") + self.assertRecordValues( + move_assigned, + [ + { + "state": "assigned", + "location_id": picking.location_id.id, + "product_uom_qty": 120, + } + ], + ) + self.assertRecordValues( + move_unassigned, + [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 30}], + ) + + def test_rule_fallback_unavailable(self): + """Assign move unavailable. + + The move source location should be changed to be the fallback location. + """ + self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) + picking = self._create_picking(self.wh, [(self.product1, 150)]) + fallback = self.loc_zone2_bin1 + self._create_rule( + {"fallback_location_id": fallback.id}, + [ + { + "location_id": self.loc_zone1_bin1.id, + "sequence": 1, + # FIXME check if this isn't an issue? + # for the test: StockQuant._get_available_quantity should + # return something otherwise we won't enter in + # StockMove._update_reserved_quantity + # and the fallback will not be applied, so to reproduce a + # case where the quants are not allowed to be taken, + # use a domain that always resolves to false + "quant_domain": [("id", "=", 0)], + } + ], + ) + self.assertEqual(len(picking.move_lines), 1) + picking.action_assign() + self.assertEqual(len(picking.move_lines), 1) + move = picking.move_lines + self.assertRecordValues( + move, + [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 150}], + ) def test_rule_take_all_in_2(self): all_locs = ( diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index f9015341d..966dbfaed 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -35,12 +35,15 @@ - + - +
@@ -63,8 +66,11 @@
- - + + WARNING: some of the removal rules are missing a reservation rule with a lower sequence. If it is desirable you should create them to ensure that the reservation on the related locations will use these rules first instead of this one.
@@ -95,7 +101,7 @@ - + From f5b3d4dec1aabed0fb7bdc1c026a2dd13be3a70e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 14 May 2020 16:09:35 +0200 Subject: [PATCH 06/45] Fix application of removal rules too broad Example of configuration: Rule location: Stock Removal rule 1: Stock/Zone1 Removal rule 2: Stock/Zone2 Reservation of a stock move with Stock/Zone2 as source location. Previously, it would reserve in Stock/Zone1. Now, it will never be allowed to reserve in Stock/Zone1. A warning message was added previously to warn the user about potential issues, which is now obsolete so I removed it. --- stock_reserve_rule/models/__init__.py | 1 + stock_reserve_rule/models/stock_location.py | 14 ++++++++ stock_reserve_rule/models/stock_move.py | 12 +++++++ .../models/stock_reserve_rule.py | 32 ----------------- stock_reserve_rule/tests/test_reserve_rule.py | 35 ++++++++++++++++++- .../views/stock_reserve_rule_views.xml | 13 +------ 6 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 stock_reserve_rule/models/stock_location.py diff --git a/stock_reserve_rule/models/__init__.py b/stock_reserve_rule/models/__init__.py index 2f1eab9ba..b7b0c5f76 100644 --- a/stock_reserve_rule/models/__init__.py +++ b/stock_reserve_rule/models/__init__.py @@ -1,3 +1,4 @@ from . import stock_move +from . import stock_location from . import stock_quant from . import stock_reserve_rule diff --git a/stock_reserve_rule/models/stock_location.py b/stock_reserve_rule/models/stock_location.py new file mode 100644 index 000000000..60caf1b3f --- /dev/null +++ b/stock_reserve_rule/models/stock_location.py @@ -0,0 +1,14 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class StockLocation(models.Model): + _inherit = "stock.location" + + def is_sublocation_of(self, others): + """Return True if self is a sublocation of at least one other""" + self.ensure_one() + # Efficient way to verify that the current location is + # below one of the other location without using SQL. + return any(self.parent_path.startswith(other.parent_path) for other in others) diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 129c1ba88..e615b9e33 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -41,6 +41,18 @@ class StockMove(models.Model): 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 not removal_rule.location_id.is_sublocation_of(location_id): + continue + quants = self.env["stock.quant"]._gather( self.product_id, removal_rule.location_id, diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index 48c519a2b..add61e828 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -62,18 +62,6 @@ class StockReserveRule(models.Model): "rule is applicable or not.", ) - missing_reserve_rule = fields.Boolean( - string="Miss a reserve rule with higher priority?", - compute="_compute_missing_reserve_rule", - ) - - @api.depends() - def _compute_missing_reserve_rule(self): - for rule in self: - rule.missing_reserve_rule = any( - rule.rule_removal_ids.mapped("missing_reserve_rule") - ) - @api.constrains("fallback_location_id") def _constraint_fallback_location_id(self): """The fallback location has to be a child of the main location.""" @@ -178,26 +166,6 @@ class StockReserveRuleRemoval(models.Model): "When empty, any packaging can be removed.", ) - missing_reserve_rule = fields.Boolean( - string="Miss a reserve rule with higher priority?", - compute="_compute_missing_reserve_rule", - ) - - @api.depends() - def _compute_missing_reserve_rule(self): - for removal_rule in self: - removal_rule.missing_reserve_rule = False - # The current rule could satisfies the need already - if removal_rule.location_id == removal_rule.rule_id.location_id: - break - rules = self.env["stock.reserve.rule"].search( - [ - ("location_id", "=", removal_rule.location_id.id), - ("sequence", "<", removal_rule.rule_id.sequence), - ] - ) - removal_rule.missing_reserve_rule = not rules - @api.constrains("location_id") def _constraint_location_id(self): """The location has to be a child of the rule location.""" diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 48ef9cec5..cee56b1ca 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -224,7 +224,13 @@ class TestReserveRule(common.SavepointCase): move = picking.move_lines self.assertRecordValues( move, - [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 150}], + [ + { + "state": "confirmed", + "location_id": fallback.id, + "product_uom_qty": 150, + } + ], ) def test_rule_take_all_in_2(self): @@ -653,3 +659,30 @@ class TestReserveRule(common.SavepointCase): ], ) 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_lines + + 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, "product_qty": 80.0}] + ) + 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 index 966dbfaed..97ffd1624 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -35,15 +35,11 @@ - + -
@@ -66,13 +62,6 @@
- - - WARNING: some of the removal rules are missing a reservation rule with a lower sequence. If it is desirable you should create them to ensure that the reservation on the related locations will use these rules first instead of this one. -
From ec0ad0a7f6a11cfb8c9146c8f91b9001f112209f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 15 May 2020 12:16:32 +0200 Subject: [PATCH 07/45] Optimize SQL queries when searching a rule Searching all rules then filtering in python the parent path is more efficient than finding all the parent locations and finding the matching rules. --- stock_reserve_rule/models/stock_reserve_rule.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index add61e828..e794e999a 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -82,7 +82,14 @@ class StockReserveRule(models.Model): raise ValidationError(msg) def _rules_for_location(self, location): - return self.search([("location_id", "parent_of", location.id)]) + # We'll typically have a handful of rules, so reading all of them then + # checking if they are a parent location of the location is pretty + # fast. Searching all the parent locations then the rules matching them + # can be much slower if we have many locations. + rules = self.search([]).filtered( + lambda rule: rule.location_id.parent_path.startswith(location.parent_path) + ) + return rules def _eval_rule_domain(self, move, domain): move_domain = [("id", "=", move.id)] From 5060ba8d110517a4757379447bc58655fa84062e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 May 2020 15:21:22 +0200 Subject: [PATCH 08/45] Remove fallback location It could not work properly here as we need the "fallback" to apply even if there is no quantity at all in the stock. As we hook the reservation rules in StockMove._update_reserved_quantity(), and this method is called only if we have at least 1 product in qty, the fallback was not applied with zero qty. A new module will handle this concept: https://github.com/OCA/wms/pull/28 --- stock_reserve_rule/README.rst | 4 +- stock_reserve_rule/models/stock_move.py | 72 ++-------- .../models/stock_reserve_rule.py | 27 ---- stock_reserve_rule/readme/CONFIGURE.rst | 6 - .../static/description/index.html | 2 - stock_reserve_rule/tests/test_reserve_rule.py | 128 ------------------ .../views/stock_reserve_rule_views.xml | 1 - 7 files changed, 11 insertions(+), 229 deletions(-) diff --git a/stock_reserve_rule/README.rst b/stock_reserve_rule/README.rst index c72fbe438..5855725f3 100644 --- a/stock_reserve_rule/README.rst +++ b/stock_reserve_rule/README.rst @@ -23,7 +23,7 @@ Stock Reservation Rules :target: https://runbot.odoo-community.org/runbot/153/12.0 :alt: Try me on Runbot -|badge1| |badge2| |badge3| |badge4| |badge5| +|badge1| |badge2| |badge3| |badge4| |badge5| This module adds rules for advanced reservation / removal strategies. @@ -86,8 +86,6 @@ 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: diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index e615b9e33..68d29e1b9 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -103,68 +103,16 @@ class StockMove(models.Model): 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") - ) - # If there is some qties to reserve in the fallback location, - # reserve them - reserved_fallback = 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, - ) - reserved += reserved_fallback - still_need = self.product_uom_qty - reserved - if still_need: - if not reserved: - # nothing could be reserved, however, we want to source - # the move on the specific fallback location (for - # replenishment), so update it's origin and return 0 - # reserved to leave the move confirmed - self.location_id = rule.fallback_location_id - return 0 - else: - # Then if there is still a need, we split the current move to - # get a new one targetting the fallback location with the - # remaining qties for replenishment - qty_split = self.product_uom._compute_quantity( - still_need, - self.product_id.uom_id, - rounding_method="HALF-UP", - ) - new_move_id = self._split(qty_split) - new_move = self.browse(new_move_id) - new_move.location_id = rule.fallback_location_id - # Shunt the caller '_action_assign' by telling that all - # the need has been reserved to get the current move - # updated to the state 'assigned' - return reserved + new_move.product_uom_qty - return reserved - - 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, - ) + # 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' diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index e794e999a..d4e4b962f 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -42,14 +42,6 @@ class StockReserveRule(models.Model): ) 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. Use it for replenishment. The source location move will be " - "changed to this location if the move is not available. If the move is " - "partially available, it is split and the unavailable quantity is sourced " - "in this location for replenishment.", - ) rule_removal_ids = fields.One2many( comodel_name="stock.reserve.rule.removal", inverse_name="rule_id" @@ -62,25 +54,6 @@ class StockReserveRule(models.Model): "rule is applicable or not.", ) - @api.constrains("fallback_location_id") - def _constraint_fallback_location_id(self): - """The fallback location has to be a child of the main location.""" - location_model = self.env["stock.location"] - for rule in self: - if rule.fallback_location_id: - is_child = location_model.search_count( - [ - ("id", "=", rule.fallback_location_id.id), - ("id", "child_of", rule.location_id.id), - ], - ) - if not is_child: - msg = _( - "Fallback location has to be a child of the location '{}'." - ).format(rule.location_id.display_name) - _logger.error("Rule '%s' - %s", rule.name, msg) - raise ValidationError(msg) - def _rules_for_location(self, location): # We'll typically have a handful of rules, so reading all of them then # checking if they are a parent location of the location is pretty diff --git a/stock_reserve_rule/readme/CONFIGURE.rst b/stock_reserve_rule/readme/CONFIGURE.rst index 03965177b..3052a5e99 100644 --- a/stock_reserve_rule/readme/CONFIGURE.rst +++ b/stock_reserve_rule/readme/CONFIGURE.rst @@ -5,12 +5,6 @@ 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. Use it for replenishment. The source location - move will be changed to this location if the move is not available. If the - move is partially available, it is split and the unavailable quantity is - sourced in this location for replenishment. 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: diff --git a/stock_reserve_rule/static/description/index.html b/stock_reserve_rule/static/description/index.html index b410ab119..9514a6efe 100644 --- a/stock_reserve_rule/static/description/index.html +++ b/stock_reserve_rule/static/description/index.html @@ -429,8 +429,6 @@ Only for development or testing purpose, do not use in production.

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:

diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index cee56b1ca..19611e9e5 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -131,23 +131,6 @@ class TestReserveRule(common.SavepointCase): ] ) - def test_rule_fallback_child_of_location(self): - # fallback is a child - self._create_rule( - {"fallback_location_id": self.loc_zone1.id}, - [{"location_id": self.loc_zone1.id}], - ) - # fallback is not a child - with self.assertRaises(exceptions.ValidationError): - self._create_rule( - { - "fallback_location_id": self.env.ref( - "stock.stock_location_locations" - ).id - }, - [{"location_id": self.loc_zone1.id}], - ) - 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}]) @@ -157,82 +140,6 @@ class TestReserveRule(common.SavepointCase): {}, [{"location_id": self.env.ref("stock.stock_location_locations").id}] ) - def test_rule_fallback_partial_assign(self): - """Assign move partially available. - - The move should be splitted in two: - - one move assigned with reserved goods - - one move for remaining goods targetting the fallback location - """ - # Need 150 and 120 available => new move with 30 waiting qties - self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) - self._update_qty_in_location(self.loc_zone2_bin1, self.product1, 20) - picking = self._create_picking(self.wh, [(self.product1, 150)]) - fallback = self.loc_zone2_bin1 - self._create_rule( - {"fallback_location_id": fallback.id}, - [{"location_id": self.loc_zone1_bin1.id, "sequence": 1}], - ) - self.assertEqual(len(picking.move_lines), 1) - picking.action_assign() - self.assertEqual(len(picking.move_lines), 2) - move_assigned = picking.move_lines.filtered(lambda m: m.state == "assigned") - move_unassigned = picking.move_lines.filtered(lambda m: m.state == "confirmed") - self.assertRecordValues( - move_assigned, - [ - { - "state": "assigned", - "location_id": picking.location_id.id, - "product_uom_qty": 120, - } - ], - ) - self.assertRecordValues( - move_unassigned, - [{"state": "confirmed", "location_id": fallback.id, "product_uom_qty": 30}], - ) - - def test_rule_fallback_unavailable(self): - """Assign move unavailable. - - The move source location should be changed to be the fallback location. - """ - self._update_qty_in_location(self.loc_zone1_bin1, self.product1, 100) - picking = self._create_picking(self.wh, [(self.product1, 150)]) - fallback = self.loc_zone2_bin1 - self._create_rule( - {"fallback_location_id": fallback.id}, - [ - { - "location_id": self.loc_zone1_bin1.id, - "sequence": 1, - # FIXME check if this isn't an issue? - # for the test: StockQuant._get_available_quantity should - # return something otherwise we won't enter in - # StockMove._update_reserved_quantity - # and the fallback will not be applied, so to reproduce a - # case where the quants are not allowed to be taken, - # use a domain that always resolves to false - "quant_domain": [("id", "=", 0)], - } - ], - ) - self.assertEqual(len(picking.move_lines), 1) - picking.action_assign() - self.assertEqual(len(picking.move_lines), 1) - move = picking.move_lines - self.assertRecordValues( - move, - [ - { - "state": "confirmed", - "location_id": fallback.id, - "product_uom_qty": 150, - } - ], - ) - def test_rule_take_all_in_2(self): all_locs = ( self.loc_zone1_bin1, @@ -324,41 +231,6 @@ class TestReserveRule(common.SavepointCase): self.assertEqual(move.state, "partially_available") self.assertEqual(move.reserved_availability, 300.0) - def test_rule_fallback(self): - reserve = self.env["stock.location"].create( - {"name": "Reserve", "location_id": self.wh.lot_stock_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.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) diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index 97ffd1624..fb0c80148 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -21,7 +21,6 @@ - From 5f132e5856a8bcd4f1fb6acc57583720f95a75be Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 May 2020 15:26:32 +0200 Subject: [PATCH 09/45] Use optimized method to check if location is child --- stock_reserve_rule/models/stock_reserve_rule.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index d4e4b962f..c4f473f77 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -149,15 +149,10 @@ class StockReserveRuleRemoval(models.Model): @api.constrains("location_id") def _constraint_location_id(self): """The location has to be a child of the rule location.""" - location_model = self.env["stock.location"] for removal_rule in self: - is_child = location_model.search_count( - [ - ("id", "=", removal_rule.location_id.id), - ("id", "child_of", removal_rule.rule_id.location_id.id), - ], - ) - if not is_child: + if not removal_rule.location_id.is_sublocation_of( + removal_rule.rule_id.location_id + ): msg = _( "Removal rule '{}' location has to be a child " "of the rule location '{}'." From 12957cdd5214b5a0863dca3e870af9b606a13241 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 May 2020 08:26:57 +0200 Subject: [PATCH 10/45] Improve usability --- .../models/stock_reserve_rule.py | 7 +- .../views/stock_reserve_rule_views.xml | 123 +++++++++--------- 2 files changed, 70 insertions(+), 60 deletions(-) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index c4f473f77..af7ef4cab 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -41,7 +41,12 @@ class StockReserveRule(models.Model): comodel_name="res.company", default=lambda self: self.env.user.company_id.id ) - location_id = fields.Many2one(comodel_name="stock.location", required=True) + location_id = fields.Many2one( + comodel_name="stock.location", + required=True, + help="Rule applied only in this location and sub-locations.", + ) + ) rule_removal_ids = fields.One2many( comodel_name="stock.reserve.rule.removal", inverse_name="rule_id" diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index fb0c80148..f2ac7d28d 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -4,64 +4,69 @@ stock.reserve.rule.form stock.reserve.rule -
-
-
- -
-
+ +
+ + + + + + + +
+
+ +
@@ -69,7 +74,7 @@ stock.reserve.rule.search stock.reserve.rule - + @@ -85,7 +90,7 @@ stock.reserve.rule stock.reserve.rule - + @@ -94,7 +99,7 @@ - Stock Reservation Rules + Reservation Rules stock.reserve.rule ir.actions.act_window @@ -102,7 +107,7 @@

- Add a Stock Reservation Rule + Add a Reservation Rule

From aebb0818b0c370806019d1b30bab410fe6b9941a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 May 2020 08:30:02 +0200 Subject: [PATCH 11/45] Add explicit filter on picking type --- .../models/stock_reserve_rule.py | 8 +++++ stock_reserve_rule/tests/test_reserve_rule.py | 31 +++++++++++++++++++ .../views/stock_reserve_rule_views.xml | 1 + 3 files changed, 40 insertions(+) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index af7ef4cab..66c947e17 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -46,6 +46,10 @@ class StockReserveRule(models.Model): required=True, help="Rule applied only in this location and sub-locations.", ) + picking_type_id = fields.Many2one( + comodel_name="stock.picking.type", + string="Operation Type", + help="Apply this rule only if the operation type of the move is the same.", ) rule_removal_ids = fields.One2many( @@ -85,6 +89,10 @@ class StockReserveRule(models.Model): ) def _is_rule_applicable(self, move): + if self.picking_type_id: + picking_type = move.picking_type_id or move.picking_id.picking_type_id + if picking_type != self.picking_type_id: + return False domain = safe_eval(self.rule_domain) or [] if domain: return self._eval_rule_domain(move, domain) diff --git a/stock_reserve_rule/tests/test_reserve_rule.py b/stock_reserve_rule/tests/test_reserve_rule.py index 19611e9e5..c065f1cb8 100644 --- a/stock_reserve_rule/tests/test_reserve_rule.py +++ b/stock_reserve_rule/tests/test_reserve_rule.py @@ -264,6 +264,37 @@ class TestReserveRule(common.SavepointCase): ) 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_id": self.wh.int_type_id.id, "sequence": 1}, + [{"location_id": self.loc_zone1.id, "sequence": 1}], + ) + self._create_rule( + # same picking type as the move + {"picking_type_id": self.wh.pick_type_id.id, "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) diff --git a/stock_reserve_rule/views/stock_reserve_rule_views.xml b/stock_reserve_rule/views/stock_reserve_rule_views.xml index f2ac7d28d..189771820 100644 --- a/stock_reserve_rule/views/stock_reserve_rule_views.xml +++ b/stock_reserve_rule/views/stock_reserve_rule_views.xml @@ -22,6 +22,7 @@ + From c9878a9c173179ec4ee02ef1903ba1459cd88614 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 26 May 2020 08:52:11 +0200 Subject: [PATCH 12/45] Remove logger that makes the tests failing As the logger outputs an error log during tests, travis counts it as a failure of a test. --- stock_reserve_rule/models/stock_reserve_rule.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index 66c947e17..0959bca16 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -172,7 +172,6 @@ class StockReserveRuleRemoval(models.Model): ).format( removal_rule.name, removal_rule.rule_id.location_id.display_name, ) - _logger.error("Rule '%s' - %s", removal_rule.rule_id.name, msg) raise ValidationError(msg) def _eval_quant_domain(self, quants, domain): From cfa3c8ee1a2bde1ff72bfbc26a9e4c1bfd66a3ea Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 27 May 2020 07:42:37 +0200 Subject: [PATCH 13/45] Revert "Optimize SQL queries when searching a rule" This reverts commit 768f186fd23e876c6daa7ccbd1423fbaf8b8cee0. Which is not more optimized, the optimization based on parent_path doesn't make sense here as the ORM will read parent_path in the location and get the parent ids by splitting the ids, it doesn't need more than one query on stock_location which is done based on its id and can reuse the cache, there is no lookup on parent path for parent_of. >>> env["stock.reserve.rule"].search([("location_id", "parent_of", 3125)]) 2020-05-27 05:36:59,938 1 DEBUG log_p odoo.sql_db: query: SELECT "stock_location"."id" as "id","stock_location"."name" as "name","stock_location"."complete_name" as "complete_name","stock_location"."active" as "active","stock_location"."usage" as "usage","stock_location"."location_id" as "location_id","stock_location"."comment" as "comment","stock_location"."parent_path" as "parent_path", ,"stock_location"."create_uid" as "create_uid","stock_location"."create_date" as "create_date","stock_location"."write_uid" as "write_uid","stock_location"."write_date" as "write_date" FROM "stock_location" WHERE "stock_location".id IN (3125) 2020-05-27 05:36:59,942 1 DEBUG log_p odoo.sql_db: query: SELECT "stock_reserve_rule".id FROM "stock_reserve_rule" WHERE (("stock_reserve_rule"."active" = true) AND ("stock_reserve_rule"."location_id" in (1,7,8,133,134,135,144,207,3125))) ORDER BY "stock_reserve_rule"."sequence" ,"stock_reserve_rule"."id" --- stock_reserve_rule/models/stock_reserve_rule.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/stock_reserve_rule/models/stock_reserve_rule.py b/stock_reserve_rule/models/stock_reserve_rule.py index 0959bca16..0015eb5ee 100644 --- a/stock_reserve_rule/models/stock_reserve_rule.py +++ b/stock_reserve_rule/models/stock_reserve_rule.py @@ -64,14 +64,7 @@ class StockReserveRule(models.Model): ) def _rules_for_location(self, location): - # We'll typically have a handful of rules, so reading all of them then - # checking if they are a parent location of the location is pretty - # fast. Searching all the parent locations then the rules matching them - # can be much slower if we have many locations. - rules = self.search([]).filtered( - lambda rule: rule.location_id.parent_path.startswith(location.parent_path) - ) - return rules + return self.search([("location_id", "parent_of", location.id)]) def _eval_rule_domain(self, move, domain): move_domain = [("id", "=", move.id)] From a551d5ced2fe41fc682be769dd1471aed328c9b1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 May 2020 07:59:43 +0200 Subject: [PATCH 14/45] Remove implicit fallback when rules are used When rules are configured and have been applied, we should not have an implicit fallback on the base location, as it would kind of cancel the benefits of the rules (as it would then take whatever it wants anywhere in all the locations). --- stock_reserve_rule/models/stock_move.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/stock_reserve_rule/models/stock_move.py b/stock_reserve_rule/models/stock_move.py index 68d29e1b9..4b3915aa8 100644 --- a/stock_reserve_rule/models/stock_move.py +++ b/stock_reserve_rule/models/stock_move.py @@ -103,16 +103,7 @@ class StockMove(models.Model): break reserved = need - still_need - # 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, - ) + return reserved # We fall here if there is no rule or they have all been # excluded by 'rule._is_rule_applicable' From 2e6d13242c9016eafaf6b41615d9de7025d3a41c Mon Sep 17 00:00:00 2001 From: oca-travis Date: Fri, 29 May 2020 13:28:01 +0000 Subject: [PATCH 15/45] [UPD] Update stock_reserve_rule.pot --- .../i18n/stock_reserve_rule.pot | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 stock_reserve_rule/i18n/stock_reserve_rule.pot 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..f9e8e8a06 --- /dev/null +++ b/stock_reserve_rule/i18n/stock_reserve_rule.pot @@ -0,0 +1,255 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_reserve_rule +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.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_id +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:product.product,name:stock_reserve_rule.product_funky_socks +#: model:product.template,name:stock_reserve_rule.product_funky_socks_product_template +msgid "Funky Socks" +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,name:stock_reserve_rule.model_stock_location +msgid "Inventory Locations" +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_id +msgid "Operation Type" +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_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 +#: model_terms:ir.ui.view,arch_db:stock_reserve_rule.view_stock_reserve_rule_tree +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_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 "" + +#. module: stock_reserve_rule +#: model:product.product,uom_name:stock_reserve_rule.product_funky_socks +#: model:product.template,uom_name:stock_reserve_rule.product_funky_socks_product_template +msgid "Units" +msgstr "" From 1d2c4fd750322990703bd625a769584c115b8c97 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Fri, 29 May 2020 13:52:41 +0000 Subject: [PATCH 16/45] [UPD] README.rst --- stock_reserve_rule/README.rst | 22 +++++++++---------- .../static/description/index.html | 18 +++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/stock_reserve_rule/README.rst b/stock_reserve_rule/README.rst index 5855725f3..c5ff7ef00 100644 --- a/stock_reserve_rule/README.rst +++ b/stock_reserve_rule/README.rst @@ -14,16 +14,16 @@ Stock Reservation Rules :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 + :target: https://github.com/OCA/stock-logistics-warehouse/tree/13.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 + :target: https://translation.odoo-community.org/projects/stock-logistics-warehouse-13-0/stock-logistics-warehouse-13-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 + :target: https://runbot.odoo-community.org/runbot/153/13.0 :alt: Try me on Runbot -|badge1| |badge2| |badge3| |badge4| |badge5| +|badge1| |badge2| |badge3| |badge4| |badge5| This module adds rules for advanced reservation / removal strategies. @@ -43,11 +43,11 @@ 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). + 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: @@ -145,7 +145,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -175,6 +175,6 @@ 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. +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/static/description/index.html b/stock_reserve_rule/static/description/index.html index 9514a6efe..2981e6f9f 100644 --- a/stock_reserve_rule/static/description/index.html +++ b/stock_reserve_rule/static/description/index.html @@ -3,7 +3,7 @@ - + Stock Reservation Rules