From dedfc9b8d03ffc409ea9ab25a1382bc2875aac9a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 2 Jul 2019 12:23:11 +0200 Subject: [PATCH 01/33] Add stock_routing_operation Detailed commit history for the past developments can be found in https://github.com/guewen/stock-logistics-warehouse/tree/12.0-add-stock_picking_zone --- .../odoo/addons/stock_routing_operation | 1 + setup/stock_routing_operation/setup.py | 6 + stock_routing_operation/README.rst | 182 ++++++ stock_routing_operation/__init__.py | 1 + stock_routing_operation/__manifest__.py | 15 + .../demo/stock_location_demo.xml | 26 + .../demo/stock_picking_type_demo.xml | 32 ++ stock_routing_operation/models/__init__.py | 4 + .../models/stock_location.py | 94 +++ stock_routing_operation/models/stock_move.py | 296 ++++++++++ .../models/stock_picking_type.py | 71 +++ stock_routing_operation/models/stock_quant.py | 94 +++ stock_routing_operation/readme/CONFIGURE.rst | 10 + .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 38 ++ stock_routing_operation/readme/USAGE.rst | 47 ++ .../static/description/index.html | 529 +++++++++++++++++ stock_routing_operation/tests/__init__.py | 2 + .../tests/test_routing_operation_dest.py | 539 ++++++++++++++++++ .../tests/test_routing_operation_src.py | 423 ++++++++++++++ .../views/stock_location_views.xml | 14 + 21 files changed, 2427 insertions(+) create mode 120000 setup/stock_routing_operation/odoo/addons/stock_routing_operation create mode 100644 setup/stock_routing_operation/setup.py create mode 100644 stock_routing_operation/README.rst create mode 100644 stock_routing_operation/__init__.py create mode 100644 stock_routing_operation/__manifest__.py create mode 100644 stock_routing_operation/demo/stock_location_demo.xml create mode 100644 stock_routing_operation/demo/stock_picking_type_demo.xml create mode 100644 stock_routing_operation/models/__init__.py create mode 100644 stock_routing_operation/models/stock_location.py create mode 100644 stock_routing_operation/models/stock_move.py create mode 100644 stock_routing_operation/models/stock_picking_type.py create mode 100644 stock_routing_operation/models/stock_quant.py create mode 100644 stock_routing_operation/readme/CONFIGURE.rst create mode 100644 stock_routing_operation/readme/CONTRIBUTORS.rst create mode 100644 stock_routing_operation/readme/DESCRIPTION.rst create mode 100644 stock_routing_operation/readme/USAGE.rst create mode 100644 stock_routing_operation/static/description/index.html create mode 100644 stock_routing_operation/tests/__init__.py create mode 100644 stock_routing_operation/tests/test_routing_operation_dest.py create mode 100644 stock_routing_operation/tests/test_routing_operation_src.py create mode 100644 stock_routing_operation/views/stock_location_views.xml diff --git a/setup/stock_routing_operation/odoo/addons/stock_routing_operation b/setup/stock_routing_operation/odoo/addons/stock_routing_operation new file mode 120000 index 0000000000..d8b3613b6c --- /dev/null +++ b/setup/stock_routing_operation/odoo/addons/stock_routing_operation @@ -0,0 +1 @@ +../../../../stock_routing_operation \ No newline at end of file diff --git a/setup/stock_routing_operation/setup.py b/setup/stock_routing_operation/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/stock_routing_operation/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_routing_operation/README.rst b/stock_routing_operation/README.rst new file mode 100644 index 0000000000..03260ca56f --- /dev/null +++ b/stock_routing_operation/README.rst @@ -0,0 +1,182 @@ +======================== +Stock Routing Operations +======================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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/13.0/stock_routing_operation + :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-13-0/stock-logistics-warehouse-13-0-stock_routing_operation + :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/13.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Route explains the steps you want to produce whereas the “picking routing +operation” defines how operations are grouped according to their final source +and destination location. + +This allows for example: + +* To parallelize picking operations in two locations of a warehouse, splitting + them in two different picking type +* To define pre-picking (wave) in some sub-locations, then roundtrip picking of + the sub-location waves + +Context for the use cases: + +In the warehouse, you have a High-Bay which requires to place goods in a +handover when you move goods in or out of it. The High-Bay contains many +sub-locations. + +A product can be stored either in the High-Bay, either in the Shelving zone. + +When picking: + +When there is enough stock in the Shelving, you expect the moves to have the +usual Pick(Highbay)-Pack-Ship steps. If the good is picked from the High-Bay, you will +need an extra operation: Pick(Highbay)-Handover-Pack-Ship. + +This is what this feature is doing: on the High-Bay location, you define +a "routing operation". A routing operation is based on a picking type. +The extra operation will have the selected picking type, and the new move +will have the source destination of the picking type. + +When putting away: + +A put-away rule targets the High-Bay location. +An operation Input-Highbay is created. You expect Input-Handover-Highbay. + +You can configure a routing operation for the put-away on the High-Bay Location. +The picking type of the new Handover move will the routing operation selected, +and its destination will be the destination of the picking type. + +.. 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 +============= + +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +On stock location, create an "Routing operation" operation type. +The default destination location will be the destination location +of the new operation inserted when a move has a source location which +is a child of the location. + +Usage +===== + +Try on runbot +~~~~~~~~~~~~~ + +* In Inventory Settings, activate: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +The initial setup in the demo data contains locations: + +* WH/Stock/Highbay +* WH/Stock/Highbay/Bin 1 +* WH/Stock/Highbay/Bin 2 +* WH/Stock/Handover + +The "Highbay" location (and children) is configured to: + +* create a source routing operation from Highbay to Handover when + goods are taken from Highbay (using a new picking type Highbay → Handover) +* create a destination routing operation from Handover to Highbay when + goods are put to Highbay (using a new picking type Handover → Highbay) + +Steps to try the Source Routing Operation: + +* In the main Warehouse, configure outgoing shipments to "Send goods in output and then deliver (2 steps)" +* Inventory a product, for instance "[FURN_8999] Three-Seat Sofa", add 50 items in "WH/Stock/Highbay/Bay A/Bin 1", and nowhere else +* Create a sales order with 5 "[FURN_8999] Three-Seat Sofa", confirm +* You'll have 3 transfers; a new one has been created dynamically for Highbay -> Handover. + +Steps to try the Destination Routing Operation: + +* In the "WH/Stock" location, create a Put-Away Strategy with: + + * "[DESK0004] Customizable Desk (Aluminium, Black)" to location "WH/Stock/Highbay/Bay A/Bin 1" + * "[E-COM06] Corner Desk Right Sit" to location "WH/Stock/Shelf 1" + +* Create a new purchase order of: + + * 5 "[DESK0004] Customizable Desk (Aluminium, Black)" + * 5 "[E-COM06] Corner Desk Right Sit" + +* Confirm the purchase +* You'll have 2 transfers: + + * one to move DESK0004 from Supplier → Handover and E-COM06 from Supplier → Shelf 1 + * one waiting on the other to move DESK0004 from Handover → WH/Stock/Highbay/Bay A/Bin 1 (the final location of the put-away) + +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 +~~~~~~~~~~~~ + +* Joël Grand-Guillaume +* Guewen Baconnier +* Akim Juillerat + +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_routing_operation/__init__.py b/stock_routing_operation/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/stock_routing_operation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_routing_operation/__manifest__.py b/stock_routing_operation/__manifest__.py new file mode 100644 index 0000000000..772ee588b6 --- /dev/null +++ b/stock_routing_operation/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +{ + "name": "Stock Routing Operations", + "summary": "Add extra routing operations for special locations", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "category": "Warehouse Management", + "version": "13.0.1.0.0", + "license": "AGPL-3", + "depends": ["stock"], + "demo": ["demo/stock_location_demo.xml", "demo/stock_picking_type_demo.xml"], + "data": ["views/stock_location_views.xml"], + "installable": True, + "development_status": "Alpha", +} diff --git a/stock_routing_operation/demo/stock_location_demo.xml b/stock_routing_operation/demo/stock_location_demo.xml new file mode 100644 index 0000000000..d29639c5d3 --- /dev/null +++ b/stock_routing_operation/demo/stock_location_demo.xml @@ -0,0 +1,26 @@ + + + + + Highbay + + + + Bay A + + + + Bin 1 + + + + Bin 2 + + + + + Handover + + + + diff --git a/stock_routing_operation/demo/stock_picking_type_demo.xml b/stock_routing_operation/demo/stock_picking_type_demo.xml new file mode 100644 index 0000000000..070887472a --- /dev/null +++ b/stock_routing_operation/demo/stock_picking_type_demo.xml @@ -0,0 +1,32 @@ + + + + + Highbay → Handover + internal + HBHO + + + + + + + + + Handover → Highbay + internal + HOHB + + + + + + + + + + + + + + diff --git a/stock_routing_operation/models/__init__.py b/stock_routing_operation/models/__init__.py new file mode 100644 index 0000000000..200ba08be0 --- /dev/null +++ b/stock_routing_operation/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_location +from . import stock_move +from . import stock_picking_type +from . import stock_quant diff --git a/stock_routing_operation/models/stock_location.py b/stock_routing_operation/models/stock_location.py new file mode 100644 index 0000000000..6043a0b913 --- /dev/null +++ b/stock_routing_operation/models/stock_location.py @@ -0,0 +1,94 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class StockLocation(models.Model): + _inherit = "stock.location" + + src_routing_picking_type_id = fields.Many2one( + "stock.picking.type", + string="Source Routing Operation", + help="Change destination of the move line according to the" + " default destination of the picking type after reservation" + " occurs, when the source of the move is in this location" + " (including sub-locations). A new chained move will be created " + " to reach the original destination.", + ) + dest_routing_picking_type_id = fields.Many2one( + "stock.picking.type", + string="Destination Routing Operation", + help="Change source of the move line according to the" + " default source of the picking type after reservation" + " occurs, when the destination of the move is in this location" + " (including sub-locations). A new chained move will be created " + " to reach the original source.", + ) + + @api.constrains("src_routing_picking_type_id") + def _check_src_routing_picking_type_id(self): + for location in self: + picking_type = location.src_routing_picking_type_id + if not picking_type: + continue + if picking_type.default_location_src_id != location: + raise ValidationError( + _( + "A picking type for source routing operations cannot have" + " a different default source location than the location it" + " is used on." + ) + ) + + @api.constrains("dest_routing_picking_type_id") + def _check_dest_routing_picking_type_id(self): + for location in self: + picking_type = location.dest_routing_picking_type_id + if not picking_type: + continue + if picking_type.default_location_dest_id != location: + raise ValidationError( + _( + "A picking type for destination routing operations " + "cannot have a different default destination location" + " than the location it is used on." + ) + ) + + def _find_picking_type_for_routing(self, routing_type): + if routing_type not in ("src", "dest"): + raise ValueError("routing_type must be one of ('src', 'dest')") + self.ensure_one() + # First select all the parent locations and the matching + # picking types. In a second step, the picking type matching the + # closest location + # is searched in memory. This is to avoid doing an SQL query + # for each location in the tree. + tree = self.search( + [("id", "parent_of", self.id)], + # the recordset will be ordered bottom location to top location + order="parent_path desc", + ) + if routing_type == "src": + routing_fieldname = "src_routing_location_ids" + default_location_fieldname = "default_location_src_id" + else: # dest + routing_fieldname = "dest_routing_location_ids" + default_location_fieldname = "default_location_dest_id" + domain = [ + (routing_fieldname, "!=", False), + (default_location_fieldname, "in", tree.ids), + ] + picking_types = self.env["stock.picking.type"].search(domain) + # the first location is the current move line's source location, + # then we climb up the tree of locations + for location in tree: + match = picking_types.filtered( + lambda p: p[default_location_fieldname] == location + ) + if match: + # we can only have one match as we have a unique + # constraint on is_zone + source (or dest) location + return match + return self.env["stock.picking.type"] diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py new file mode 100644 index 0000000000..fa0df884b6 --- /dev/null +++ b/stock_routing_operation/models/stock_move.py @@ -0,0 +1,296 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from itertools import chain +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _action_assign(self): + super()._action_assign() + if not self.env.context.get("exclude_apply_routing_operation"): + self._apply_src_move_routing_operation() + self._apply_dest_move_routing_operation() + + def _apply_src_move_routing_operation(self): + src_moves = self._split_per_src_routing_operation() + src_moves._apply_move_location_src_routing_operation() + + def _apply_dest_move_routing_operation(self): + dest_moves = self._split_per_dest_routing_operation() + dest_moves._apply_move_location_dest_routing_operation() + + def _split_per_src_routing_operation(self): + """Split moves per source routing operations + + When a move has move lines with different routing operations or lines + with routing operations and lines without, on the source location, this + method split the move in as many source routing operations they have. + + The reason: the destination location of the moves with a routing + operation will change and their "move_dest_ids" will be modified to + target a new move for the routing operation. + """ + move_to_assign_ids = set() + new_move_per_location = {} + for move in self: + if move.state not in ("assigned", "partially_available"): + continue + + # Group move lines per source location, some may need an additional + # operations while others not. Store the number of products to + # take from each location, so we'll be able to split the move + # if needed. + move_lines = {} + for move_line in move.move_line_ids: + location = move_line.location_id + move_lines[location] = sum(move_line.mapped("product_uom_qty")) + + # We'll split the move to have one move per different location + # where we have to take products + routing_quantities = {} + for source, qty in move_lines.items(): + routing_picking_type = source._find_picking_type_for_routing("src") + routing_quantities.setdefault(routing_picking_type, 0.0) + routing_quantities[routing_picking_type] += qty + + if len(routing_quantities) == 1: + # The whole quantity can be taken from only one location (an + # empty routing picking type being equal to one location here), + # nothing to split. + continue + + move._do_unreserve() + move.package_level_id.unlink() + move_to_assign_ids.add(move.id) + for picking_type, qty in routing_quantities.items(): + # When picking_type is empty, it means we have no routing + # operation for the move, so we have nothing to do. + if picking_type: + routing_location = picking_type.default_location_src_id + # If we have a routing operation, the move may have several + # lines with different routing operations (or lines with + # a routing operation, lines without). We split the lines + # according to these. + # The _split() method returns the same move if the qty + # is the same than the move's qty, so we don't need to + # explicitly check if we really need to split or not. + new_move_id = move._split(qty) + new_move_per_location.setdefault(routing_location.id, []) + new_move_per_location[routing_location.id].append(new_move_id) + + # it is important to assign the routed moves first + for location_id, new_move_ids in new_move_per_location.items(): + new_moves = self.browse(new_move_ids) + new_moves.with_context( + # Prevent to call _apply_move_location_routing_operation, will + # be called when all lines are processed. + exclude_apply_routing_operation=True, + # Force reservation of quants in the location they were + # reserved in at the origin (so we keep the same quantities + # at the same places) + gather_in_location_id=location_id, + )._action_assign() + + # reassign the moves which have been unreserved for the split + moves_to_assign = self.browse(move_to_assign_ids) + if moves_to_assign: + moves_to_assign._action_assign() + new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) + return self + new_moves + + def _apply_move_location_src_routing_operation(self): + """Apply routing operations + + When a move has a routing operation configured on its location and the + destination of the move does not match the destination of the routing + operation, this method updates the move's destination and it's picking + type with the routing operation ones and creates a new chained move + after it. + """ + for move in self: + if move.state not in ("assigned", "partially_available"): + continue + + # Group move lines per source location, some may need an additional + # operations while others not. Store the number of products to + # take from each location, so we'll be able to split the move + # if needed. + # At this point, we should not have lines with different source + # locations, they have been split in + # _split_per_routing_operation(), so we can take the first one + source = move.move_line_ids[0].location_id + destination = move.move_line_ids[0].location_dest_id + # we have to add a move as destination + # we have to add move as origin + routing = source._find_picking_type_for_routing("src") + if not routing: + continue + + if self.env["stock.location"].search( + [ + ("id", "=", routing.default_location_dest_id.id), + ("id", "parent_of", move.location_dest_id.id), + ] + ): + # we don't need to do anything because we already go through + # the expected destination + continue + + # the current move becomes the routing move, and we'll add a new + # move after this one to pick the goods where the routing moved + # them, we have to unreserve and assign at the end to have the move + # lines go to the correct destination + move._do_unreserve() + move.package_level_id.unlink() + dest = routing.default_location_dest_id + current_picking_type = move.picking_id.picking_type_id + move.write({"location_dest_id": dest.id, "picking_type_id": routing.id}) + move._insert_routing_moves( + current_picking_type, move.location_id, destination + ) + + picking = move.picking_id + move._assign_picking() + if not picking.move_lines: + # When the picking type changes, it will create a new picking + # for the move. If the previous picking has no other move, + # we have to drop it. + picking.unlink() + + move._action_assign() + + def _insert_routing_moves(self, picking_type, location, destination): + """Create a chained move for the source routing operation""" + self.ensure_one() + dest_moves = self.move_dest_ids + # Insert move between the source and destination for the new + # operation + routing_move_values = self._prepare_routing_move_values( + picking_type, location, destination + ) + routing_move = self.copy(routing_move_values) + + # modify the chain to include the new move + self.write( + {"move_dest_ids": [(3, m.id) for m in dest_moves] + [(4, routing_move.id)]} + ) + if dest_moves: + dest_moves.write({"move_orig_ids": [(3, self.id), (4, routing_move.id)]}) + routing_move._action_confirm(merge=False) + routing_move._assign_picking() + + def _prepare_routing_move_values(self, picking_type, source, destination): + return { + "picking_id": False, + "location_id": source.id, + "location_dest_id": destination.id, + "state": "waiting", + "picking_type_id": picking_type.id, + } + + def _split_per_dest_routing_operation(self): + """Split moves per destination routing operations + + When a move has move lines with different routing operations or lines + with routing operations and lines without, on the destination, this + method split the move in as many destination routing operations they + have. + + The reason: the destination location of the moves with a routing + operation will change and their "move_dest_ids" will be modified to + target a new move for the routing operation. + """ + new_moves = self.browse() + for move in self: + if move.state not in ("assigned", "partially_available"): + continue + + # Group move lines per destination location, some may need an + # additional operations while others not. Store the number of + # products to take from each location, so we'll be able to split + # the move if needed. + routing_move_lines = {} + routing_operations = {} + for move_line in move.move_line_ids: + dest = move_line.location_dest_id + if dest in routing_operations: + routing_picking_type = routing_operations[dest] + else: + routing_picking_type = dest._find_picking_type_for_routing("dest") + routing_move_lines.setdefault( + routing_picking_type, self.env["stock.move.line"].browse() + ) + routing_move_lines[routing_picking_type] |= move_line + + if len(routing_move_lines) == 1: + # If we have no routing operation or only one routing + # operation, we don't need to split the moves. We need to split + # only if we have 2 different routing operations, or move + # without routing operation and one(s) with routing operations. + continue + + for picking_type, move_lines in routing_move_lines.items(): + if not picking_type: + # No routing operation is required for these moves, + # continue to the next + continue + # if we have a picking type, split returns the same move if + # the qty is the same + qty = sum(move_lines.mapped("product_uom_qty")) + new_move_id = move._split(qty) + new_move = self.browse(new_move_id) + move_lines.write({"move_id": new_move.id}) + assert move.state in ("assigned", "partially_available") + # We know the new move is 'assigned' because we created it + # with the quantity matching the move lines that we move into + new_move.state = "assigned" + new_moves += new_move + + return self + new_moves + + def _apply_move_location_dest_routing_operation(self): + """Apply routing operations + + When a move has a routing operation configured on its location and the + destination of the move does not match the destination of the routing + operation, this method updates the move's destination and it's picking + type with the routing operation ones and creates a new chained move + after it. + """ + for move in self: + if move.state not in ("assigned", "partially_available"): + continue + + # Group move lines per source location, some may need an additional + # operations while others not. Store the number of products to + # take from each location, so we'll be able to split the move + # if needed. + # At this point, we should not have lines with different source + # locations, they have been split in + # _split_per_routing_operation(), so we can take the first one + destination = move.move_line_ids[0].location_dest_id + picking_type = destination._find_picking_type_for_routing("dest") + if not picking_type: + continue + + if self.env["stock.location"].search( + [ + ("id", "=", picking_type.default_location_src_id.id), + ("id", "parent_of", move.location_id.id), + ] + ): + # This move has been created for the routing operation, + # or was already created with the correct locations anyway, + # exit or it would indefinitely add a next move + continue + + # Move the goods in the "routing" location instead. + # In this use case, we want to keep the move lines so we don't + # change the reservation. + move.write({"location_dest_id": picking_type.default_location_src_id.id}) + move.move_line_ids.write( + {"location_dest_id": picking_type.default_location_src_id.id} + ) + move._insert_routing_moves(picking_type, move.location_dest_id, destination) diff --git a/stock_routing_operation/models/stock_picking_type.py b/stock_routing_operation/models/stock_picking_type.py new file mode 100644 index 0000000000..ad5465931f --- /dev/null +++ b/stock_routing_operation/models/stock_picking_type.py @@ -0,0 +1,71 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import _, api, exceptions, fields, models + + +class StockPickingType(models.Model): + _inherit = "stock.picking.type" + + src_routing_location_ids = fields.One2many( + "stock.location", "src_routing_picking_type_id" + ) + dest_routing_location_ids = fields.One2many( + "stock.location", "dest_routing_picking_type_id" + ) + + def _check_routing_location_unique(self, routing_type): + if routing_type not in ("src", "dest"): + raise ValueError("routing_type must be one of ('src', 'dest')") + if routing_type == "src": + routing_location_fieldname = "src_routing_location_ids" + default_location_fieldname = "default_location_src_id" + message_fragment = _("source") + else: # dest + routing_location_fieldname = "dest_routing_location_ids" + default_location_fieldname = "default_location_dest_id" + message_fragment = _("destination") + for picking_type in self: + if not picking_type[routing_location_fieldname]: + continue + if len(picking_type[routing_location_fieldname]) > 1: + raise exceptions.ValidationError( + _( + "The same picking type cannot be used on different " + "locations having routing operations." + ) + ) + if ( + picking_type[routing_location_fieldname] + != picking_type[default_location_fieldname] + ): + raise exceptions.ValidationError( + _( + "A picking type for routing operations cannot have a" + " different default %s location than the location it " + "is used on." + ) + % (message_fragment,) + ) + default_location = picking_type[default_location_fieldname] + domain = [ + (routing_location_fieldname, "!=", False), + (default_location_fieldname, "=", default_location.id), + ("id", "!=", picking_type.id), + ] + other = self.search(domain) + if other: + raise exceptions.ValidationError( + _( + "Another routing operation picking type (%s) exists for" + " the same %s location." + ) + % (other.display_name, message_fragment) + ) + + @api.constrains("src_routing_location_ids", "default_location_src_id") + def _check_src_routing_location_unique(self): + self._check_routing_location_unique("src") + + @api.constrains("dest_routing_location_ids", "default_location_dest_id") + def _check_dest_routing_location_unique(self): + self._check_routing_location_unique("dest") diff --git a/stock_routing_operation/models/stock_quant.py b/stock_routing_operation/models/stock_quant.py new file mode 100644 index 0000000000..9242fc3ea4 --- /dev/null +++ b/stock_routing_operation/models/stock_quant.py @@ -0,0 +1,94 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +"""Allow forcing reservations of quants in a location (or children) + +When the context key "gather_in_location_id" is passed, it will look +in this location or its children. + +Example:: + + moves.with_context( + gather_in_location_id=location.id, + )._action_assign() + +""" + +from odoo import models + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + def _update_reserved_quantity( + self, + product_id, + location_id, + quantity, + lot_id=None, + package_id=None, + owner_id=None, + strict=False, + ): + gather_in_location_id = self.env.context.get("gather_in_location_id") + if gather_in_location_id: + location_model = self.env["stock.location"] + location_id = location_model.browse(gather_in_location_id) + result = super()._update_reserved_quantity( + product_id, + location_id, + quantity, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + return result + + def _update_available_quantity( + self, + product_id, + location_id, + quantity, + lot_id=None, + package_id=None, + owner_id=None, + in_date=None, + ): + gather_in_location_id = self.env.context.get("gather_in_location_id") + if gather_in_location_id: + location_model = self.env["stock.location"] + location_id = location_model.browse(gather_in_location_id) + result = super()._update_available_quantity( + product_id, + location_id, + quantity, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + in_date=in_date, + ) + return result + + def _get_available_quantity( + self, + product_id, + location_id, + lot_id=None, + package_id=None, + owner_id=None, + strict=False, + allow_negative=False, + ): + gather_in_location_id = self.env.context.get("gather_in_location_id") + if gather_in_location_id: + location_model = self.env["stock.location"] + location_id = location_model.browse(gather_in_location_id) + result = super()._get_available_quantity( + product_id, + location_id, + lot_id=lot_id, + package_id=package_id, + owner_id=owner_id, + strict=strict, + ) + return result diff --git a/stock_routing_operation/readme/CONFIGURE.rst b/stock_routing_operation/readme/CONFIGURE.rst new file mode 100644 index 0000000000..9e880ec0c5 --- /dev/null +++ b/stock_routing_operation/readme/CONFIGURE.rst @@ -0,0 +1,10 @@ +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +On stock location, create an "Routing operation" operation type. +The default destination location will be the destination location +of the new operation inserted when a move has a source location which +is a child of the location. diff --git a/stock_routing_operation/readme/CONTRIBUTORS.rst b/stock_routing_operation/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..7c15b32aa7 --- /dev/null +++ b/stock_routing_operation/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Joël Grand-Guillaume +* Guewen Baconnier +* Akim Juillerat diff --git a/stock_routing_operation/readme/DESCRIPTION.rst b/stock_routing_operation/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..cdb9b16fdd --- /dev/null +++ b/stock_routing_operation/readme/DESCRIPTION.rst @@ -0,0 +1,38 @@ +Route explains the steps you want to produce whereas the “picking routing +operation” defines how operations are grouped according to their final source +and destination location. + +This allows for example: + +* To parallelize picking operations in two locations of a warehouse, splitting + them in two different picking type +* To define pre-picking (wave) in some sub-locations, then roundtrip picking of + the sub-location waves + +Context for the use cases: + +In the warehouse, you have a High-Bay which requires to place goods in a +handover when you move goods in or out of it. The High-Bay contains many +sub-locations. + +A product can be stored either in the High-Bay, either in the Shelving zone. + +When picking: + +When there is enough stock in the Shelving, you expect the moves to have the +usual Pick(Highbay)-Pack-Ship steps. If the good is picked from the High-Bay, you will +need an extra operation: Pick(Highbay)-Handover-Pack-Ship. + +This is what this feature is doing: on the High-Bay location, you define +a "routing operation". A routing operation is based on a picking type. +The extra operation will have the selected picking type, and the new move +will have the source destination of the picking type. + +When putting away: + +A put-away rule targets the High-Bay location. +An operation Input-Highbay is created. You expect Input-Handover-Highbay. + +You can configure a routing operation for the put-away on the High-Bay Location. +The picking type of the new Handover move will the routing operation selected, +and its destination will be the destination of the picking type. diff --git a/stock_routing_operation/readme/USAGE.rst b/stock_routing_operation/readme/USAGE.rst new file mode 100644 index 0000000000..ea465cdec0 --- /dev/null +++ b/stock_routing_operation/readme/USAGE.rst @@ -0,0 +1,47 @@ +Try on runbot +~~~~~~~~~~~~~ + +* In Inventory Settings, activate: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +The initial setup in the demo data contains locations: + +* WH/Stock/Highbay +* WH/Stock/Highbay/Bin 1 +* WH/Stock/Highbay/Bin 2 +* WH/Stock/Handover + +The "Highbay" location (and children) is configured to: + +* create a source routing operation from Highbay to Handover when + goods are taken from Highbay (using a new picking type Highbay → Handover) +* create a destination routing operation from Handover to Highbay when + goods are put to Highbay (using a new picking type Handover → Highbay) + +Steps to try the Source Routing Operation: + +* In the main Warehouse, configure outgoing shipments to "Send goods in output and then deliver (2 steps)" +* Inventory a product, for instance "[FURN_8999] Three-Seat Sofa", add 50 items in "WH/Stock/Highbay/Bay A/Bin 1", and nowhere else +* Create a sales order with 5 "[FURN_8999] Three-Seat Sofa", confirm +* You'll have 3 transfers; a new one has been created dynamically for Highbay -> Handover. + +Steps to try the Destination Routing Operation: + +* In the "WH/Stock" location, create a Put-Away Strategy with: + + * "[DESK0004] Customizable Desk (Aluminium, Black)" to location "WH/Stock/Highbay/Bay A/Bin 1" + * "[E-COM06] Corner Desk Right Sit" to location "WH/Stock/Shelf 1" + +* Create a new purchase order of: + + * 5 "[DESK0004] Customizable Desk (Aluminium, Black)" + * 5 "[E-COM06] Corner Desk Right Sit" + +* Confirm the purchase +* You'll have 2 transfers: + + * one to move DESK0004 from Supplier → Handover and E-COM06 from Supplier → Shelf 1 + * one waiting on the other to move DESK0004 from Handover → WH/Stock/Highbay/Bay A/Bin 1 (the final location of the put-away) diff --git a/stock_routing_operation/static/description/index.html b/stock_routing_operation/static/description/index.html new file mode 100644 index 0000000000..cb1463f599 --- /dev/null +++ b/stock_routing_operation/static/description/index.html @@ -0,0 +1,529 @@ + + + + + + +Stock Routing Operations + + + +
+

Stock Routing Operations

+ + +

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

+

Route explains the steps you want to produce whereas the “picking routing +operation” defines how operations are grouped according to their final source +and destination location.

+

This allows for example:

+
    +
  • To parallelize picking operations in two locations of a warehouse, splitting +them in two different picking type
  • +
  • To define pre-picking (wave) in some sub-locations, then roundtrip picking of +the sub-location waves
  • +
+

Context for the use cases:

+

In the warehouse, you have a High-Bay which requires to place goods in a +handover when you move goods in or out of it. The High-Bay contains many +sub-locations.

+

A product can be stored either in the High-Bay, either in the Shelving zone.

+

When picking:

+

When there is enough stock in the Shelving, you expect the moves to have the +usual Pick(Highbay)-Pack-Ship steps. If the good is picked from the High-Bay, you will +need an extra operation: Pick(Highbay)-Handover-Pack-Ship.

+

This is what this feature is doing: on the High-Bay location, you define +a “routing operation”. A routing operation is based on a picking type. +The extra operation will have the selected picking type, and the new move +will have the source destination of the picking type.

+

When putting away:

+

A put-away rule targets the High-Bay location. +An operation Input-Highbay is created. You expect Input-Handover-Highbay.

+

You can configure a routing operation for the put-away on the High-Bay Location. +The picking type of the new Handover move will the routing operation selected, +and its destination will be the destination of the picking type.

+
+

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

+

In Inventory Settings, you must have:

+
+
    +
  • Storage Locations
  • +
  • Multi-Warehouses
  • +
  • Multi-Step Routes
  • +
+
+

On stock location, create an “Routing operation” operation type. +The default destination location will be the destination location +of the new operation inserted when a move has a source location which +is a child of the location.

+
+
+

Usage

+
+

Try on runbot

+
    +
  • In Inventory Settings, activate:
      +
    • Storage Locations
    • +
    • Multi-Warehouses
    • +
    • Multi-Step Routes
    • +
    +
  • +
+

The initial setup in the demo data contains locations:

+
    +
  • WH/Stock/Highbay
  • +
  • WH/Stock/Highbay/Bin 1
  • +
  • WH/Stock/Highbay/Bin 2
  • +
  • WH/Stock/Handover
  • +
+

The “Highbay” location (and children) is configured to:

+
    +
  • create a source routing operation from Highbay to Handover when +goods are taken from Highbay (using a new picking type Highbay → Handover)
  • +
  • create a destination routing operation from Handover to Highbay when +goods are put to Highbay (using a new picking type Handover → Highbay)
  • +
+

Steps to try the Source Routing Operation:

+
    +
  • In the main Warehouse, configure outgoing shipments to “Send goods in output and then deliver (2 steps)”
  • +
  • Inventory a product, for instance “[FURN_8999] Three-Seat Sofa”, add 50 items in “WH/Stock/Highbay/Bay A/Bin 1”, and nowhere else
  • +
  • Create a sales order with 5 “[FURN_8999] Three-Seat Sofa”, confirm
  • +
  • You’ll have 3 transfers; a new one has been created dynamically for Highbay -> Handover.
  • +
+

Steps to try the Destination Routing Operation:

+
    +
  • In the “WH/Stock” location, create a Put-Away Strategy with:
      +
    • “[DESK0004] Customizable Desk (Aluminium, Black)” to location “WH/Stock/Highbay/Bay A/Bin 1”
    • +
    • “[E-COM06] Corner Desk Right Sit” to location “WH/Stock/Shelf 1”
    • +
    +
  • +
  • Create a new purchase order of:
      +
    • 5 “[DESK0004] Customizable Desk (Aluminium, Black)”
    • +
    • 5 “[E-COM06] Corner Desk Right Sit”
    • +
    +
  • +
  • Confirm the purchase
  • +
  • You’ll have 2 transfers:
      +
    • one to move DESK0004 from Supplier → Handover and E-COM06 from Supplier → Shelf 1
    • +
    • one waiting on the other to move DESK0004 from Handover → WH/Stock/Highbay/Bay A/Bin 1 (the final location of the put-away)
    • +
    +
  • +
+
+
+
+

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_routing_operation/tests/__init__.py b/stock_routing_operation/tests/__init__.py new file mode 100644 index 0000000000..2a582037d5 --- /dev/null +++ b/stock_routing_operation/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_routing_operation_src +from . import test_routing_operation_dest diff --git a/stock_routing_operation/tests/test_routing_operation_dest.py b/stock_routing_operation/tests/test_routing_operation_dest.py new file mode 100644 index 0000000000..be7c62dc09 --- /dev/null +++ b/stock_routing_operation/tests/test_routing_operation_dest.py @@ -0,0 +1,539 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo.tests import common + + +class TestDestRoutingOperation(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.supplier_loc = cls.env.ref("stock.stock_location_suppliers") + cls.location_hb = cls.env["stock.location"].create( + {"name": "Highbay", "location_id": cls.wh.lot_stock_id.id} + ) + cls.location_shelf_1 = cls.env["stock.location"].create( + {"name": "Shelf 1", "location_id": cls.wh.lot_stock_id.id} + ) + cls.location_hb_1 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1", "location_id": cls.location_hb.id} + ) + cls.location_hb_1_1 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1 Bin 1", "location_id": cls.location_hb_1.id} + ) + cls.location_hb_1_2 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1 Bin 2", "location_id": cls.location_hb_1.id} + ) + + cls.location_handover = cls.env["stock.location"].create( + {"name": "Handover", "location_id": cls.location_hb.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.pick_type_routing_op = cls.env["stock.picking.type"].create( + { + "name": "Routing operation", + "code": "internal", + "sequence_code": "WH/HO", + "warehouse_id": cls.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": cls.location_handover.id, + "default_location_dest_id": cls.location_hb.id, + } + ) + cls.location_hb.write( + {"dest_routing_picking_type_id": cls.pick_type_routing_op.id} + ) + + def _create_supplier_input_highbay(self, wh, products=None): + """Create pickings supplier->input, input-> highbay + + Products must be a list of tuples (product, quantity, put_location). + One stock move will be create for each tuple. + """ + if products is None: + products = [] + in_picking = self.env["stock.picking"].create( + { + "location_id": self.supplier_loc.id, + "location_dest_id": wh.wh_input_stock_loc_id.id, + "partner_id": self.partner_delta.id, + "picking_type_id": wh.in_type_id.id, + } + ) + + internal_picking = self.env["stock.picking"].create( + { + "location_id": wh.wh_input_stock_loc_id.id, + "location_dest_id": wh.lot_stock_id.id, + "partner_id": self.partner_delta.id, + "picking_type_id": wh.int_type_id.id, + } + ) + + for product, qty, put_location in products: + dest = self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "picking_id": internal_picking.id, + "location_id": wh.wh_input_stock_loc_id.id, + "location_dest_id": put_location.id, + "state": "waiting", + "procure_method": "make_to_order", + } + ) + + self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "picking_id": in_picking.id, + "location_id": self.supplier_loc.id, + "location_dest_id": wh.wh_input_stock_loc_id.id, + "move_dest_ids": [(4, dest.id)], + "state": "confirmed", + } + ) + in_picking.action_assign() + return in_picking, internal_picking + + def _update_product_qty_in_location(self, location, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, location, quantity) + + def assert_src_input(self, record): + self.assertEqual(record.location_id, self.wh.wh_input_stock_loc_id) + + def assert_dest_input(self, record): + self.assertEqual(record.location_dest_id, self.wh.wh_input_stock_loc_id) + + def assert_src_handover(self, record): + self.assertEqual(record.location_id, self.location_handover) + + def assert_dest_handover(self, record): + self.assertEqual(record.location_dest_id, self.location_handover) + + def assert_dest_stock(self, record): + self.assertEqual(record.location_dest_id, self.wh.lot_stock_id) + + def assert_src_shelf1(self, record): + self.assertEqual(record.location_id, self.location_shelf_1) + + def assert_dest_shelf1(self, record): + self.assertEqual(record.location_dest_id, self.location_shelf_1) + + def assert_src_highbay_1_2(self, record): + self.assertEqual(record.location_id, self.location_hb_1_2) + + def assert_dest_highbay_1_2(self, record): + self.assertEqual(record.location_dest_id, self.location_hb_1_2) + + def assert_src_supplier(self, record): + self.assertEqual(record.location_id, self.supplier_loc) + + def process_operations(self, moves): + for move in moves: + qty = move.move_line_ids.product_uom_qty + move.move_line_ids.qty_done = qty + move.mapped("picking_id").action_done() + + def test_change_location_to_routing_operation(self): + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + self.assertEqual(move_a.state, "assigned") + self.process_operations(move_a) + + # At this point, we should have 3 stock.picking: + # + # +---------------------------------------------------------+ + # | IN/xxxx Done | + # | Supplier -> Input | + # | Product1 Supplier → Input (done) move_a | + # +---------------------------------------------------------+ + # + # +-------------------------------------------------------------------+ + # | INT/xxxx Available | + # | Input → Stock | + # | Product1 Input → Stock/Highbay/Handover (available) move_b | + # +-------------------------------------------------------------------+ + # + # the new one with our routing picking type: + # +-------------------------------------------------------------------+ + # | HO/xxxx Waiting | + # | Stock/Handover → Highbay | + # | Product1 Stock/Highbay/Handover → Highbay1-2 (waiting) added_move | + # +-------------------------------------------------------------------+ + + self.assert_src_supplier(move_a) + self.assert_dest_input(move_a) + self.assert_src_input(move_b) + # the move stays B stays on the same dest location + self.assert_dest_handover(move_b) + + # we should have a move added after move_b to put + # the goods in their final location + routing_move = move_b.move_dest_ids + # the middle move stays in the same source location than the original + # move: the move line will be in the sub-locations (handover) + + self.assert_src_handover(routing_move) + self.assert_dest_highbay_1_2(routing_move) + + self.assertEquals(routing_move.picking_type_id, self.pick_type_routing_op) + self.assertEquals( + routing_move.picking_id.picking_type_id, self.pick_type_routing_op + ) + self.assertEquals(move_a.picking_id.picking_type_id, self.wh.in_type_id) + self.assertEquals(move_b.picking_id.picking_type_id, self.wh.int_type_id) + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b.state, "assigned") + self.assertEqual(routing_move.state, "waiting") + + # we put the B move, to check that our new destination + # move is correctly assigned + self.process_operations(move_b) + + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b.state, "done") + self.assertEqual(routing_move.state, "assigned") + + routing_ml = routing_move.move_line_ids + self.assertEqual(len(routing_ml), 1) + self.assert_src_handover(routing_ml) + self.assert_dest_highbay_1_2(routing_ml) + + def test_several_moves(self): + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, + [ + (self.product1, 10, self.location_hb_1_2), + (self.product2, 10, self.location_shelf_1), + ], + ) + product1 = self.product1 + product2 = self.product2 + in_moves = in_picking.move_lines + move_a_p1 = in_moves.filtered(lambda r: r.product_id == product1) + move_a_p2 = in_moves.filtered(lambda r: r.product_id == product2) + internal_moves = internal_picking.move_lines + move_b_p1 = internal_moves.filtered(lambda r: r.product_id == product1) + move_b_p2 = internal_moves.filtered(lambda r: r.product_id == product2) + self.assertEqual(move_a_p1.state, "assigned") + self.assertEqual(move_a_p2.state, "assigned") + self.assertEqual(move_b_p1.state, "waiting") + self.assertEqual(move_b_p2.state, "waiting") + + self.process_operations(move_a_p1 + move_a_p2) + + # At this point, we should have 3 stock.picking: + # + # +-----------------------------------------------------+ + # | IN/xxxx Done | + # | Supplier -> Input | + # | Product1 Supplier → Input (done) move_a_p1 | + # | Product2 Supplier → Input (done) move_a_p2 | + # +-----------------------------------------------------+ + # + # +-----------------------------------------------------------+ + # | INT/xxxx Available | + # | Input → Stock | + # | Product1 Input → Stock/Handover (available) move_b_p1 | + # | Product2 Input → Shelf1 (available) move_b_p2 | + # +-----------------------------------------------------------+ + # + # the new one with our routing picking type: + # +-------------------------------------------------------------------+ + # | HO/xxxx Waiting | + # | Stock/Handover → Highbay | + # | Product1 Stock/Highbay/Handover → Highbay1-2 (waiting) added_move | + # +-------------------------------------------------------------------+ + + routing_move = move_b_p1.move_dest_ids + self.assertEqual(len(routing_move), 1) + self.assertEqual(move_a_p1.move_dest_ids, move_b_p1) + self.assertEqual(move_a_p2.move_dest_ids, move_b_p2) + self.assertFalse(routing_move.move_dest_ids) + self.assertFalse(move_b_p2.move_dest_ids) + + self.assertEqual(move_a_p1.state, "done") + self.assertEqual(move_a_p2.state, "done") + self.assertEqual(move_b_p1.state, "assigned") + self.assertEqual(move_b_p2.state, "assigned") + self.assertEqual(routing_move.state, "waiting") + + routing_picking = routing_move.picking_id + + # Check picking A + self.assertEqual(in_picking.move_lines, move_a_p1 + move_a_p2) + self.assertEqual(in_picking.picking_type_id, self.wh.in_type_id) + self.assert_src_supplier(in_picking) + self.assert_dest_input(in_picking) + + # Check picking B + self.assertEqual(internal_picking.move_lines, move_b_p1 + move_b_p2) + self.assertEqual(internal_picking.picking_type_id, self.wh.int_type_id) + self.assert_src_input(internal_picking) + self.assert_dest_stock(internal_picking) + + # Check routing picking + self.assertEqual(routing_picking.move_lines, routing_move) + self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) + self.assert_src_handover(routing_picking) + self.assert_dest_highbay_1_2(routing_picking) + + # check move and move line A for product1 + self.assert_src_supplier(move_a_p1) + self.assert_dest_input(move_a_p1) + ml = move_a_p1.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_supplier(ml) + self.assert_dest_input(ml) + + # check move and move line A for product2 + self.assert_src_supplier(move_a_p2) + self.assert_dest_input(move_a_p2) + ml = move_a_p2.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_supplier(ml) + self.assert_dest_input(ml) + + # check move and move line B for product1 + self.assert_src_input(move_b_p1) + self.assert_dest_handover(move_b_p1) + ml = move_b_p1.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_input(ml) + self.assert_dest_handover(ml) + self.assertEqual(ml.state, "assigned") + + # check move and move line B for product2 + self.assert_src_input(move_b_p2) + self.assert_dest_shelf1(move_b_p2) + ml = move_b_p2.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_input(ml) + self.assert_dest_shelf1(ml) + self.assertEqual(ml.state, "assigned") + + # check routing move for product1 + self.assert_src_handover(routing_move) + self.assert_dest_highbay_1_2(routing_move) + + # Deliver the internal picking (moves B), + # the routing move for product1 should be assigned, + # the product2 should be done (put in shelf1). + self.process_operations(move_b_p1 + move_b_p2) + + self.assertEqual(move_b_p1.state, "done") + self.assertEqual(move_b_p2.state, "done") + self.assertEqual(routing_move.state, "assigned") + + # Check move line for the routing move + ml = routing_move.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_handover(ml) + self.assert_dest_highbay_1_2(ml) + + self.process_operations(routing_move) + + self.assertEqual(move_a_p1.state, "done") + self.assertEqual(move_a_p2.state, "done") + self.assertEqual(move_b_p1.state, "done") + self.assertEqual(move_b_p2.state, "done") + self.assertEqual(routing_move.state, "done") + + def test_several_move_lines(self): + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + # We do not want to trigger the routing operation now (see explanation + # below) + move_b.location_dest_id = self.wh.lot_stock_id + + self.process_operations(move_a) + + # At this point, move_a being 'done', action_assign is executed + # on move_b. The standard put-away rules would put all the 10 + # products in a location. So with a standard odoo, we can't have + # the situation we want to test here. Using additional modules + # with more advanced put-away rules, the put-away could create + # several move lines with different destinations, for instance + # one in the highbay, one in the shelf. The highbay one will + # need an additional move, the one in the shelf not. + + # In order to simulate this, we'll manually change the move lines of + # move_b and call'_apply_dest_move_routing_operation()' on it to force + # the application of the routing operation. + + first_ml = move_b.move_line_ids + # use _write to bypass the changes to quants done in the + # write of stock.move.line: we want to keep the same reservation + # of 10 units + first_ml._write( + {"product_uom_qty": 6.0, "location_dest_id": self.location_hb_1_2.id} + ) + move_b.move_line_ids.copy( + {"product_uom_qty": 4.0, "location_dest_id": self.location_shelf_1.id} + ) + move_b.move_line_ids.invalidate_cache(["product_uom_qty", "location_dest_id"]) + # At this point, we should have this + # + # +-----------------------------------------------------+ + # | IN/xxxx Done | + # | Supplier -> Input | + # | 10x Product1 Supplier → Input (done) move_a | + # +-----------------------------------------------------+ + # + # +--------------------------------------------------------+ + # | INT/xxxx Available | + # | Input → Stock | + # | Move B Product1 Input → Stock with 2 operations: | + # | 6x Product1 Input → Stock/HB-1-2 (available) | + # | 4x Product1 Input → Stock/Shelf1 (available) | + # +--------------------------------------------------------+ + + move_b._apply_dest_move_routing_operation() + + # We expect the routing operation to split the move_b so + # we'll be able to have a move_dest_ids for the Highbay: + + # +--------------------------------------------------------+ + # | IN/xxxx Done | + # | Supplier -> Input | + # | 10x Product1 Supplier → Input (done) move_a | + # +--------------------------------------------------------+ + # + # +--------------------------------------------------------+ + # | INT/xxxx Available | + # | Input → Stock | + # | 6x Product1 Input → Stock/Handover (available) move_b1 | + # | 4x Product1 Input → Stock/Shelf1 (available) move_b2 | + # +--------------------------------------------------------+ + # + # the new one with our routing picking type: + # +--------------------------------------------------------+ + # | HO/xxxx Waiting | + # | Stock/Handover → Highbay | + # | 6x Product1 Stock/Highbay/Handover → HB-1-2 (waiting) | + # +--------------------------------------------------------+ + + move_b_shelf = move_b + move_b_handover = move_b.picking_id.move_lines - move_b + self.assertEqual(len(move_b_handover), 1) + + routing_move = move_b_handover.move_dest_ids + self.assertEqual(len(routing_move), 1) + routing_picking = routing_move.picking_id + + # check chaining + self.assertEqual(move_a.move_dest_ids, move_b_shelf + move_b_handover) + self.assertFalse(move_b_shelf.move_dest_ids) + self.assertEqual(move_b_handover.move_dest_ids, routing_move) + self.assertFalse(routing_move.move_dest_ids) + + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b_shelf.state, "assigned") + self.assertEqual(move_b_handover.state, "assigned") + self.assertEqual(routing_move.state, "waiting") + + # Check picking A + self.assertEqual(in_picking.move_lines, move_a) + self.assertEqual(in_picking.picking_type_id, self.wh.in_type_id) + self.assert_src_supplier(in_picking) + self.assert_dest_input(in_picking) + + # Check picking B + self.assertEqual(internal_picking.move_lines, move_b_shelf + move_b_handover) + self.assertEqual(internal_picking.picking_type_id, self.wh.int_type_id) + self.assert_src_input(internal_picking) + self.assert_dest_stock(internal_picking) + + # Check routing picking + self.assertEqual(routing_picking.move_lines, routing_move) + self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) + self.assert_src_handover(routing_picking) + self.assert_dest_highbay_1_2(routing_picking) + + # check move and move line A + self.assert_src_supplier(move_a) + self.assert_dest_input(move_a) + self.assertEqual(move_a.product_qty, 10.0) + ml = move_a.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_supplier(ml) + self.assert_dest_input(ml) + self.assertEqual(ml.qty_done, 10.0) + + # check move and move line B Shelf + self.assert_src_input(move_b_shelf) + self.assert_dest_stock(move_b_shelf) + self.assertEqual(move_b_shelf.product_qty, 4.0) + ml = move_b_shelf.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_input(ml) + self.assert_dest_shelf1(ml) + self.assertEqual(ml.product_qty, 4.0) + self.assertEqual(ml.qty_done, 0.0) + + # check move and move line B Handover + self.assert_src_input(move_b_handover) + self.assert_dest_handover(move_b_handover) + self.assertEqual(move_b_handover.product_qty, 6.0) + ml = move_b_handover.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_input(ml) + self.assert_dest_handover(ml) + self.assertEqual(ml.product_qty, 6.0) + self.assertEqual(ml.qty_done, 0.0) + + # check routing move for product1 + self.assert_src_handover(routing_move) + self.assert_dest_highbay_1_2(routing_move) + + # Deliver the internal picking (moves B), + # the routing move should be assigned, + # the other should be done (put in shelf1). + self.process_operations(move_b_shelf + move_b_handover) + + self.assertEqual(move_b_shelf.state, "done") + self.assertEqual(move_b_handover.state, "done") + self.assertEqual(routing_move.state, "assigned") + + # Check move line for the routing move + ml = routing_move.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_handover(ml) + self.assert_dest_highbay_1_2(ml) + + self.process_operations(routing_move) + + self.assertEqual(move_a.state, "done") + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b_shelf.state, "done") + self.assertEqual(move_b_handover.state, "done") + self.assertEqual(routing_move.state, "done") diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_operation_src.py new file mode 100644 index 0000000000..4c1cb1f7f0 --- /dev/null +++ b/stock_routing_operation/tests/test_routing_operation_src.py @@ -0,0 +1,423 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo.tests import common + + +class TestSourceRoutingOperation(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.location_hb = cls.env["stock.location"].create( + {"name": "Highbay", "location_id": cls.wh.lot_stock_id.id} + ) + cls.location_shelf_1 = cls.env["stock.location"].create( + {"name": "Shelf 1", "location_id": cls.wh.lot_stock_id.id} + ) + cls.location_hb_1 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1", "location_id": cls.location_hb.id} + ) + cls.location_hb_1_1 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1 Bin 1", "location_id": cls.location_hb_1.id} + ) + cls.location_hb_1_2 = cls.env["stock.location"].create( + {"name": "Highbay Shelf 1 Bin 2", "location_id": cls.location_hb_1.id} + ) + + cls.location_handover = cls.env["stock.location"].create( + {"name": "Handover", "location_id": cls.wh.lot_stock_id.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.pick_type_routing_op = cls.env["stock.picking.type"].create( + { + "name": "Routing operation", + "code": "internal", + "sequence_code": "WH/HO", + "warehouse_id": cls.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": cls.location_hb.id, + "default_location_dest_id": cls.location_handover.id, + } + ) + cls.location_hb.write( + {"src_routing_picking_type_id": cls.pick_type_routing_op.id} + ) + + def _create_pick_ship(self, wh, products=None): + """Create pick+ship pickings + + Products must be a list of tuples (product, quantity). + One stock move will be create for each tuple. + """ + if products is None: + products = [] + customer_picking = self.env["stock.picking"].create( + { + "location_id": wh.wh_output_stock_loc_id.id, + "location_dest_id": self.customer_loc.id, + "partner_id": self.partner_delta.id, + "picking_type_id": wh.out_type_id.id, + } + ) + + pick_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: + dest = self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "picking_id": customer_picking.id, + "location_id": wh.wh_output_stock_loc_id.id, + "location_dest_id": self.customer_loc.id, + "state": "waiting", + "procure_method": "make_to_order", + } + ) + + self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "picking_id": pick_picking.id, + "location_id": wh.lot_stock_id.id, + "location_dest_id": wh.wh_output_stock_loc_id.id, + "move_dest_ids": [(4, dest.id)], + "state": "confirmed", + } + ) + return pick_picking, customer_picking + + def _update_product_qty_in_location(self, location, product, quantity): + self.env["stock.quant"]._update_available_quantity(product, location, quantity) + + def assert_src_stock(self, record): + self.assertEqual(record.location_id, self.wh.lot_stock_id) + + def assert_src_handover(self, record): + self.assertEqual(record.location_id, self.location_handover) + + def assert_dest_handover(self, record): + self.assertEqual(record.location_dest_id, self.location_handover) + + def assert_src_shelf1(self, record): + self.assertEqual(record.location_id, self.location_shelf_1) + + def assert_dest_shelf1(self, record): + self.assertEqual(record.location_dest_id, self.location_shelf_1) + + def assert_src_highbay_1_2(self, record): + self.assertEqual(record.location_id, self.location_hb_1_2) + + def assert_dest_highbay_1_2(self, record): + self.assertEqual(record.location_destid, self.location_hb_1_2) + + def assert_src_output(self, record): + self.assertEqual(record.location_id, self.wh.wh_output_stock_loc_id) + + def assert_dest_output(self, record): + self.assertEqual(record.location_dest_id, self.wh.wh_output_stock_loc_id) + + def assert_dest_customer(self, record): + self.assertEqual(record.location_dest_id, self.customer_loc) + + def process_operations(self, move): + qty = move.move_line_ids.product_uom_qty + move.move_line_ids.qty_done = qty + move.picking_id.action_done() + + def test_change_location_to_routing_operation(self): + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + + ml = move_a.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_highbay_1_2(ml) + self.assert_dest_handover(ml) + + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) + + self.assert_src_stock(move_a) + self.assert_dest_handover(move_a) + # the move stays B stays on the same source location + self.assert_src_output(move_b) + self.assert_dest_customer(move_b) + + move_middle = move_a.move_dest_ids + # the routing move stays in the same source location than the original + # move: the move line will be in the sub-locations (handover) + + self.assert_src_stock(move_middle) + # Output + self.assert_dest_output(move_middle) + + self.assert_src_stock(move_a.picking_id) + self.assert_dest_handover(move_a.picking_id) + + self.assertEqual(move_a.state, "assigned") + self.assertEqual(move_middle.state, "waiting") + self.assertEqual(move_b.state, "waiting") + + # we deliver move A to check that our middle move line properly takes + # goods from the handover + self.process_operations(move_a) + self.assertEqual(move_a.state, "done") + self.assertEqual(move_middle.state, "assigned") + self.assertEqual(move_b.state, "waiting") + + move_line_middle = move_middle.move_line_ids + self.assertEqual(len(move_line_middle), 1) + self.assert_src_handover(move_line_middle) + self.assert_dest_output(move_line_middle) + + def test_several_moves(self): + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10), (self.product2, 10)] + ) + product1 = self.product1 + product2 = self.product2 + # in Highbay → should generate a new operation in Highbay picking type + self._update_product_qty_in_location(self.location_hb_1_2, self.product1, 20) + # another product in a shelf, no additional operation for this one + self._update_product_qty_in_location(self.location_shelf_1, self.product2, 20) + pick_moves = pick_picking.move_lines + move_a_p1 = pick_moves.filtered(lambda r: r.product_id == product1) + move_a_p2 = pick_moves.filtered(lambda r: r.product_id == product2) + cust_moves = customer_picking.move_lines + move_b_p1 = cust_moves.filtered(lambda r: r.product_id == product1) + move_b_p2 = cust_moves.filtered(lambda r: r.product_id == product2) + + pick_picking.action_assign() + + # At this point, we should have 3 stock.picking: + # + # +-------------------------------------------------------------------+ + # | HO/xxxx Assigned | + # | Stock → Stock/Handover | + # | Product1 Highbay/Bay1/Bin1 → Stock/Handover (available) move_a_p1 | + # +-------------------------------------------------------------------+ + # + # +------------------------------------------------------------------+ + # | PICK/xxxx Waiting | + # | Stock → Output | + # | Product1 Stock/Handover → Output (waiting) move_middle (added) | + # | Product2 Stock/Shelf1 → Output (available) move_a_p2 | + # +------------------------------------------------------------------+ + # + # +------------------------------------------------+ + # | OUT/xxxx Waiting | + # | Output → Customer | + # | Product1 Output → Customer (waiting) move_b_p1 | + # | Product2 Output → Customer (waiting) move_b_p2 | + # +------------------------------------------------+ + + move_middle = move_a_p1.move_dest_ids + self.assertEqual(len(move_middle), 1) + + self.assertFalse(move_a_p1.move_orig_ids) + self.assertEqual(move_middle.move_dest_ids, move_b_p1) + self.assertFalse(move_a_p2.move_orig_ids) + self.assertEqual(move_a_p2.move_dest_ids, move_b_p2) + + ml = move_a_p1.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_highbay_1_2(ml) + self.assert_dest_handover(ml) + # this is a new HO picking + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) + self.assertEqual(ml.state, "assigned") + + # this one stays in the PICK/ + ml = move_a_p2.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_shelf1(ml) + self.assert_dest_output(ml) + self.assertEqual(ml.picking_id.picking_type_id, self.wh.pick_type_id) + self.assertEqual(ml.state, "assigned") + + # the move stays B stays on the same source location + self.assert_src_output(move_b_p1) + self.assert_dest_customer(move_b_p1) + self.assertEqual(move_b_p1.state, "waiting") + self.assert_src_output(move_b_p2) + self.assert_dest_customer(move_b_p2) + self.assertEqual(move_b_p2.state, "waiting") + + # Check middle move + # Stock + self.assert_src_stock(move_middle) + # Output + self.assert_dest_output(move_middle) + self.assert_src_stock(move_middle.picking_id) + # we deliver move A to check that our middle move line properly takes + # goods from the handover + self.process_operations(move_a_p1) + + self.assertEqual(move_a_p1.state, "done") + self.assertEqual(move_a_p2.state, "assigned") + self.assertEqual(move_middle.state, "assigned") + self.assertEqual(move_b_p1.state, "waiting") + self.assertEqual(move_b_p2.state, "waiting") + + move_line_middle = move_middle.move_line_ids + self.assertEqual(len(move_line_middle), 1) + # Handover + self.assert_src_handover(move_line_middle) + # Output + self.assert_dest_output(move_line_middle) + + self.process_operations(move_middle) + self.process_operations(move_a_p2) + + self.assertEqual(move_a_p1.state, "done") + self.assertEqual(move_a_p2.state, "done") + self.assertEqual(move_middle.state, "done") + self.assertEqual(move_b_p1.state, "assigned") + self.assertEqual(move_b_p2.state, "assigned") + + self.process_operations(move_b_p1) + self.process_operations(move_b_p2) + + self.assertEqual(move_a_p1.state, "done") + self.assertEqual(move_a_p2.state, "done") + self.assertEqual(move_middle.state, "done") + self.assertEqual(move_b_p1.state, "done") + self.assertEqual(move_b_p2.state, "done") + + def test_several_move_lines(self): + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + # in Highbay → should generate a new operation in Highbay picking type + self._update_product_qty_in_location(self.location_hb_1_2, move_a.product_id, 6) + # same product in a shelf, we should have a second move line directly + # picked from the shelf without additional operation for the Highbay + self._update_product_qty_in_location( + self.location_shelf_1, move_a.product_id, 4 + ) + + pick_picking.action_assign() + # it splits the stock move to be able to chain the quantities from + # the Highbay + self.assertEqual(len(pick_picking.move_lines), 2) + move_a1 = pick_picking.move_lines.filtered( + lambda move: move.product_uom_qty == 4 + ) + move_a2 = pick_picking.move_lines.filtered( + lambda move: move.product_uom_qty == 6 + ) + move_ho = move_a2.move_orig_ids + self.assertTrue(move_ho) + + # At this point, we should have 3 stock.picking: + # + # +-------------------------------------------------------------------+ + # | HO/xxxx Assigned | + # | Stock → Stock/Handover | + # | 6x Product Highbay/Bay1/Bin1 → Stock/Handover (available) move_ho | + # +-------------------------------------------------------------------+ + # + # +-------------------------------------------------------------------+ + # | PICK/xxxx Waiting | + # | Stock → Output | + # | 6x Product Stock/Handover → Output (waiting) move_a2 (split) | + # | 4x Product Stock/Shelf1 → Output (available) move_a1 | + # +-------------------------------------------------------------------+ + # + # +-------------------------------------------------+ + # | OUT/xxxx Waiting | + # | Output → Customer | + # | 10x Product Output → Customer (waiting) move_b | + # +-------------------------------------------------+ + + self.assertFalse(move_a1.move_orig_ids) + self.assertEqual(move_ho.move_dest_ids, move_a2) + + ml = move_a1.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_shelf1(ml) + self.assert_dest_output(ml) + self.assertEqual(ml.picking_id.picking_type_id, self.wh.pick_type_id) + self.assertEqual(ml.state, "assigned") + + ml = move_ho.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_highbay_1_2(ml) + self.assert_dest_handover(ml) + # this is a new HO picking + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) + self.assertEqual(ml.state, "assigned") + + # the split move is waiting for 'move_ho' + self.assertEqual(len(ml), 1) + self.assert_src_stock(move_a2) + self.assert_dest_output(move_a2) + self.assertEqual(move_a2.picking_id.picking_type_id, self.wh.pick_type_id) + self.assertEqual(move_a2.state, "waiting") + + # the move stays B stays identical + self.assert_src_output(move_b) + self.assert_dest_customer(move_b) + self.assertEqual(move_b.state, "waiting") + + # we deliver HO picking to check that our middle move line properly + # takes goods from the handover + self.process_operations(move_ho) + + self.assertEqual(move_ho.state, "done") + self.assertEqual(move_a1.state, "assigned") + self.assertEqual(move_a2.state, "assigned") + self.assertEqual(move_b.state, "waiting") + + self.process_operations(move_a1) + self.process_operations(move_a2) + + self.assertEqual(move_ho.state, "done") + self.assertEqual(move_a1.state, "done") + self.assertEqual(move_a2.state, "done") + self.assertEqual(move_b.state, "assigned") + + self.process_operations(move_b) + self.assertEqual(move_ho.state, "done") + self.assertEqual(move_a1.state, "done") + self.assertEqual(move_a2.state, "done") + self.assertEqual(move_b.state, "done") diff --git a/stock_routing_operation/views/stock_location_views.xml b/stock_routing_operation/views/stock_location_views.xml new file mode 100644 index 0000000000..e64fbe58e6 --- /dev/null +++ b/stock_routing_operation/views/stock_location_views.xml @@ -0,0 +1,14 @@ + + + + stock.location.form.inherit + stock.location + + + + + + + + + From f112cbf4b5d56c6886dd89d5036e275aa85e8e44 Mon Sep 17 00:00:00 2001 From: Florian da Costa Date: Fri, 13 Dec 2019 15:32:49 +0100 Subject: [PATCH 02/33] Allow to bypass the routing operation for a move --- stock_routing_operation/models/stock_move.py | 29 +++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index fa0df884b6..a32e9b882d 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -1,6 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) from itertools import chain + from odoo import models @@ -21,6 +22,14 @@ def _apply_dest_move_routing_operation(self): dest_moves = self._split_per_dest_routing_operation() dest_moves._apply_move_location_dest_routing_operation() + def _bypass_routing_operation_application(self, routing_type): + """ Override this method if you need to by pass the routing operation + logic for moves related characteristic. + """ + if routing_type not in ("src", "dest"): + raise ValueError("routing_type must be one of ('src', 'dest')") + return False + def _split_per_src_routing_operation(self): """Split moves per source routing operations @@ -35,7 +44,10 @@ def _split_per_src_routing_operation(self): move_to_assign_ids = set() new_move_per_location = {} for move in self: - if move.state not in ("assigned", "partially_available"): + if move.state not in ( + "assigned", + "partially_available", + ) or move._bypass_routing_operation_application("src"): continue # Group move lines per source location, some may need an additional @@ -110,7 +122,10 @@ def _apply_move_location_src_routing_operation(self): after it. """ for move in self: - if move.state not in ("assigned", "partially_available"): + if move.state not in ( + "assigned", + "partially_available", + ) or move._bypass_routing_operation_application("src"): continue # Group move lines per source location, some may need an additional @@ -204,7 +219,10 @@ def _split_per_dest_routing_operation(self): """ new_moves = self.browse() for move in self: - if move.state not in ("assigned", "partially_available"): + if move.state not in ( + "assigned", + "partially_available", + ) or move._bypass_routing_operation_application("dest"): continue # Group move lines per destination location, some may need an @@ -260,7 +278,10 @@ def _apply_move_location_dest_routing_operation(self): after it. """ for move in self: - if move.state not in ("assigned", "partially_available"): + if move.state not in ( + "assigned", + "partially_available", + ) or move._bypass_routing_operation_application("dest"): continue # Group move lines per source location, some may need an additional From a53ec1ee1bf47086d6883995727bcdeed7a0dd5d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 11 Mar 2020 14:31:46 +0100 Subject: [PATCH 03/33] Add comment --- stock_routing_operation/models/stock_move.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index a32e9b882d..7a56bea96c 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -9,6 +9,8 @@ class StockMove(models.Model): _inherit = "stock.move" def _action_assign(self): + # TODO use savepoint on the original assign instead of using + # unreserve super()._action_assign() if not self.env.context.get("exclude_apply_routing_operation"): self._apply_src_move_routing_operation() @@ -23,7 +25,7 @@ def _apply_dest_move_routing_operation(self): dest_moves._apply_move_location_dest_routing_operation() def _bypass_routing_operation_application(self, routing_type): - """ Override this method if you need to by pass the routing operation + """ Override this method if you need to bypass the routing operation logic for moves related characteristic. """ if routing_type not in ("src", "dest"): From 415259fd2e5214023763669d3bf2af6549382fed Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 3 Apr 2020 09:30:10 +0200 Subject: [PATCH 04/33] Change rules for source routing * if the move's dest location is a child of the routing's dest location: it's more precise so change only the picking type * if the move's dest location is a parent of the routing's dest location: change the dest location to the routing's dest location and change the picking type * if the move's dest location is outside of the routing's dest location: add a routing operation before It means, when there is a routing, even if the location was already correct, the picking type is changed so users handle transfers the same way. The same changes will be done on the destination routing --- stock_routing_operation/models/__init__.py | 1 + stock_routing_operation/models/stock_move.py | 67 ++++++---- .../models/stock_picking.py | 30 +++++ .../tests/test_routing_operation_src.py | 116 ++++++++++++++++++ 4 files changed, 190 insertions(+), 24 deletions(-) create mode 100644 stock_routing_operation/models/stock_picking.py diff --git a/stock_routing_operation/models/__init__.py b/stock_routing_operation/models/__init__.py index 200ba08be0..6a2905b271 100644 --- a/stock_routing_operation/models/__init__.py +++ b/stock_routing_operation/models/__init__.py @@ -1,4 +1,5 @@ from . import stock_location from . import stock_move +from . import stock_picking from . import stock_picking_type from . import stock_quant diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 7a56bea96c..7377631a5a 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -1,4 +1,4 @@ -# Copyright 2019 Camptocamp (https://www.camptocamp.com) +# Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) from itertools import chain @@ -123,6 +123,7 @@ def _apply_move_location_src_routing_operation(self): type with the routing operation ones and creates a new chained move after it. """ + pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: if move.state not in ( "assigned", @@ -138,21 +139,12 @@ def _apply_move_location_src_routing_operation(self): # locations, they have been split in # _split_per_routing_operation(), so we can take the first one source = move.move_line_ids[0].location_id - destination = move.move_line_ids[0].location_dest_id - # we have to add a move as destination - # we have to add move as origin + original_destination = move.move_line_ids[0].location_dest_id routing = source._find_picking_type_for_routing("src") if not routing: continue - if self.env["stock.location"].search( - [ - ("id", "=", routing.default_location_dest_id.id), - ("id", "parent_of", move.location_dest_id.id), - ] - ): - # we don't need to do anything because we already go through - # the expected destination + if move.picking_id.picking_type_id == routing: continue # the current move becomes the routing move, and we'll add a new @@ -161,23 +153,50 @@ def _apply_move_location_src_routing_operation(self): # lines go to the correct destination move._do_unreserve() move.package_level_id.unlink() - dest = routing.default_location_dest_id + current_picking_type = move.picking_id.picking_type_id - move.write({"location_dest_id": dest.id, "picking_type_id": routing.id}) - move._insert_routing_moves( - current_picking_type, move.location_id, destination - ) + if self.env["stock.location"].search( + [ + ("id", "=", routing.default_location_dest_id.id), + ("id", "child_of", move.location_dest_id.id), + ] + ): + # The destination of the move, as a parent of the destination + # of the routing, goes to the correct place, but is not precise + # enough: set the new destination to match the picking type + move.location_dest_id = routing.default_location_dest_id + move.picking_type_id = routing - picking = move.picking_id - move._assign_picking() - if not picking.move_lines: - # When the picking type changes, it will create a new picking - # for the move. If the previous picking has no other move, - # we have to drop it. - picking.unlink() + elif self.env["stock.location"].search( + [ + ("id", "=", routing.default_location_dest_id.id), + ("id", "parent_of", move.location_dest_id.id), + ] + ): + # The destination of the move is already more precise than the + # expected destination of the routing: keep it, but we still + # want to change the picking type + move.picking_type_id = routing + else: + # The destination of the move is unrelated (nor identical, nor + # a parent or a child) to the routing destination: in this case + # we have to add a routing move before the current move to + # route the goods in the routing + move.location_dest_id = routing.default_location_dest_id + move.picking_type_id = routing + # create a copy of the move with the current picking type and + # going to its original destination: it will be assigned to the + # same picking as the original picking of our move + move._insert_routing_moves( + current_picking_type, move.location_id, original_destination + ) + pickings_to_check_for_emptiness |= move.picking_id + move._assign_picking() move._action_assign() + pickings_to_check_for_emptiness._routing_operation_handle_empty() + def _insert_routing_moves(self, picking_type, location, destination): """Create a chained move for the source routing operation""" self.ensure_one() diff --git a/stock_routing_operation/models/stock_picking.py b/stock_routing_operation/models/stock_picking.py new file mode 100644 index 0000000000..fc901cef76 --- /dev/null +++ b/stock_routing_operation/models/stock_picking.py @@ -0,0 +1,30 @@ +# Copyright 2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + canceled_by_routing = fields.Boolean( + default=False, + help="Technical field. Indicates the transfer is" + " canceled because it was left empty after a routing operation.", + ) + + @api.depends("canceled_by_routing") + def _compute_state(self): + super()._compute_state() + for picking in self: + if picking.canceled_by_routing: + picking.state = "cancel" + + def _routing_operation_handle_empty(self): + """Handle pickings emptied during a routing operation""" + for picking in self: + if not picking.move_lines: + # When the picking type changes, it will create a new picking + # for the move. As the picking is now empty, it's useless. + # We could drop it but it can make code crash later in the + # transaction. This flag will set the picking as cancel. + picking.canceled_by_routing = True diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_operation_src.py index 4c1cb1f7f0..2e43e5f1e1 100644 --- a/stock_routing_operation/tests/test_routing_operation_src.py +++ b/stock_routing_operation/tests/test_routing_operation_src.py @@ -421,3 +421,119 @@ def test_several_move_lines(self): self.assertEqual(move_a1.state, "done") self.assertEqual(move_a2.state, "done") self.assertEqual(move_b.state, "done") + + def test_destination_parent_tree_change_picking_type_and_dest(self): + # Change the picking type destination so the move goes to a location + # which is a parent destination of the routing destination (move will + # go to Output, routing destination is Output/Area1). + # With this configuration, the move already goes to the correct place, + # so we don't need to add a chained move before it. However, since the + # location of the picking type was more precise, we have to change it. + # Also, we expect the move to be "re-classified" in a picking of the + # routing's picking type. + area1 = self.env["stock.location"].create( + {"location_id": self.wh.wh_output_stock_loc_id.id, "name": "Area1"} + ) + self.pick_type_routing_op.default_location_dest_id = area1 + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + + ml = move_a.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_highbay_1_2(ml) + self.assertEqual(ml.location_dest_id, area1) + + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) + + self.assert_src_stock(move_a) + self.assertEqual(move_a.location_dest_id, area1) + # the move stays B stays on the same source location + self.assert_src_output(move_b) + self.assert_dest_customer(move_b) + + # the original chaining stays the same: we don't add any move here + self.assertFalse(move_a.move_orig_ids) + self.assertEqual(move_a.move_dest_ids, move_b) + self.assertFalse(move_b.move_dest_ids) + + self.assert_src_stock(move_a.picking_id) + self.assertEqual(move_a.picking_id.location_dest_id, area1) + + self.assertEqual(move_a.state, "assigned") + self.assertEqual(move_b.state, "waiting") + + # we deliver move A to check that our move B properly takes + # goods from the area1 + self.process_operations(move_a) + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b.state, "assigned") + self.assertEqual(move_b.move_line_ids.location_id, area1) + + def test_destination_child_tree_change_picking_type(self): + # Change the picking type destination so the move goes to a location + # which is a child destination of the routing destination (move will + # go to Above Output/Area1, routing destination is Above Output). + # With this configuration, the move already goes to the correct place, + # so we don't need to add a chained move before it. And as the location + # in more precise, we have to keep it. + # We expect the move to be "re-classified" in a picking of the + # routing's picking type. + above_output = self.env["stock.location"].create( + { + "location_id": self.wh.wh_output_stock_loc_id.location_id.id, + "name": "Above Output", + } + ) + self.wh.wh_output_stock_loc_id.location_id = above_output + self.pick_type_routing_op.default_location_dest_id = above_output + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + + ml = move_a.move_line_ids + self.assertEqual(len(ml), 1) + self.assert_src_highbay_1_2(ml) + self.assert_dest_output(ml) + + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) + + self.assert_src_stock(move_a) + self.assert_dest_output(move_a) + # the move stays B stays on the same source location + self.assert_src_output(move_b) + self.assert_dest_customer(move_b) + + # the original chaining stays the same: we don't add any move here + self.assertFalse(move_a.move_orig_ids) + self.assertEqual(move_a.move_dest_ids, move_b) + self.assertFalse(move_b.move_dest_ids) + + self.assert_src_stock(move_a.picking_id) + self.assert_dest_output(move_a.picking_id) + + self.assertEqual(move_a.state, "assigned") + self.assertEqual(move_b.state, "waiting") + + # we deliver move A to check that our move B properly takes + # goods from the output + self.process_operations(move_a) + self.assertEqual(move_a.state, "done") + self.assertEqual(move_b.state, "assigned") + self.assert_src_output(move_b.move_line_ids) From 7bb9d49f1e555db4bcf6db52680e16f188eeee43 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 3 Apr 2020 12:33:15 +0200 Subject: [PATCH 05/33] Add domain to exclude moves from source routing We can probably optimize it by appling the domain only once for all the moves of a routing. The same thing should be applied on destination routing. --- stock_routing_operation/__manifest__.py | 2 +- .../models/stock_location.py | 1 + stock_routing_operation/models/stock_move.py | 59 +++++++++++-------- .../models/stock_picking_type.py | 7 +++ .../tests/test_routing_operation_src.py | 46 +++++++++++++++ .../views/stock_picking_type_views.xml | 19 ++++++ 6 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 stock_routing_operation/views/stock_picking_type_views.xml diff --git a/stock_routing_operation/__manifest__.py b/stock_routing_operation/__manifest__.py index 772ee588b6..def7bfba58 100644 --- a/stock_routing_operation/__manifest__.py +++ b/stock_routing_operation/__manifest__.py @@ -9,7 +9,7 @@ "license": "AGPL-3", "depends": ["stock"], "demo": ["demo/stock_location_demo.xml", "demo/stock_picking_type_demo.xml"], - "data": ["views/stock_location_views.xml"], + "data": ["views/stock_location_views.xml", "views/stock_picking_type_views.xml"], "installable": True, "development_status": "Alpha", } diff --git a/stock_routing_operation/models/stock_location.py b/stock_routing_operation/models/stock_location.py index 6043a0b913..9541ddbbe3 100644 --- a/stock_routing_operation/models/stock_location.py +++ b/stock_routing_operation/models/stock_location.py @@ -7,6 +7,7 @@ class StockLocation(models.Model): _inherit = "stock.location" + # NOTE: these fields will be moved to dedicated models src_routing_picking_type_id = fields.Many2one( "stock.picking.type", string="Source Routing Operation", diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 7377631a5a..c038201a62 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -3,6 +3,8 @@ from itertools import chain from odoo import models +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval class StockMove(models.Model): @@ -24,20 +26,28 @@ def _apply_dest_move_routing_operation(self): dest_moves = self._split_per_dest_routing_operation() dest_moves._apply_move_location_dest_routing_operation() - def _bypass_routing_operation_application(self, routing_type): - """ Override this method if you need to bypass the routing operation - logic for moves related characteristic. - """ - if routing_type not in ("src", "dest"): - raise ValueError("routing_type must be one of ('src', 'dest')") - return False + def _src_routing_apply_domain(self, routing): + if not routing.src_routing_move_domain: + return self + domain = safe_eval(routing.src_routing_move_domain) + return self._eval_routing_domain(domain) + + def _eval_routing_domain(self, domain): + move_domain = [("id", "in", self.ids)] + # 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 + # _src_routing_apply_domain to add your own logic (based on SQL, ...). + return self.env["stock.move"].search(expression.AND([move_domain, domain])) def _split_per_src_routing_operation(self): """Split moves per source routing operations When a move has move lines with different routing operations or lines with routing operations and lines without, on the source location, this - method split the move in as many source routing operations they have. + method splits the move in as many source routing operations they have. The reason: the destination location of the moves with a routing operation will change and their "move_dest_ids" will be modified to @@ -46,10 +56,7 @@ def _split_per_src_routing_operation(self): move_to_assign_ids = set() new_move_per_location = {} for move in self: - if move.state not in ( - "assigned", - "partially_available", - ) or move._bypass_routing_operation_application("src"): + if move.state not in ("assigned", "partially_available"): continue # Group move lines per source location, some may need an additional @@ -65,7 +72,12 @@ def _split_per_src_routing_operation(self): # where we have to take products routing_quantities = {} for source, qty in move_lines.items(): + # TODO consider to use the domain directly in the method that + # find the routing routing_picking_type = source._find_picking_type_for_routing("src") + if not move._src_routing_apply_domain(routing_picking_type): + # reset to "no routing" + routing_picking_type = self.env["stock.picking.type"].browse() routing_quantities.setdefault(routing_picking_type, 0.0) routing_quantities[routing_picking_type] += qty @@ -125,10 +137,7 @@ def _apply_move_location_src_routing_operation(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: - if move.state not in ( - "assigned", - "partially_available", - ) or move._bypass_routing_operation_application("src"): + if move.state not in ("assigned", "partially_available"): continue # Group move lines per source location, some may need an additional @@ -139,14 +148,18 @@ def _apply_move_location_src_routing_operation(self): # locations, they have been split in # _split_per_routing_operation(), so we can take the first one source = move.move_line_ids[0].location_id - original_destination = move.move_line_ids[0].location_dest_id routing = source._find_picking_type_for_routing("src") - if not routing: + # TODO we might optimize this by calling it once for a routing + # and a group of moves + if not routing or not move._src_routing_apply_domain(routing): continue if move.picking_id.picking_type_id == routing: + # already correct continue + original_destination = move.move_line_ids[0].location_dest_id + # the current move becomes the routing move, and we'll add a new # move after this one to pick the goods where the routing moved # them, we have to unreserve and assign at the end to have the move @@ -240,10 +253,7 @@ def _split_per_dest_routing_operation(self): """ new_moves = self.browse() for move in self: - if move.state not in ( - "assigned", - "partially_available", - ) or move._bypass_routing_operation_application("dest"): + if move.state not in ("assigned", "partially_available"): continue # Group move lines per destination location, some may need an @@ -299,10 +309,7 @@ def _apply_move_location_dest_routing_operation(self): after it. """ for move in self: - if move.state not in ( - "assigned", - "partially_available", - ) or move._bypass_routing_operation_application("dest"): + if move.state not in ("assigned", "partially_available"): continue # Group move lines per source location, some may need an additional diff --git a/stock_routing_operation/models/stock_picking_type.py b/stock_routing_operation/models/stock_picking_type.py index ad5465931f..39b65358f1 100644 --- a/stock_routing_operation/models/stock_picking_type.py +++ b/stock_routing_operation/models/stock_picking_type.py @@ -6,9 +6,16 @@ class StockPickingType(models.Model): _inherit = "stock.picking.type" + # NOTE: these fields will be moved to dedicated models src_routing_location_ids = fields.One2many( "stock.location", "src_routing_picking_type_id" ) + src_routing_move_domain = fields.Char( + string="Source Routing Domain", + default=[], + help="Domain based on Stock Moves, to define if the " + "source routing is applicable or not.", + ) dest_routing_location_ids = fields.One2many( "stock.location", "dest_routing_picking_type_id" ) diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_operation_src.py index 2e43e5f1e1..591e607429 100644 --- a/stock_routing_operation/tests/test_routing_operation_src.py +++ b/stock_routing_operation/tests/test_routing_operation_src.py @@ -537,3 +537,49 @@ def test_destination_child_tree_change_picking_type(self): self.assertEqual(move_a.state, "done") self.assertEqual(move_b.state, "assigned") self.assert_src_output(move_b.move_line_ids) + + def test_domain_ignore_move(self): + # define a domain that will exclude the routing for this + # move, there will not be any change on the moves compared + # to a standard setup + domain = "[('product_id', '=', {})]".format(self.product2.id) + self.pick_type_routing_op.src_routing_move_domain = domain + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + + self.assertEqual( + move_a.move_line_ids.picking_id.picking_type_id, self.wh.pick_type_id + ) + # the original chaining stays the same: we don't add any move here + self.assertFalse(move_a.move_orig_ids) + self.assertEqual(move_a.move_dest_ids, move_b) + self.assertFalse(move_b.move_dest_ids) + + def test_domain_include_move(self): + # define a domain that will include the routing for this + # move, so routing is applied + domain = "[('product_id', '=', {})]".format(self.product1.id) + self.pick_type_routing_op.src_routing_move_domain = domain + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + + self.assertEqual( + move_a.move_line_ids.picking_id.picking_type_id, self.pick_type_routing_op + ) + self.assertFalse(move_a.move_orig_ids) + self.assertNotEqual(move_a.move_dest_ids, move_b) + self.assertFalse(move_b.move_dest_ids) diff --git a/stock_routing_operation/views/stock_picking_type_views.xml b/stock_routing_operation/views/stock_picking_type_views.xml new file mode 100644 index 0000000000..1f1d342eaf --- /dev/null +++ b/stock_routing_operation/views/stock_picking_type_views.xml @@ -0,0 +1,19 @@ + + + + + Operation Types + stock.picking.type + + + + + + + + + From 2458e218e37c1a2f8599f97bb25d6f8d04055a60 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 6 Apr 2020 11:16:31 +0200 Subject: [PATCH 06/33] Use as savepoint to cancel _action_assign when we have to apply a routing instead of using unreserve: any side-effect will be cancelled before doing the routing and calling assign again, thus we avoid leaving things behind. --- stock_routing_operation/models/stock_move.py | 81 +++++++++++++++---- .../tests/test_routing_operation_dest.py | 3 +- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index c038201a62..bc818e809d 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -1,28 +1,50 @@ # Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import uuid from itertools import chain +from psycopg2 import sql + from odoo import models from odoo.osv import expression from odoo.tools.safe_eval import safe_eval +# TODO check product_qty / product_uom_qty + class StockMove(models.Model): _inherit = "stock.move" def _action_assign(self): - # TODO use savepoint on the original assign instead of using - # unreserve - super()._action_assign() - if not self.env.context.get("exclude_apply_routing_operation"): + if self.env.context.get("exclude_apply_routing_operation"): + super()._action_assign() + else: + # these methods will call _action_assign in a savepoint + # and modify the routing if necessary self._apply_src_move_routing_operation() self._apply_dest_move_routing_operation() def _apply_src_move_routing_operation(self): + """Apply source routing operations + + * calls super()._action_assign() on moves not yet available + * split the moves if their move lines have different source locations + * apply the routing + + Important: if you inherit this method to skip the routing for some + moves, you have to call super()._action_assign() on them + """ src_moves = self._split_per_src_routing_operation() src_moves._apply_move_location_src_routing_operation() def _apply_dest_move_routing_operation(self): + """Apply destination routing operations + + * at this point, _action_assign should have been called by + ``_apply_src_move_routing_operation`` + * split the moves if their move lines have different destination locations + * apply the routing + """ dest_moves = self._split_per_dest_routing_operation() dest_moves._apply_move_location_dest_routing_operation() @@ -53,8 +75,20 @@ def _split_per_src_routing_operation(self): operation will change and their "move_dest_ids" will be modified to target a new move for the routing operation. """ - move_to_assign_ids = set() + if not self: + return self + new_move_per_location = {} + + savepoint_name = uuid.uuid1().hex + self.env["base"].flush() + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + super()._action_assign() + + moves_with_routing = {} for move in self: if move.state not in ("assigned", "partially_available"): continue @@ -81,15 +115,30 @@ def _split_per_src_routing_operation(self): routing_quantities.setdefault(routing_picking_type, 0.0) routing_quantities[routing_picking_type] += qty - if len(routing_quantities) == 1: + if len(routing_quantities) > 1: # The whole quantity can be taken from only one location (an # empty routing picking type being equal to one location here), # nothing to split. - continue + moves_with_routing[move] = routing_quantities + + if not moves_with_routing: + # no split needed, so the reservations done by _action_assign + # are valid + self.env["base"].flush() + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + return self - move._do_unreserve() - move.package_level_id.unlink() - move_to_assign_ids.add(move.id) + # rollack _action_assign, it'll be called again after the splits + self.env.clear() + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("ROLLBACK TO SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + + for move, routing_quantities in moves_with_routing.items(): for picking_type, qty in routing_quantities.items(): # When picking_type is empty, it means we have no routing # operation for the move, so we have nothing to do. @@ -106,7 +155,8 @@ def _split_per_src_routing_operation(self): new_move_per_location.setdefault(routing_location.id, []) new_move_per_location[routing_location.id].append(new_move_id) - # it is important to assign the routed moves first + # it is important to assign the routed moves first so they take the + # quantities in the expected locations (same locations as the splits) for location_id, new_move_ids in new_move_per_location.items(): new_moves = self.browse(new_move_ids) new_moves.with_context( @@ -120,9 +170,7 @@ def _split_per_src_routing_operation(self): )._action_assign() # reassign the moves which have been unreserved for the split - moves_to_assign = self.browse(move_to_assign_ids) - if moves_to_assign: - moves_to_assign._action_assign() + super()._action_assign() new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) return self + new_moves @@ -250,6 +298,10 @@ def _split_per_dest_routing_operation(self): The reason: the destination location of the moves with a routing operation will change and their "move_dest_ids" will be modified to target a new move for the routing operation. + + We don't need to cancel the reservation as done in + ``_split_per_src_routing_operation`` because the source location + doesn't change. """ new_moves = self.browse() for move in self: @@ -291,6 +343,7 @@ def _split_per_dest_routing_operation(self): new_move_id = move._split(qty) new_move = self.browse(new_move_id) move_lines.write({"move_id": new_move.id}) + move_lines.modified(["product_uom_qty"]) assert move.state in ("assigned", "partially_available") # We know the new move is 'assigned' because we created it # with the quantity matching the move lines that we move into diff --git a/stock_routing_operation/tests/test_routing_operation_dest.py b/stock_routing_operation/tests/test_routing_operation_dest.py index be7c62dc09..24b248b19d 100644 --- a/stock_routing_operation/tests/test_routing_operation_dest.py +++ b/stock_routing_operation/tests/test_routing_operation_dest.py @@ -401,6 +401,8 @@ def test_several_move_lines(self): {"product_uom_qty": 4.0, "location_dest_id": self.location_shelf_1.id} ) move_b.move_line_ids.invalidate_cache(["product_uom_qty", "location_dest_id"]) + # assign moves ignoring the routing, then apply it manually + move_b.with_context(exclude_apply_routing_operation=True)._action_assign() # At this point, we should have this # # +-----------------------------------------------------+ @@ -416,7 +418,6 @@ def test_several_move_lines(self): # | 6x Product1 Input → Stock/HB-1-2 (available) | # | 4x Product1 Input → Stock/Shelf1 (available) | # +--------------------------------------------------------+ - move_b._apply_dest_move_routing_operation() # We expect the routing operation to split the move_b so From 6cdc6d5a1d0f1374c77bf8fb2b944df88f9522ce Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 6 Apr 2020 14:00:13 +0200 Subject: [PATCH 07/33] Delete package level of move lines when unreserving The relation from the move doesn't always exist, if we delete the package level before the unreserve, they are properly deleted. --- stock_routing_operation/models/stock_move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index bc818e809d..2bdf597d1c 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -212,8 +212,8 @@ def _apply_move_location_src_routing_operation(self): # move after this one to pick the goods where the routing moved # them, we have to unreserve and assign at the end to have the move # lines go to the correct destination + move.mapped("move_line_ids.package_level_id").unlink() move._do_unreserve() - move.package_level_id.unlink() current_picking_type = move.picking_id.picking_type_id if self.env["stock.location"].search( From f5e6db8b4de633e1a5e4f970487c344dfa343563 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Apr 2020 11:51:31 +0200 Subject: [PATCH 08/33] Rework using dedicated models Rules can be ordered and excluded by domains. Store the rule chosen for a move to avoid doing it twice. --- stock_routing_operation/__manifest__.py | 12 +- .../demo/stock_picking_type_demo.xml | 6 - .../demo/stock_routing_demo.xml | 20 ++ stock_routing_operation/models/__init__.py | 3 +- .../models/stock_location.py | 102 ++------ stock_routing_operation/models/stock_move.py | 234 ++++++++---------- .../models/stock_picking_type.py | 78 ------ .../models/stock_routing.py | 86 +++++++ .../models/stock_routing_rule.py | 84 +++++++ stock_routing_operation/readme/CONFIGURE.rst | 13 +- .../readme/DESCRIPTION.rst | 16 +- stock_routing_operation/readme/USAGE.rst | 8 +- .../security/ir.model.access.csv | 5 + .../tests/test_routing_operation_dest.py | 21 +- .../tests/test_routing_operation_src.py | 20 +- .../views/stock_location_views.xml | 1 - .../views/stock_picking_type_views.xml | 19 -- .../views/stock_routing_views.xml | 97 ++++++++ 18 files changed, 487 insertions(+), 338 deletions(-) create mode 100644 stock_routing_operation/demo/stock_routing_demo.xml delete mode 100644 stock_routing_operation/models/stock_picking_type.py create mode 100644 stock_routing_operation/models/stock_routing.py create mode 100644 stock_routing_operation/models/stock_routing_rule.py create mode 100644 stock_routing_operation/security/ir.model.access.csv delete mode 100644 stock_routing_operation/views/stock_picking_type_views.xml create mode 100644 stock_routing_operation/views/stock_routing_views.xml diff --git a/stock_routing_operation/__manifest__.py b/stock_routing_operation/__manifest__.py index def7bfba58..f4a0408055 100644 --- a/stock_routing_operation/__manifest__.py +++ b/stock_routing_operation/__manifest__.py @@ -8,8 +8,16 @@ "version": "13.0.1.0.0", "license": "AGPL-3", "depends": ["stock"], - "demo": ["demo/stock_location_demo.xml", "demo/stock_picking_type_demo.xml"], - "data": ["views/stock_location_views.xml", "views/stock_picking_type_views.xml"], + "demo": [ + "demo/stock_location_demo.xml", + "demo/stock_picking_type_demo.xml", + "demo/stock_routing_demo.xml", + ], + "data": [ + "views/stock_location_views.xml", + "views/stock_routing_views.xml", + "security/ir.model.access.csv", + ], "installable": True, "development_status": "Alpha", } diff --git a/stock_routing_operation/demo/stock_picking_type_demo.xml b/stock_routing_operation/demo/stock_picking_type_demo.xml index 070887472a..934426dc51 100644 --- a/stock_routing_operation/demo/stock_picking_type_demo.xml +++ b/stock_routing_operation/demo/stock_picking_type_demo.xml @@ -23,10 +23,4 @@ - - - - - - diff --git a/stock_routing_operation/demo/stock_routing_demo.xml b/stock_routing_operation/demo/stock_routing_demo.xml new file mode 100644 index 0000000000..7e12ff7ea1 --- /dev/null +++ b/stock_routing_operation/demo/stock_routing_demo.xml @@ -0,0 +1,20 @@ + + + + + + + + + + pull + + + + + + push + + + + diff --git a/stock_routing_operation/models/__init__.py b/stock_routing_operation/models/__init__.py index 6a2905b271..e68dc48510 100644 --- a/stock_routing_operation/models/__init__.py +++ b/stock_routing_operation/models/__init__.py @@ -1,5 +1,6 @@ from . import stock_location from . import stock_move from . import stock_picking -from . import stock_picking_type from . import stock_quant +from . import stock_routing +from . import stock_routing_rule diff --git a/stock_routing_operation/models/stock_location.py b/stock_routing_operation/models/stock_location.py index 9541ddbbe3..cf85bf5a63 100644 --- a/stock_routing_operation/models/stock_location.py +++ b/stock_routing_operation/models/stock_location.py @@ -1,95 +1,33 @@ # Copyright 2019 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError +from odoo import api, models, tools class StockLocation(models.Model): _inherit = "stock.location" - # NOTE: these fields will be moved to dedicated models - src_routing_picking_type_id = fields.Many2one( - "stock.picking.type", - string="Source Routing Operation", - help="Change destination of the move line according to the" - " default destination of the picking type after reservation" - " occurs, when the source of the move is in this location" - " (including sub-locations). A new chained move will be created " - " to reach the original destination.", - ) - dest_routing_picking_type_id = fields.Many2one( - "stock.picking.type", - string="Destination Routing Operation", - help="Change source of the move line according to the" - " default source of the picking type after reservation" - " occurs, when the destination of the move is in this location" - " (including sub-locations). A new chained move will be created " - " to reach the original source.", - ) - - @api.constrains("src_routing_picking_type_id") - def _check_src_routing_picking_type_id(self): - for location in self: - picking_type = location.src_routing_picking_type_id - if not picking_type: - continue - if picking_type.default_location_src_id != location: - raise ValidationError( - _( - "A picking type for source routing operations cannot have" - " a different default source location than the location it" - " is used on." - ) - ) - - @api.constrains("dest_routing_picking_type_id") - def _check_dest_routing_picking_type_id(self): - for location in self: - picking_type = location.dest_routing_picking_type_id - if not picking_type: - continue - if picking_type.default_location_dest_id != location: - raise ValidationError( - _( - "A picking type for destination routing operations " - "cannot have a different default destination location" - " than the location it is used on." - ) - ) - - def _find_picking_type_for_routing(self, routing_type): - if routing_type not in ("src", "dest"): - raise ValueError("routing_type must be one of ('src', 'dest')") + @tools.ormcache("self.id") + def _location_parent_tree(self): self.ensure_one() - # First select all the parent locations and the matching - # picking types. In a second step, the picking type matching the - # closest location - # is searched in memory. This is to avoid doing an SQL query - # for each location in the tree. tree = self.search( [("id", "parent_of", self.id)], # the recordset will be ordered bottom location to top location order="parent_path desc", ) - if routing_type == "src": - routing_fieldname = "src_routing_location_ids" - default_location_fieldname = "default_location_src_id" - else: # dest - routing_fieldname = "dest_routing_location_ids" - default_location_fieldname = "default_location_dest_id" - domain = [ - (routing_fieldname, "!=", False), - (default_location_fieldname, "in", tree.ids), - ] - picking_types = self.env["stock.picking.type"].search(domain) - # the first location is the current move line's source location, - # then we climb up the tree of locations - for location in tree: - match = picking_types.filtered( - lambda p: p[default_location_fieldname] == location - ) - if match: - # we can only have one match as we have a unique - # constraint on is_zone + source (or dest) location - return match - return self.env["stock.picking.type"] + return tree + + @api.model_create_multi + def create(self, vals_list): + locations = super().create(vals_list) + self._location_parent_tree.clear_cache(self) + return locations + + def write(self, values): + res = super().write(values) + self._location_parent_tree.clear_cache(self) + return res + + def unlink(self): + res = super().unlink() + self._location_parent_tree.clear_cache(self) + return res diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 2bdf597d1c..398ba6b8a1 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -5,9 +5,7 @@ from psycopg2 import sql -from odoo import models -from odoo.osv import expression -from odoo.tools.safe_eval import safe_eval +from odoo import fields, models # TODO check product_qty / product_uom_qty @@ -15,16 +13,29 @@ class StockMove(models.Model): _inherit = "stock.move" + pull_routing_rule_id = fields.Many2one( + comodel_name="stock.routing.rule", + copy=False, + help="Technical field. Store the routing pull rule that has been" + " selected for the move.", + ) + push_routing_rule_id = fields.Many2one( + comodel_name="stock.routing.rule", + copy=False, + help="Technical field. Store the routing push rule that has been" + " selected for the move.", + ) + def _action_assign(self): if self.env.context.get("exclude_apply_routing_operation"): super()._action_assign() else: # these methods will call _action_assign in a savepoint # and modify the routing if necessary - self._apply_src_move_routing_operation() - self._apply_dest_move_routing_operation() + self._split_and_apply_routing_pull() + self._split_and_apply_routing_push() - def _apply_src_move_routing_operation(self): + def _split_and_apply_routing_pull(self): """Apply source routing operations * calls super()._action_assign() on moves not yet available @@ -34,37 +45,21 @@ def _apply_src_move_routing_operation(self): Important: if you inherit this method to skip the routing for some moves, you have to call super()._action_assign() on them """ - src_moves = self._split_per_src_routing_operation() - src_moves._apply_move_location_src_routing_operation() + src_moves = self._split_and_set_rule_for_routing_pull() + src_moves._apply_routing_rule_pull() - def _apply_dest_move_routing_operation(self): + def _split_and_apply_routing_push(self): """Apply destination routing operations * at this point, _action_assign should have been called by - ``_apply_src_move_routing_operation`` + ``_split_and_apply_routing_pull`` * split the moves if their move lines have different destination locations * apply the routing """ - dest_moves = self._split_per_dest_routing_operation() - dest_moves._apply_move_location_dest_routing_operation() + dest_moves = self._split_and_set_rule_for_routing_push() + dest_moves._apply_routing_rule_push() - def _src_routing_apply_domain(self, routing): - if not routing.src_routing_move_domain: - return self - domain = safe_eval(routing.src_routing_move_domain) - return self._eval_routing_domain(domain) - - def _eval_routing_domain(self, domain): - move_domain = [("id", "in", self.ids)] - # 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 - # _src_routing_apply_domain to add your own logic (based on SQL, ...). - return self.env["stock.move"].search(expression.AND([move_domain, domain])) - - def _split_per_src_routing_operation(self): + def _split_and_set_rule_for_routing_pull(self): """Split moves per source routing operations When a move has move lines with different routing operations or lines @@ -74,6 +69,9 @@ def _split_per_src_routing_operation(self): The reason: the destination location of the moves with a routing operation will change and their "move_dest_ids" will be modified to target a new move for the routing operation. + + This method writes "pull_routing_rule_id" on the moves, this rule will be + used by ``_apply_routing_rule_pull`` """ if not self: return self @@ -88,40 +86,35 @@ def _split_per_src_routing_operation(self): ) super()._action_assign() + move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( + "pull", self + ) moves_with_routing = {} + need_split = False for move in self: if move.state not in ("assigned", "partially_available"): continue - # Group move lines per source location, some may need an additional + # Group move lines per their rule, some may need an additional # operations while others not. Store the number of products to # take from each location, so we'll be able to split the move # if needed. - move_lines = {} - for move_line in move.move_line_ids: - location = move_line.location_id - move_lines[location] = sum(move_line.mapped("product_uom_qty")) - - # We'll split the move to have one move per different location - # where we have to take products - routing_quantities = {} - for source, qty in move_lines.items(): - # TODO consider to use the domain directly in the method that - # find the routing - routing_picking_type = source._find_picking_type_for_routing("src") - if not move._src_routing_apply_domain(routing_picking_type): - # reset to "no routing" - routing_picking_type = self.env["stock.picking.type"].browse() - routing_quantities.setdefault(routing_picking_type, 0.0) - routing_quantities[routing_picking_type] += qty - - if len(routing_quantities) > 1: - # The whole quantity can be taken from only one location (an - # empty routing picking type being equal to one location here), - # nothing to split. - moves_with_routing[move] = routing_quantities - - if not moves_with_routing: + move_routing = move_routing_rules[move] + # FIXME split partial quantities when there is a rule + moves_with_routing[move] = { + rule: sum(move_lines.mapped("product_uom_qty")) + for rule, move_lines in move_routing.items() + } + + if any(len(rules) > 1 for rules in moves_with_routing.values()): + need_split = True + else: + # shortcut, we can directly save the rule as we do not need + # to split + for move, rule_quantities in moves_with_routing.items(): + move.pull_routing_rule_id = next(iter(rule_quantities)) + + if not need_split: # no split needed, so the reservations done by _action_assign # are valid self.env["base"].flush() @@ -131,7 +124,7 @@ def _split_per_src_routing_operation(self): ) return self - # rollack _action_assign, it'll be called again after the splits + # rollback _action_assign, it'll be called again after the splits self.env.clear() # pylint: disable=sql-injection self.env.cr.execute( @@ -139,21 +132,25 @@ def _split_per_src_routing_operation(self): ) for move, routing_quantities in moves_with_routing.items(): - for picking_type, qty in routing_quantities.items(): - # When picking_type is empty, it means we have no routing - # operation for the move, so we have nothing to do. - if picking_type: - routing_location = picking_type.default_location_src_id - # If we have a routing operation, the move may have several - # lines with different routing operations (or lines with - # a routing operation, lines without). We split the lines - # according to these. - # The _split() method returns the same move if the qty - # is the same than the move's qty, so we don't need to - # explicitly check if we really need to split or not. - new_move_id = move._split(qty) - new_move_per_location.setdefault(routing_location.id, []) - new_move_per_location[routing_location.id].append(new_move_id) + for routing_rule, qty in routing_quantities.items(): + # When the rule is empty, it means we have no routing + # operation for the move, so we have nothing to do, + # it will behave as normally. + if not routing_rule: + continue + routing_location = routing_rule.location_src_id + # If we have a routing operation, the move may have several + # lines with different routing operations (or lines with + # a routing operation, lines without). We split the lines + # according to these. + # The _split() method returns the same move if the qty + # is the same than the move's qty, so we don't need to + # explicitly check if we really need to split or not. + new_move_id = move._split(qty) + new_move = self.env["stock.move"].browse(new_move_id) + new_move.pull_routing_rule_id = routing_rule + new_move_per_location.setdefault(routing_location.id, []) + new_move_per_location[routing_location.id].append(new_move_id) # it is important to assign the routed moves first so they take the # quantities in the expected locations (same locations as the splits) @@ -174,7 +171,7 @@ def _split_per_src_routing_operation(self): new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) return self + new_moves - def _apply_move_location_src_routing_operation(self): + def _apply_routing_rule_pull(self): """Apply routing operations When a move has a routing operation configured on its location and the @@ -187,25 +184,15 @@ def _apply_move_location_src_routing_operation(self): for move in self: if move.state not in ("assigned", "partially_available"): continue - - # Group move lines per source location, some may need an additional - # operations while others not. Store the number of products to - # take from each location, so we'll be able to split the move - # if needed. - # At this point, we should not have lines with different source - # locations, they have been split in - # _split_per_routing_operation(), so we can take the first one - source = move.move_line_ids[0].location_id - routing = source._find_picking_type_for_routing("src") - # TODO we might optimize this by calling it once for a routing - # and a group of moves - if not routing or not move._src_routing_apply_domain(routing): + routing_rule = move.pull_routing_rule_id + if not routing_rule: continue - - if move.picking_id.picking_type_id == routing: + if move.picking_id.picking_type_id == routing_rule.picking_type_id: # already correct continue + # we expect all the lines to go to the same destination for + # pull routing rules original_destination = move.move_line_ids[0].location_dest_id # the current move becomes the routing move, and we'll add a new @@ -218,33 +205,33 @@ def _apply_move_location_src_routing_operation(self): current_picking_type = move.picking_id.picking_type_id if self.env["stock.location"].search( [ - ("id", "=", routing.default_location_dest_id.id), + ("id", "=", routing_rule.location_dest_id.id), ("id", "child_of", move.location_dest_id.id), ] ): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise - # enough: set the new destination to match the picking type - move.location_dest_id = routing.default_location_dest_id - move.picking_type_id = routing + # enough: set the new destination to match the rule's one + move.location_dest_id = routing_rule.location_dest_id + move.picking_type_id = routing_rule.picking_type_id elif self.env["stock.location"].search( [ - ("id", "=", routing.default_location_dest_id.id), + ("id", "=", routing_rule.location_dest_id.id), ("id", "parent_of", move.location_dest_id.id), ] ): # The destination of the move is already more precise than the # expected destination of the routing: keep it, but we still # want to change the picking type - move.picking_type_id = routing + move.picking_type_id = routing_rule.picking_type_id else: # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to # route the goods in the routing - move.location_dest_id = routing.default_location_dest_id - move.picking_type_id = routing + move.location_dest_id = routing_rule.location_dest_id + move.picking_type_id = routing_rule.picking_type_id # create a copy of the move with the current picking type and # going to its original destination: it will be assigned to the # same picking as the original picking of our move @@ -287,7 +274,7 @@ def _prepare_routing_move_values(self, picking_type, source, destination): "picking_type_id": picking_type.id, } - def _split_per_dest_routing_operation(self): + def _split_and_set_rule_for_routing_push(self): """Split moves per destination routing operations When a move has move lines with different routing operations or lines @@ -304,45 +291,38 @@ def _split_per_dest_routing_operation(self): doesn't change. """ new_moves = self.browse() + move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( + "push", self + ) for move in self: if move.state not in ("assigned", "partially_available"): continue - # Group move lines per destination location, some may need an - # additional operations while others not. Store the number of - # products to take from each location, so we'll be able to split - # the move if needed. - routing_move_lines = {} - routing_operations = {} - for move_line in move.move_line_ids: - dest = move_line.location_dest_id - if dest in routing_operations: - routing_picking_type = routing_operations[dest] - else: - routing_picking_type = dest._find_picking_type_for_routing("dest") - routing_move_lines.setdefault( - routing_picking_type, self.env["stock.move.line"].browse() - ) - routing_move_lines[routing_picking_type] |= move_line + routing_rules = move_routing_rules[move] - if len(routing_move_lines) == 1: + if len(routing_rules) == 1: # If we have no routing operation or only one routing # operation, we don't need to split the moves. We need to split # only if we have 2 different routing operations, or move # without routing operation and one(s) with routing operations. + rule = next(iter(routing_rules)) + if rule: + # but if we have a rule, store it to apply it later + move.push_routing_rule_id = rule continue - for picking_type, move_lines in routing_move_lines.items(): - if not picking_type: + for rule, move_lines in routing_rules.items(): + if not rule: # No routing operation is required for these moves, # continue to the next continue - # if we have a picking type, split returns the same move if + # if we have a routing rule, split returns the same move if # the qty is the same qty = sum(move_lines.mapped("product_uom_qty")) new_move_id = move._split(qty) new_move = self.browse(new_move_id) - move_lines.write({"move_id": new_move.id}) + move_lines.move_id = new_move + new_move.push_routing_rule_id = rule move_lines.modified(["product_uom_qty"]) assert move.state in ("assigned", "partially_available") # We know the new move is 'assigned' because we created it @@ -352,7 +332,7 @@ def _split_per_dest_routing_operation(self): return self + new_moves - def _apply_move_location_dest_routing_operation(self): + def _apply_routing_rule_push(self): """Apply routing operations When a move has a routing operation configured on its location and the @@ -373,13 +353,16 @@ def _apply_move_location_dest_routing_operation(self): # locations, they have been split in # _split_per_routing_operation(), so we can take the first one destination = move.move_line_ids[0].location_dest_id - picking_type = destination._find_picking_type_for_routing("dest") - if not picking_type: + routing_rule = move.push_routing_rule_id + if not routing_rule: + continue + if move.picking_id.picking_type_id == routing_rule.picking_type_id: + # already correct continue if self.env["stock.location"].search( [ - ("id", "=", picking_type.default_location_src_id.id), + ("id", "=", routing_rule.location_src_id.id), ("id", "parent_of", move.location_id.id), ] ): @@ -391,8 +374,11 @@ def _apply_move_location_dest_routing_operation(self): # Move the goods in the "routing" location instead. # In this use case, we want to keep the move lines so we don't # change the reservation. - move.write({"location_dest_id": picking_type.default_location_src_id.id}) + move.write({"location_dest_id": routing_rule.location_src_id.id}) move.move_line_ids.write( - {"location_dest_id": picking_type.default_location_src_id.id} + {"location_dest_id": routing_rule.location_src_id.id} + ) + + move._insert_routing_moves( + routing_rule.picking_type_id, routing_rule.location_src_id, destination ) - move._insert_routing_moves(picking_type, move.location_dest_id, destination) diff --git a/stock_routing_operation/models/stock_picking_type.py b/stock_routing_operation/models/stock_picking_type.py deleted file mode 100644 index 39b65358f1..0000000000 --- a/stock_routing_operation/models/stock_picking_type.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2019 Camptocamp (https://www.camptocamp.com) -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo import _, api, exceptions, fields, models - - -class StockPickingType(models.Model): - _inherit = "stock.picking.type" - - # NOTE: these fields will be moved to dedicated models - src_routing_location_ids = fields.One2many( - "stock.location", "src_routing_picking_type_id" - ) - src_routing_move_domain = fields.Char( - string="Source Routing Domain", - default=[], - help="Domain based on Stock Moves, to define if the " - "source routing is applicable or not.", - ) - dest_routing_location_ids = fields.One2many( - "stock.location", "dest_routing_picking_type_id" - ) - - def _check_routing_location_unique(self, routing_type): - if routing_type not in ("src", "dest"): - raise ValueError("routing_type must be one of ('src', 'dest')") - if routing_type == "src": - routing_location_fieldname = "src_routing_location_ids" - default_location_fieldname = "default_location_src_id" - message_fragment = _("source") - else: # dest - routing_location_fieldname = "dest_routing_location_ids" - default_location_fieldname = "default_location_dest_id" - message_fragment = _("destination") - for picking_type in self: - if not picking_type[routing_location_fieldname]: - continue - if len(picking_type[routing_location_fieldname]) > 1: - raise exceptions.ValidationError( - _( - "The same picking type cannot be used on different " - "locations having routing operations." - ) - ) - if ( - picking_type[routing_location_fieldname] - != picking_type[default_location_fieldname] - ): - raise exceptions.ValidationError( - _( - "A picking type for routing operations cannot have a" - " different default %s location than the location it " - "is used on." - ) - % (message_fragment,) - ) - default_location = picking_type[default_location_fieldname] - domain = [ - (routing_location_fieldname, "!=", False), - (default_location_fieldname, "=", default_location.id), - ("id", "!=", picking_type.id), - ] - other = self.search(domain) - if other: - raise exceptions.ValidationError( - _( - "Another routing operation picking type (%s) exists for" - " the same %s location." - ) - % (other.display_name, message_fragment) - ) - - @api.constrains("src_routing_location_ids", "default_location_src_id") - def _check_src_routing_location_unique(self): - self._check_routing_location_unique("src") - - @api.constrains("dest_routing_location_ids", "default_location_dest_id") - def _check_dest_routing_location_unique(self): - self._check_routing_location_unique("dest") diff --git a/stock_routing_operation/models/stock_routing.py b/stock_routing_operation/models/stock_routing.py new file mode 100644 index 0000000000..f39e8cba6e --- /dev/null +++ b/stock_routing_operation/models/stock_routing.py @@ -0,0 +1,86 @@ +# Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockRouting(models.Model): + _name = "stock.routing" + _description = "Stock Routing" + _order = "location_id" + + _rec_name = "location_id" + + location_id = fields.Many2one( + comodel_name="stock.location", + required=True, + unique=True, + ondelete="cascade", + index=True, + ) + active = fields.Boolean(default=True) + rule_ids = fields.One2many( + comodel_name="stock.routing.rule", inverse_name="routing_id" + ) + + _sql_constraints = [ + ( + "location_id_uniq", + "unique(location_id)", + "A routing configuration already exists for this location", + ) + ] + + def _routing_rule_for_moves(self, method, moves): + """Return a routing rule for moves + + :param method: pull (pick) or push (put-away) + :param move: recordset of the move + :return: dict {move: {rule: move_lines}} + """ + if method not in ("pull", "push"): + raise ValueError("routing_type must be one of ('pull', 'push')") + + result = {move: {} for move in moves} + valid_rules_for_move = set() + for move_line in moves.mapped("move_line_ids"): + if method == "pull": + location = move_line.location_id + else: + location = move_line.location_dest_id + location_tree = location._location_parent_tree() + candidate_routings = self.search([("location_id", "in", location_tree.ids)]) + + result.setdefault(move_line.move_id, []) + # the first location is the current move line's source or dest + # location, then we climb up the tree of locations + for loc in location_tree: + # and search the first allowed rule in the routing + routing = candidate_routings.filtered(lambda r: r.location_id == loc) + rules = routing.rule_ids.filtered(lambda r: r.method == method) + # find the first valid rule + found = False + for rule in rules: + if not ( + (move_line.move_id, rule) in valid_rules_for_move + or rule._is_valid_for_moves(move_line.move_id) + ): + continue + # memorize the result so we don't compute it for + # every move line + valid_rules_for_move.add((move_line.move_id, rule)) + if rule in result[move_line.move_id]: + result[move_line.move_id][rule] |= move_line + else: + result[move_line.move_id][rule] = move_line + found = True + break + if found: + break + else: + empty_rule = self.env["stock.routing.rule"].browse() + if empty_rule in result[move_line.move_id]: + result[move_line.move_id][empty_rule] |= move_line + else: + result[move_line.move_id][empty_rule] = move_line + return result diff --git a/stock_routing_operation/models/stock_routing_rule.py b/stock_routing_operation/models/stock_routing_rule.py new file mode 100644 index 0000000000..2b3454abb9 --- /dev/null +++ b/stock_routing_operation/models/stock_routing_rule.py @@ -0,0 +1,84 @@ +# Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import _, api, exceptions, fields, models +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + + +class StockRoutingRule(models.Model): + _name = "stock.routing.rule" + _description = "Stock Routing Rule" + _order = "sequence, id" + + sequence = fields.Integer(default=lambda self: self._default_sequence()) + routing_id = fields.Many2one( + comodel_name="stock.routing", required=True, ondelete="cascade" + ) + routing_location_id = fields.Many2one(related="routing_id.location_id") + method = fields.Selection( + selection=[("pull", "Pull"), ("push", "Push")], + help="On pull, the routing is applied when the source location of " + "a move line matches the source location of the rule. " + "On push, the routing is applied when the destination location of " + "a move line matches the destination location of the rule.", + ) + picking_type_id = fields.Many2one(comodel_name="stock.picking.type", required=True) + location_src_id = fields.Many2one( + related="picking_type_id.default_location_src_id", readonly=True + ) + location_dest_id = fields.Many2one( + related="picking_type_id.default_location_dest_id", readonly=True + ) + rule_domain = fields.Char( + string="Source Routing Domain", + default=[], + help="Domain based on Stock Moves, to define if the " + "routing rule is applicable or not.", + ) + + def _default_sequence(self): + maxrule = self.search([], order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 + + @api.constrains("picking_type_id") + def _constrains_picking_type_location(self): + for record in self: + base_location = record.routing_location_id + + if record.method == "pull" and record.location_src_id != base_location: + raise exceptions.ValidationError( + _( + "Operation type of a rule used as 'pull' must have '{}' as" + " source location." + ).format(base_location.display_name) + ) + elif record.method == "push" and record.location_dest_id != base_location: + + raise exceptions.ValidationError( + _( + "Operation type of a rule used as 'push' must have '{}' as" + " destination location." + ).format(base_location.display_name) + ) + + def _is_valid_for_moves(self, moves): + if not self.rule_domain: + return self + domain = safe_eval(self.rule_domain) + return self._eval_routing_domain(moves, domain) + + def _eval_routing_domain(self, moves, domain): + if not domain: + return self + move_domain = [("id", "in", moves.ids)] + # 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 + # _is_valid_for_moves to add your own logic (based on SQL, ...). + return self.env["stock.move"].search(expression.AND([move_domain, domain])) diff --git a/stock_routing_operation/readme/CONFIGURE.rst b/stock_routing_operation/readme/CONFIGURE.rst index 9e880ec0c5..e117eb4436 100644 --- a/stock_routing_operation/readme/CONFIGURE.rst +++ b/stock_routing_operation/readme/CONFIGURE.rst @@ -4,7 +4,12 @@ In Inventory Settings, you must have: * Multi-Warehouses * Multi-Step Routes -On stock location, create an "Routing operation" operation type. -The default destination location will be the destination location -of the new operation inserted when a move has a source location which -is a child of the location. +A new menu in Inventory Settings allow to create new routing rules: +"Stock Routing". + +Create a new routing for a location, then pull or push routing rules. +A pull rule is applied on moves with the same source location (or children). +A push rule is applied on moves with the same destination location (or children). + +Rules can exclude moves based on a domain. The order of the rules is important: +the first to match is used. diff --git a/stock_routing_operation/readme/DESCRIPTION.rst b/stock_routing_operation/readme/DESCRIPTION.rst index cdb9b16fdd..d68ae1e068 100644 --- a/stock_routing_operation/readme/DESCRIPTION.rst +++ b/stock_routing_operation/readme/DESCRIPTION.rst @@ -1,11 +1,10 @@ -Route explains the steps you want to produce whereas the “picking routing -operation” defines how operations are grouped according to their final source +Route explains the steps you want to produce whereas the “Routing Rules” defines how operations are grouped according to their final source and destination location. This allows for example: -* To parallelize picking operations in two locations of a warehouse, splitting - them in two different picking type +* To parallelize transfers in two locations of a warehouse, splitting + them in two different operation type * To define pre-picking (wave) in some sub-locations, then roundtrip picking of the sub-location waves @@ -24,9 +23,8 @@ usual Pick(Highbay)-Pack-Ship steps. If the good is picked from the High-Bay, yo need an extra operation: Pick(Highbay)-Handover-Pack-Ship. This is what this feature is doing: on the High-Bay location, you define -a "routing operation". A routing operation is based on a picking type. -The extra operation will have the selected picking type, and the new move -will have the source destination of the picking type. +a "routing rule". A routing rule selects a different operation type for the move. +The extra transfer will have the selected operation type, and be added before the chain of moves. When putting away: @@ -34,5 +32,5 @@ A put-away rule targets the High-Bay location. An operation Input-Highbay is created. You expect Input-Handover-Highbay. You can configure a routing operation for the put-away on the High-Bay Location. -The picking type of the new Handover move will the routing operation selected, -and its destination will be the destination of the picking type. +The operation type of the new Handover move will the one of the matching routing rule, +and its destination will be the destination of the operation type. diff --git a/stock_routing_operation/readme/USAGE.rst b/stock_routing_operation/readme/USAGE.rst index ea465cdec0..12e8e3eb41 100644 --- a/stock_routing_operation/readme/USAGE.rst +++ b/stock_routing_operation/readme/USAGE.rst @@ -16,19 +16,19 @@ The initial setup in the demo data contains locations: The "Highbay" location (and children) is configured to: -* create a source routing operation from Highbay to Handover when +* create a pull routing transfer from Highbay to Handover when goods are taken from Highbay (using a new picking type Highbay → Handover) -* create a destination routing operation from Handover to Highbay when +* create a push routing transfer from Handover to Highbay when goods are put to Highbay (using a new picking type Handover → Highbay) -Steps to try the Source Routing Operation: +Steps to try the Pull Routing Transfer: * In the main Warehouse, configure outgoing shipments to "Send goods in output and then deliver (2 steps)" * Inventory a product, for instance "[FURN_8999] Three-Seat Sofa", add 50 items in "WH/Stock/Highbay/Bay A/Bin 1", and nowhere else * Create a sales order with 5 "[FURN_8999] Three-Seat Sofa", confirm * You'll have 3 transfers; a new one has been created dynamically for Highbay -> Handover. -Steps to try the Destination Routing Operation: +Steps to try the Push Routing Transfer: * In the "WH/Stock" location, create a Put-Away Strategy with: diff --git a/stock_routing_operation/security/ir.model.access.csv b/stock_routing_operation/security/ir.model.access.csv new file mode 100644 index 0000000000..4e259c2be2 --- /dev/null +++ b/stock_routing_operation/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_routing_stock_user,access_stock_routing stock user,model_stock_routing,stock.group_stock_user,1,0,0,0 +access_stock_routing_manager,access_stock_routing stock manager,model_stock_routing,stock.group_stock_manager,1,1,1,1 +access_stock_routing_rule_stock_user,access_stock_routing_rule stock user,model_stock_routing_rule,stock.group_stock_user,1,0,0,0 +access_stock_routing_rule_manager,access_stock_routing_rule stock manager,model_stock_routing_rule,stock.group_stock_manager,1,1,1,1 diff --git a/stock_routing_operation/tests/test_routing_operation_dest.py b/stock_routing_operation/tests/test_routing_operation_dest.py index 24b248b19d..b894acaa49 100644 --- a/stock_routing_operation/tests/test_routing_operation_dest.py +++ b/stock_routing_operation/tests/test_routing_operation_dest.py @@ -57,8 +57,20 @@ def setUpClass(cls): "default_location_dest_id": cls.location_hb.id, } ) - cls.location_hb.write( - {"dest_routing_picking_type_id": cls.pick_type_routing_op.id} + cls.routing = cls.env["stock.routing"].create( + { + "location_id": cls.location_hb.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "push", + "picking_type_id": cls.pick_type_routing_op.id, + }, + ) + ], + } ) def _create_supplier_input_highbay(self, wh, products=None): @@ -387,7 +399,7 @@ def test_several_move_lines(self): # need an additional move, the one in the shelf not. # In order to simulate this, we'll manually change the move lines of - # move_b and call'_apply_dest_move_routing_operation()' on it to force + # move_b and call '_split_and_apply_routing_push' on it to force # the application of the routing operation. first_ml = move_b.move_line_ids @@ -403,6 +415,7 @@ def test_several_move_lines(self): move_b.move_line_ids.invalidate_cache(["product_uom_qty", "location_dest_id"]) # assign moves ignoring the routing, then apply it manually move_b.with_context(exclude_apply_routing_operation=True)._action_assign() + # At this point, we should have this # # +-----------------------------------------------------+ @@ -418,7 +431,7 @@ def test_several_move_lines(self): # | 6x Product1 Input → Stock/HB-1-2 (available) | # | 4x Product1 Input → Stock/Shelf1 (available) | # +--------------------------------------------------------+ - move_b._apply_dest_move_routing_operation() + move_b._split_and_apply_routing_push() # We expect the routing operation to split the move_b so # we'll be able to have a move_dest_ids for the Highbay: diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_operation_src.py index 591e607429..d7cc3a7094 100644 --- a/stock_routing_operation/tests/test_routing_operation_src.py +++ b/stock_routing_operation/tests/test_routing_operation_src.py @@ -57,8 +57,20 @@ def setUpClass(cls): "default_location_dest_id": cls.location_handover.id, } ) - cls.location_hb.write( - {"src_routing_picking_type_id": cls.pick_type_routing_op.id} + cls.routing = cls.env["stock.routing"].create( + { + "location_id": cls.location_hb.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "pull", + "picking_type_id": cls.pick_type_routing_op.id, + }, + ) + ], + } ) def _create_pick_ship(self, wh, products=None): @@ -543,7 +555,7 @@ def test_domain_ignore_move(self): # move, there will not be any change on the moves compared # to a standard setup domain = "[('product_id', '=', {})]".format(self.product2.id) - self.pick_type_routing_op.src_routing_move_domain = domain + self.routing.rule_ids.rule_domain = domain pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] ) @@ -566,7 +578,7 @@ def test_domain_include_move(self): # define a domain that will include the routing for this # move, so routing is applied domain = "[('product_id', '=', {})]".format(self.product1.id) - self.pick_type_routing_op.src_routing_move_domain = domain + self.routing.rule_ids.rule_domain = domain pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] ) diff --git a/stock_routing_operation/views/stock_location_views.xml b/stock_routing_operation/views/stock_location_views.xml index e64fbe58e6..5622304d07 100644 --- a/stock_routing_operation/views/stock_location_views.xml +++ b/stock_routing_operation/views/stock_location_views.xml @@ -6,7 +6,6 @@ - diff --git a/stock_routing_operation/views/stock_picking_type_views.xml b/stock_routing_operation/views/stock_picking_type_views.xml deleted file mode 100644 index 1f1d342eaf..0000000000 --- a/stock_routing_operation/views/stock_picking_type_views.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Operation Types - stock.picking.type - - - - - - - - - diff --git a/stock_routing_operation/views/stock_routing_views.xml b/stock_routing_operation/views/stock_routing_views.xml new file mode 100644 index 0000000000..9ad7b8b71e --- /dev/null +++ b/stock_routing_operation/views/stock_routing_views.xml @@ -0,0 +1,97 @@ + + + + stock.routing.form + stock.routing + +
+
+
+ +
+ + stock.routing.search + stock.routing + + + + + + + + + + stock.routing + stock.routing + + + + + + + + Stock Routing + stock.routing + ir.actions.act_window + + + + +

+ Add a Stock Routing +

+
+
+ +
From 5694ef31b126c4a690a77316e83c545b9a4c2b0c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Apr 2020 11:55:31 +0200 Subject: [PATCH 09/33] pre-commit: run new xml prettier --- .../demo/stock_location_demo.xml | 45 +++++++++---------- .../demo/stock_picking_type_demo.xml | 45 +++++++++---------- .../demo/stock_routing_demo.xml | 32 ++++++++----- .../views/stock_location_views.xml | 8 +++- 4 files changed, 68 insertions(+), 62 deletions(-) diff --git a/stock_routing_operation/demo/stock_location_demo.xml b/stock_routing_operation/demo/stock_location_demo.xml index d29639c5d3..889b520860 100644 --- a/stock_routing_operation/demo/stock_location_demo.xml +++ b/stock_routing_operation/demo/stock_location_demo.xml @@ -1,26 +1,23 @@ - + - - - Highbay - - - - Bay A - - - - Bin 1 - - - - Bin 2 - - - - - Handover - - - + + Highbay + + + + Bay A + + + + Bin 1 + + + + Bin 2 + + + + Handover + + diff --git a/stock_routing_operation/demo/stock_picking_type_demo.xml b/stock_routing_operation/demo/stock_picking_type_demo.xml index 934426dc51..9ffdb99af2 100644 --- a/stock_routing_operation/demo/stock_picking_type_demo.xml +++ b/stock_routing_operation/demo/stock_picking_type_demo.xml @@ -1,26 +1,23 @@ - + - - - Highbay → Handover - internal - HBHO - - - - - - - - - Handover → Highbay - internal - HOHB - - - - - - - + + Highbay → Handover + internal + HBHO + + + + + + + + Handover → Highbay + internal + HOHB + + + + + + diff --git a/stock_routing_operation/demo/stock_routing_demo.xml b/stock_routing_operation/demo/stock_routing_demo.xml index 7e12ff7ea1..8709edf443 100644 --- a/stock_routing_operation/demo/stock_routing_demo.xml +++ b/stock_routing_operation/demo/stock_routing_demo.xml @@ -1,20 +1,28 @@ - + - - + - - - + + pull - + - - - + + push - + - diff --git a/stock_routing_operation/views/stock_location_views.xml b/stock_routing_operation/views/stock_location_views.xml index 5622304d07..6cfcb2675a 100644 --- a/stock_routing_operation/views/stock_location_views.xml +++ b/stock_routing_operation/views/stock_location_views.xml @@ -1,4 +1,4 @@ - + stock.location.form.inherit @@ -6,7 +6,11 @@ - + From 25422032a79ddb5996960a033d50010bb2384140 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 8 Apr 2020 08:27:34 +0200 Subject: [PATCH 10/33] Fix support of partial reservation on pull routing rules A split must occur to handle the routing only on the available quantity. --- stock_routing_operation/models/stock_move.py | 32 +++++++----- .../tests/test_routing_operation_src.py | 49 ++++++++++++++++--- 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 398ba6b8a1..cd3f17baa2 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -76,8 +76,6 @@ def _split_and_set_rule_for_routing_pull(self): if not self: return self - new_move_per_location = {} - savepoint_name = uuid.uuid1().hex self.env["base"].flush() # pylint: disable=sql-injection @@ -89,7 +87,8 @@ def _split_and_set_rule_for_routing_pull(self): move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( "pull", self ) - moves_with_routing = {} + moves_routing = {} + no_routing_rule = self.env["stock.routing.rule"].browse() need_split = False for move in self: if move.state not in ("assigned", "partially_available"): @@ -99,19 +98,25 @@ def _split_and_set_rule_for_routing_pull(self): # operations while others not. Store the number of products to # take from each location, so we'll be able to split the move # if needed. - move_routing = move_routing_rules[move] - # FIXME split partial quantities when there is a rule - moves_with_routing[move] = { + routing_rules = move_routing_rules[move] + moves_routing[move] = { rule: sum(move_lines.mapped("product_uom_qty")) - for rule, move_lines in move_routing.items() + for rule, move_lines in routing_rules.items() } + if move.state == "partially_available": + # consider unreserved quantity as without routing, so it will + # be split if another part of the quantity need a routing + moves_routing[move].setdefault(no_routing_rule, 0) + moves_routing[move][no_routing_rule] += ( + move.product_uom_qty - move.reserved_availability + ) - if any(len(rules) > 1 for rules in moves_with_routing.values()): + if any(len(rules) > 1 for rules in moves_routing.values()): need_split = True else: # shortcut, we can directly save the rule as we do not need # to split - for move, rule_quantities in moves_with_routing.items(): + for move, rule_quantities in moves_routing.items(): move.pull_routing_rule_id = next(iter(rule_quantities)) if not need_split: @@ -131,7 +136,8 @@ def _split_and_set_rule_for_routing_pull(self): sql.SQL("ROLLBACK TO SAVEPOINT {}").format(sql.Identifier(savepoint_name)) ) - for move, routing_quantities in moves_with_routing.items(): + new_move_per_location = {} + for move, routing_quantities in moves_routing.items(): for routing_rule, qty in routing_quantities.items(): # When the rule is empty, it means we have no routing # operation for the move, so we have nothing to do, @@ -212,6 +218,7 @@ def _apply_routing_rule_pull(self): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one + move.location_id = routing_rule.location_src_id move.location_dest_id = routing_rule.location_dest_id move.picking_type_id = routing_rule.picking_type_id @@ -224,19 +231,22 @@ def _apply_routing_rule_pull(self): # The destination of the move is already more precise than the # expected destination of the routing: keep it, but we still # want to change the picking type + move.location_id = routing_rule.location_src_id move.picking_type_id = routing_rule.picking_type_id else: # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to # route the goods in the routing + source_location = move.location_id + move.location_id = routing_rule.location_src_id move.location_dest_id = routing_rule.location_dest_id move.picking_type_id = routing_rule.picking_type_id # create a copy of the move with the current picking type and # going to its original destination: it will be assigned to the # same picking as the original picking of our move move._insert_routing_moves( - current_picking_type, move.location_id, original_destination + current_picking_type, source_location, original_destination ) pickings_to_check_for_emptiness |= move.picking_id diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_operation_src.py index d7cc3a7094..9028dc6ac0 100644 --- a/stock_routing_operation/tests/test_routing_operation_src.py +++ b/stock_routing_operation/tests/test_routing_operation_src.py @@ -147,6 +147,9 @@ def assert_src_shelf1(self, record): def assert_dest_shelf1(self, record): self.assertEqual(record.location_dest_id, self.location_shelf_1) + def assert_src_highbay(self, record): + self.assertEqual(record.location_id, self.location_hb) + def assert_src_highbay_1_2(self, record): self.assertEqual(record.location_id, self.location_hb_1_2) @@ -186,7 +189,7 @@ def test_change_location_to_routing_operation(self): self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) - self.assert_src_stock(move_a) + self.assert_src_highbay(move_a) self.assert_dest_handover(move_a) # the move stays B stays on the same source location self.assert_src_output(move_b) @@ -200,7 +203,7 @@ def test_change_location_to_routing_operation(self): # Output self.assert_dest_output(move_middle) - self.assert_src_stock(move_a.picking_id) + self.assert_src_highbay(move_a.picking_id) self.assert_dest_handover(move_a.picking_id) self.assertEqual(move_a.state, "assigned") @@ -466,7 +469,7 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) - self.assert_src_stock(move_a) + self.assert_src_highbay(move_a) self.assertEqual(move_a.location_dest_id, area1) # the move stays B stays on the same source location self.assert_src_output(move_b) @@ -477,7 +480,7 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.assertEqual(move_a.move_dest_ids, move_b) self.assertFalse(move_b.move_dest_ids) - self.assert_src_stock(move_a.picking_id) + self.assert_src_highbay(move_a.picking_id) self.assertEqual(move_a.picking_id.location_dest_id, area1) self.assertEqual(move_a.state, "assigned") @@ -526,7 +529,7 @@ def test_destination_child_tree_change_picking_type(self): self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_routing_op) - self.assert_src_stock(move_a) + self.assert_src_highbay(move_a) self.assert_dest_output(move_a) # the move stays B stays on the same source location self.assert_src_output(move_b) @@ -537,7 +540,7 @@ def test_destination_child_tree_change_picking_type(self): self.assertEqual(move_a.move_dest_ids, move_b) self.assertFalse(move_b.move_dest_ids) - self.assert_src_stock(move_a.picking_id) + self.assert_src_highbay(move_a.picking_id) self.assert_dest_output(move_a.picking_id) self.assertEqual(move_a.state, "assigned") @@ -595,3 +598,37 @@ def test_domain_include_move(self): self.assertFalse(move_a.move_orig_ids) self.assertNotEqual(move_a.move_dest_ids, move_b) self.assertFalse(move_b.move_dest_ids) + + def test_partial_qty(self): + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + self._update_product_qty_in_location(self.location_hb_1_2, move_a.product_id, 8) + pick_picking.action_assign() + + # move_a should remain in the PICK with an unreserved qty of 2 + self.assertEqual(move_a.picking_id, pick_picking) + self.assertEqual(move_a.product_qty, 2) + self.assertEqual(move_a.state, "confirmed") + + # we have a new waiting move in the PICK with a qty of 8 + split_move = move_a.move_dest_ids.move_orig_ids - move_a + self.assertEqual(split_move.picking_id, pick_picking) + self.assertEqual(split_move.product_qty, 8) + self.assertEqual(split_move.state, "waiting") + + # we have a new move for the routing before the split move + routing_move = split_move.move_orig_ids + self.assertRecordValues( + routing_move, + [ + { + "picking_type_id": self.pick_type_routing_op.id, + "product_qty": 8, + "state": "assigned", + } + ], + ) + self.assert_src_highbay(routing_move) + self.assert_dest_handover(routing_move) From 33c6363edbb98d9da6e5264fe5f00c94f1a0dea6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 8 Apr 2020 09:44:08 +0200 Subject: [PATCH 11/33] Refactor pull to avoid assign+unreserve between split and routing --- stock_routing_operation/models/stock_move.py | 118 +++++++++---------- 1 file changed, 56 insertions(+), 62 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index cd3f17baa2..b8fec678cc 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -45,8 +45,14 @@ def _split_and_apply_routing_pull(self): Important: if you inherit this method to skip the routing for some moves, you have to call super()._action_assign() on them """ - src_moves = self._split_and_set_rule_for_routing_pull() - src_moves._apply_routing_rule_pull() + moves_routing = self._prepare_routing_pull() + # apply the routing + if moves_routing: + moves = self._routing_pull_do_splits(moves_routing) + moves._apply_routing_rule_pull() + super(StockMove, moves)._action_assign() + else: + super()._action_assign() def _split_and_apply_routing_push(self): """Apply destination routing operations @@ -59,19 +65,20 @@ def _split_and_apply_routing_push(self): dest_moves = self._split_and_set_rule_for_routing_push() dest_moves._apply_routing_rule_push() - def _split_and_set_rule_for_routing_pull(self): - """Split moves per source routing operations + def _prepare_routing_pull(self): + """Prepare pull routing rules for moves When a move has move lines with different routing operations or lines - with routing operations and lines without, on the source location, this - method splits the move in as many source routing operations they have. + with routing operations and lines without, on the source location, we have + to split the moves. This method assigns the moves in a savepoint to compute + the routing rules according to the source location of the move lines. - The reason: the destination location of the moves with a routing - operation will change and their "move_dest_ids" will be modified to - target a new move for the routing operation. + If no routing is applied, the savepoint is released. + If routing must be applied on at least one move, the savepoint is + rollbacked. - This method writes "pull_routing_rule_id" on the moves, this rule will be - used by ``_apply_routing_rule_pull`` + Return the computed routing rules for the next step, which will be + to split the moves. """ if not self: return self @@ -84,12 +91,38 @@ def _split_and_set_rule_for_routing_pull(self): ) super()._action_assign() + moves_routing = self._routing_pull_get_rules() + + if not any(rule for routing in moves_routing.values() for rule in routing): + # no routing to apply, so the reservations done by _action_assign + # are valid and we can resolve to a normal flow + self.env["base"].flush() + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + return {} + + # rollback _action_assign, it'll be called again after the routing + self.env.clear() + # pylint: disable=sql-injection + self.env.cr.execute( + sql.SQL("ROLLBACK TO SAVEPOINT {}").format(sql.Identifier(savepoint_name)) + ) + return moves_routing + + def _routing_pull_get_rules(self): + """Compute routing pull rules + + Called in a savepoint (_prepare_routing_pull). + Return a dictionary {move: {rule: reserved quantity}}. The rule for a quantity + can be an empty recordset, which means no routing rule. + """ move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( "pull", self ) moves_routing = {} no_routing_rule = self.env["stock.routing.rule"].browse() - need_split = False for move in self: if move.state not in ("assigned", "partially_available"): continue @@ -110,32 +143,16 @@ def _split_and_set_rule_for_routing_pull(self): moves_routing[move][no_routing_rule] += ( move.product_uom_qty - move.reserved_availability ) + return moves_routing - if any(len(rules) > 1 for rules in moves_routing.values()): - need_split = True - else: - # shortcut, we can directly save the rule as we do not need - # to split - for move, rule_quantities in moves_routing.items(): - move.pull_routing_rule_id = next(iter(rule_quantities)) - - if not need_split: - # no split needed, so the reservations done by _action_assign - # are valid - self.env["base"].flush() - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("RELEASE SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) - return self + def _routing_pull_do_splits(self, moves_routing): + """Split moves according to routing rules - # rollback _action_assign, it'll be called again after the splits - self.env.clear() - # pylint: disable=sql-injection - self.env.cr.execute( - sql.SQL("ROLLBACK TO SAVEPOINT {}").format(sql.Identifier(savepoint_name)) - ) + This method splits the move in as many routing pull rules they have. + This method writes "pull_routing_rule_id" on the moves, this rule will be + used by ``_apply_routing_rule_pull`` + """ new_move_per_location = {} for move, routing_quantities in moves_routing.items(): for routing_rule, qty in routing_quantities.items(): @@ -158,22 +175,6 @@ def _split_and_set_rule_for_routing_pull(self): new_move_per_location.setdefault(routing_location.id, []) new_move_per_location[routing_location.id].append(new_move_id) - # it is important to assign the routed moves first so they take the - # quantities in the expected locations (same locations as the splits) - for location_id, new_move_ids in new_move_per_location.items(): - new_moves = self.browse(new_move_ids) - new_moves.with_context( - # Prevent to call _apply_move_location_routing_operation, will - # be called when all lines are processed. - exclude_apply_routing_operation=True, - # Force reservation of quants in the location they were - # reserved in at the origin (so we keep the same quantities - # at the same places) - gather_in_location_id=location_id, - )._action_assign() - - # reassign the moves which have been unreserved for the split - super()._action_assign() new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) return self + new_moves @@ -188,25 +189,18 @@ def _apply_routing_rule_pull(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: - if move.state not in ("assigned", "partially_available"): - continue routing_rule = move.pull_routing_rule_id if not routing_rule: continue + if move.picking_id.picking_type_id == routing_rule.picking_type_id: # already correct continue # we expect all the lines to go to the same destination for # pull routing rules - original_destination = move.move_line_ids[0].location_dest_id - - # the current move becomes the routing move, and we'll add a new - # move after this one to pick the goods where the routing moved - # them, we have to unreserve and assign at the end to have the move - # lines go to the correct destination - move.mapped("move_line_ids.package_level_id").unlink() - move._do_unreserve() + # original_destination = move.move_line_ids[0].location_dest_id + original_destination = move.location_dest_id current_picking_type = move.picking_id.picking_type_id if self.env["stock.location"].search( @@ -237,7 +231,7 @@ def _apply_routing_rule_pull(self): # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to - # route the goods in the routing + # route the goods in the correct place source_location = move.location_id move.location_id = routing_rule.location_src_id move.location_dest_id = routing_rule.location_dest_id From 633a5078770d192d61e175c0039aee19aa757035 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 8 Apr 2020 15:46:32 +0200 Subject: [PATCH 12/33] Rework push routing rules, with reclassification --- stock_routing_operation/__manifest__.py | 6 +- stock_routing_operation/models/stock_move.py | 63 +++++++------ .../tests/test_routing_operation_dest.py | 93 ++++++++++++++++++- .../views/stock_location_views.xml | 17 ---- 4 files changed, 126 insertions(+), 53 deletions(-) delete mode 100644 stock_routing_operation/views/stock_location_views.xml diff --git a/stock_routing_operation/__manifest__.py b/stock_routing_operation/__manifest__.py index f4a0408055..ffc54552a3 100644 --- a/stock_routing_operation/__manifest__.py +++ b/stock_routing_operation/__manifest__.py @@ -13,11 +13,7 @@ "demo/stock_picking_type_demo.xml", "demo/stock_routing_demo.xml", ], - "data": [ - "views/stock_location_views.xml", - "views/stock_routing_views.xml", - "security/ir.model.access.csv", - ], + "data": ["views/stock_routing_views.xml", "security/ir.model.access.csv"], "installable": True, "development_status": "Alpha", } diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index b8fec678cc..f81d0e3b02 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -202,7 +202,10 @@ def _apply_routing_rule_pull(self): # original_destination = move.move_line_ids[0].location_dest_id original_destination = move.location_dest_id + current_source_location = move.location_id current_picking_type = move.picking_id.picking_type_id + move.location_id = routing_rule.location_src_id + move.picking_type_id = routing_rule.picking_type_id if self.env["stock.location"].search( [ ("id", "=", routing_rule.location_dest_id.id), @@ -212,35 +215,24 @@ def _apply_routing_rule_pull(self): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one - move.location_id = routing_rule.location_src_id move.location_dest_id = routing_rule.location_dest_id - move.picking_type_id = routing_rule.picking_type_id - elif self.env["stock.location"].search( + elif not self.env["stock.location"].search( [ ("id", "=", routing_rule.location_dest_id.id), ("id", "parent_of", move.location_dest_id.id), ] ): - # The destination of the move is already more precise than the - # expected destination of the routing: keep it, but we still - # want to change the picking type - move.location_id = routing_rule.location_src_id - move.picking_type_id = routing_rule.picking_type_id - else: # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to # route the goods in the correct place - source_location = move.location_id - move.location_id = routing_rule.location_src_id move.location_dest_id = routing_rule.location_dest_id - move.picking_type_id = routing_rule.picking_type_id # create a copy of the move with the current picking type and # going to its original destination: it will be assigned to the # same picking as the original picking of our move move._insert_routing_moves( - current_picking_type, source_location, original_destination + current_picking_type, current_source_location, original_destination ) pickings_to_check_for_emptiness |= move.picking_id @@ -315,6 +307,7 @@ def _split_and_set_rule_for_routing_push(self): move.push_routing_rule_id = rule continue + # TODO split when we split pull for rule, move_lines in routing_rules.items(): if not rule: # No routing operation is required for these moves, @@ -345,6 +338,7 @@ def _apply_routing_rule_push(self): type with the routing operation ones and creates a new chained move after it. """ + pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: if move.state not in ("assigned", "partially_available"): continue @@ -361,28 +355,39 @@ def _apply_routing_rule_push(self): if not routing_rule: continue if move.picking_id.picking_type_id == routing_rule.picking_type_id: - # already correct + # the routing rule has already been applied and re-classified + # the move in the picking type + continue + if move.location_dest_id == routing_rule.location_src_id: + # the routing rule has already been applied and added a new + # routing move after this one continue if self.env["stock.location"].search( [ + # the source is already correct (more precise than the routing), + # but we still want to classify the move in the routing's picking + # type ("id", "=", routing_rule.location_src_id.id), + # if the source location of the move is a child of the routing's + # source location, we don't need to change it ("id", "parent_of", move.location_id.id), ] ): - # This move has been created for the routing operation, - # or was already created with the correct locations anyway, - # exit or it would indefinitely add a next move - continue - - # Move the goods in the "routing" location instead. - # In this use case, we want to keep the move lines so we don't - # change the reservation. - move.write({"location_dest_id": routing_rule.location_src_id.id}) - move.move_line_ids.write( - {"location_dest_id": routing_rule.location_src_id.id} - ) + move.picking_type_id = routing_rule.picking_type_id + pickings_to_check_for_emptiness |= move.picking_id + move._assign_picking() + else: + # Fall here when the source location is unrelated to the + # routing's one. Redirect the move and move line to go through + # the routing and add a new move after it to reach the + # destination of the routing. + move.location_dest_id = routing_rule.location_src_id + move.move_line_ids.location_dest_id = routing_rule.location_src_id + move._insert_routing_moves( + routing_rule.picking_type_id, + routing_rule.location_src_id, + destination, + ) - move._insert_routing_moves( - routing_rule.picking_type_id, routing_rule.location_src_id, destination - ) + pickings_to_check_for_emptiness._routing_operation_handle_empty() diff --git a/stock_routing_operation/tests/test_routing_operation_dest.py b/stock_routing_operation/tests/test_routing_operation_dest.py index b894acaa49..f5e089eb86 100644 --- a/stock_routing_operation/tests/test_routing_operation_dest.py +++ b/stock_routing_operation/tests/test_routing_operation_dest.py @@ -11,7 +11,7 @@ def setUpClass(cls): cls.wh = cls.env["stock.warehouse"].create( { "name": "Base Warehouse", - "reception_steps": "one_step", + "reception_steps": "two_steps", "delivery_steps": "pick_ship", "code": "WHTEST", } @@ -198,7 +198,6 @@ def test_change_location_to_routing_operation(self): # | Stock/Handover → Highbay | # | Product1 Stock/Highbay/Handover → Highbay1-2 (waiting) added_move | # +-------------------------------------------------------------------+ - self.assert_src_supplier(move_a) self.assert_dest_input(move_a) self.assert_src_input(move_b) @@ -551,3 +550,93 @@ def test_several_move_lines(self): self.assertEqual(move_b_shelf.state, "done") self.assertEqual(move_b_handover.state, "done") self.assertEqual(routing_move.state, "done") + + def test_classify_picking_type_sub_location(self): + # When a move already comes from a location within the source location + # of the routing's picking type, we don't need a new routing move, but + # we want to re-classify the move in a stock.picking of the routing's + # picking type. + # For this test, we create a handover inside Input, and we change the + # routing to be Input -> Highbay. Then we change the moves to go + # through Input Handover, to match the picking type. + # The source location of the move stays "Input Handover" because it is already + # more precise as the "Input" of the picking type. + input_ho_location = self.env["stock.location"].create( + {"location_id": self.wh.wh_input_stock_loc_id.id, "name": "Input Handover"} + ) + # any move from input (and sub-locations) to highbay has to be classified in + # our picking type + self.pick_type_routing_op.default_location_src_id = ( + self.wh.wh_input_stock_loc_id + ) + + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + # go through our Input Handover location, as it is under the source location + # of the routing's picking type, we should not have an additional move, + # but move_b must be classified in the routing's picking type + move_a.location_dest_id = input_ho_location + move_a.move_line_ids.location_dest_id = input_ho_location + move_b.location_id = input_ho_location + + self.process_operations(move_a) + + self.assertEqual(move_a.state, "done") + + # move B is classified in a new picking + self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.state, "assigned") + self.assertEqual(move_b.location_id, input_ho_location) + self.assertEqual(move_b.move_line_ids.location_id, input_ho_location) + self.assertEqual(move_b.picking_id.location_id, input_ho_location) + self.assert_dest_highbay_1_2(move_b) + self.assert_dest_highbay_1_2(move_b.move_line_ids) + self.assert_dest_highbay_1_2(move_b.picking_id) + self.assertEqual(move_b.picking_id.picking_type_id, self.pick_type_routing_op) + self.assertFalse(move_b.move_dest_ids) + + def test_picking_type_super_location_extra_move(self): + # When a move comes from a location above the source location of the + # routing's picking type, we need an extra move to reach the particular + # space in the location (example: the goods were brought to Input, but the + # picking type is "Input/Handover -> Highbay"), we'll need an extra move to + # move goods from Input to Input/Handover). + # For this test, we create a handover inside Input, and we change the + # routing to be "Input Handover" -> Highbay. And we change the routing source + # location to "Input Handover". + input_ho_location = self.env["stock.location"].create( + {"location_id": self.wh.wh_input_stock_loc_id.id, "name": "Input Handover"} + ) + # any move from input (and sub-locations) to highbay has to be classified in + # our picking type + self.pick_type_routing_op.default_location_src_id = input_ho_location + + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + + self.process_operations(move_a) + + self.assertEqual(move_a.state, "done") + + self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.state, "assigned") + self.assert_src_input(move_b) + self.assertEqual(move_b.location_dest_id, input_ho_location) + self.assertEqual(move_b.move_line_ids.location_dest_id, input_ho_location) + + # we have an extra move to reach the Highbay from Input/Handover + extra_move = move_b.move_dest_ids + self.assert_dest_highbay_1_2(extra_move) + self.assert_dest_highbay_1_2(extra_move.picking_id) + self.assertEqual( + extra_move.picking_id.picking_type_id, self.pick_type_routing_op + ) + self.assertFalse(extra_move.move_dest_ids) + + # TODO tests for domains diff --git a/stock_routing_operation/views/stock_location_views.xml b/stock_routing_operation/views/stock_location_views.xml deleted file mode 100644 index 6cfcb2675a..0000000000 --- a/stock_routing_operation/views/stock_location_views.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - stock.location.form.inherit - stock.location - - - - - - - - From 89767719e430f4c4eadc8096ce6fd9536b71992c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 9 Apr 2020 08:12:39 +0200 Subject: [PATCH 13/33] Add tests --- stock_routing_operation/tests/__init__.py | 4 +- ..._operation_src.py => test_routing_pull.py} | 25 +++++++--- ...operation_dest.py => test_routing_push.py} | 47 ++++++++++++++++++- 3 files changed, 65 insertions(+), 11 deletions(-) rename stock_routing_operation/tests/{test_routing_operation_src.py => test_routing_pull.py} (95%) rename stock_routing_operation/tests/{test_routing_operation_dest.py => test_routing_push.py} (92%) diff --git a/stock_routing_operation/tests/__init__.py b/stock_routing_operation/tests/__init__.py index 2a582037d5..32e07f5e56 100644 --- a/stock_routing_operation/tests/__init__.py +++ b/stock_routing_operation/tests/__init__.py @@ -1,2 +1,2 @@ -from . import test_routing_operation_src -from . import test_routing_operation_dest +from . import test_routing_pull +from . import test_routing_push diff --git a/stock_routing_operation/tests/test_routing_operation_src.py b/stock_routing_operation/tests/test_routing_pull.py similarity index 95% rename from stock_routing_operation/tests/test_routing_operation_src.py rename to stock_routing_operation/tests/test_routing_pull.py index 9028dc6ac0..4ec9294d5f 100644 --- a/stock_routing_operation/tests/test_routing_operation_src.py +++ b/stock_routing_operation/tests/test_routing_pull.py @@ -3,7 +3,7 @@ from odoo.tests import common -class TestSourceRoutingOperation(common.SavepointCase): +class TestRoutingPull(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -182,6 +182,8 @@ def test_change_location_to_routing_operation(self): ) pick_picking.action_assign() + self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + ml = move_a.move_line_ids self.assertEqual(len(ml), 1) self.assert_src_highbay_1_2(ml) @@ -240,6 +242,8 @@ def test_several_moves(self): move_b_p2 = cust_moves.filtered(lambda r: r.product_id == product2) pick_picking.action_assign() + self.assertEqual(move_a_p1.pull_routing_rule_id, self.routing.rule_ids) + self.assertFalse(move_a_p2.pull_routing_rule_id) # At this point, we should have 3 stock.picking: # @@ -351,16 +355,21 @@ def test_several_move_lines(self): ) pick_picking.action_assign() + # it splits the stock move to be able to chain the quantities from # the Highbay self.assertEqual(len(pick_picking.move_lines), 2) move_a1 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 4 ) + self.assertFalse(move_a1.pull_routing_rule_id) move_a2 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 6 ) move_ho = move_a2.move_orig_ids + # move_ho is the move which has been split from move_a and moved + # to a different picking type + self.assertEqual(move_ho.pull_routing_rule_id, self.routing.rule_ids) self.assertTrue(move_ho) # At this point, we should have 3 stock.picking: @@ -461,6 +470,7 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() + self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -521,6 +531,7 @@ def test_destination_child_tree_change_picking_type(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() + self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -569,9 +580,8 @@ def test_domain_ignore_move(self): ) pick_picking.action_assign() - self.assertEqual( - move_a.move_line_ids.picking_id.picking_type_id, self.wh.pick_type_id - ) + self.assertFalse(move_a.pull_routing_rule_id) + self.assertEqual(move_a.picking_id.picking_type_id, self.wh.pick_type_id) # the original chaining stays the same: we don't add any move here self.assertFalse(move_a.move_orig_ids) self.assertEqual(move_a.move_dest_ids, move_b) @@ -592,9 +602,8 @@ def test_domain_include_move(self): ) pick_picking.action_assign() - self.assertEqual( - move_a.move_line_ids.picking_id.picking_type_id, self.pick_type_routing_op - ) + self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_a.picking_id.picking_type_id, self.pick_type_routing_op) self.assertFalse(move_a.move_orig_ids) self.assertNotEqual(move_a.move_dest_ids, move_b) self.assertFalse(move_b.move_dest_ids) @@ -611,6 +620,7 @@ def test_partial_qty(self): self.assertEqual(move_a.picking_id, pick_picking) self.assertEqual(move_a.product_qty, 2) self.assertEqual(move_a.state, "confirmed") + self.assertFalse(move_a.pull_routing_rule_id) # we have a new waiting move in the PICK with a qty of 8 split_move = move_a.move_dest_ids.move_orig_ids - move_a @@ -620,6 +630,7 @@ def test_partial_qty(self): # we have a new move for the routing before the split move routing_move = split_move.move_orig_ids + self.assertEqual(routing_move.pull_routing_rule_id, self.routing.rule_ids) self.assertRecordValues( routing_move, [ diff --git a/stock_routing_operation/tests/test_routing_operation_dest.py b/stock_routing_operation/tests/test_routing_push.py similarity index 92% rename from stock_routing_operation/tests/test_routing_operation_dest.py rename to stock_routing_operation/tests/test_routing_push.py index f5e089eb86..b0a90257d6 100644 --- a/stock_routing_operation/tests/test_routing_operation_dest.py +++ b/stock_routing_operation/tests/test_routing_push.py @@ -3,7 +3,7 @@ from odoo.tests import common -class TestDestRoutingOperation(common.SavepointCase): +class TestRoutingPush(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -203,6 +203,7 @@ def test_change_location_to_routing_operation(self): self.assert_src_input(move_b) # the move stays B stays on the same dest location self.assert_dest_handover(move_b) + self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) # we should have a move added after move_b to put # the goods in their final location @@ -463,6 +464,10 @@ def test_several_move_lines(self): self.assertEqual(len(routing_move), 1) routing_picking = routing_move.picking_id + self.assertEqual(move_b_handover.push_routing_rule_id, self.routing.rule_ids) + self.assertFalse(move_b_shelf.push_routing_rule_id) + self.assertFalse(routing_move.push_routing_rule_id) + # check chaining self.assertEqual(move_a.move_dest_ids, move_b_shelf + move_b_handover) self.assertFalse(move_b_shelf.move_dest_ids) @@ -639,4 +644,42 @@ def test_picking_type_super_location_extra_move(self): ) self.assertFalse(extra_move.move_dest_ids) - # TODO tests for domains + def test_domain_ignore_move(self): + # define a domain that will exclude the routing for this + # move, there will not be any change on the moves compared + # to a standard setup + domain = "[('product_id', '=', {})]".format(self.product2.id) + self.routing.rule_ids.rule_domain = domain + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + self.process_operations(move_a) + self.assertFalse(move_b.push_routing_rule_id) + self.assertEqual(move_b.picking_id.picking_type_id, self.wh.int_type_id) + # the original chaining stays the same: we don't add any move here + self.assertFalse(move_a.move_orig_ids) + self.assertEqual(move_a.move_dest_ids, move_b) + self.assertFalse(move_b.move_dest_ids) + + def test_domain_include_move(self): + # define a domain that will include the routing for this + # move, so routing is applied + domain = "[('product_id', '=', {})]".format(self.product1.id) + self.routing.rule_ids.rule_domain = domain + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + self.process_operations(move_a) + self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + # we have an extra move + self.assertFalse(move_a.move_orig_ids) + self.assertEqual(move_a.move_dest_ids, move_b) + self.assertTrue(move_b.move_dest_ids) + next_move = move_b.move_dest_ids + self.assertEqual( + next_move.picking_id.picking_type_id, self.pick_type_routing_op + ) From cb1940a828e687bc6311efd63f905371698f264d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 9 Apr 2020 11:41:01 +0200 Subject: [PATCH 14/33] Refactor: include push rules in the same "workflow" used for pull rules --- stock_routing_operation/models/stock_move.py | 225 ++++++------------ .../models/stock_routing.py | 129 ++++++---- .../models/stock_routing_rule.py | 12 +- .../tests/test_routing_pull.py | 22 +- .../tests/test_routing_push.py | 106 +++++---- .../views/stock_routing_views.xml | 6 +- 6 files changed, 233 insertions(+), 267 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index f81d0e3b02..9367ce726f 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -13,16 +13,10 @@ class StockMove(models.Model): _inherit = "stock.move" - pull_routing_rule_id = fields.Many2one( + routing_rule_id = fields.Many2one( comodel_name="stock.routing.rule", copy=False, - help="Technical field. Store the routing pull rule that has been" - " selected for the move.", - ) - push_routing_rule_id = fields.Many2one( - comodel_name="stock.routing.rule", - copy=False, - help="Technical field. Store the routing push rule that has been" + help="Technical field. Store the routing rule that has been" " selected for the move.", ) @@ -32,50 +26,42 @@ def _action_assign(self): else: # these methods will call _action_assign in a savepoint # and modify the routing if necessary - self._split_and_apply_routing_pull() - self._split_and_apply_routing_push() + moves = self._split_and_apply_routing() + super(StockMove, moves)._action_assign() - def _split_and_apply_routing_pull(self): - """Apply source routing operations + def _split_and_apply_routing(self): + """Apply routing rules - * calls super()._action_assign() on moves not yet available - * split the moves if their move lines have different source locations - * apply the routing + * calls super()._action_assign() (in a savepoint) on moves not yet + available to compute the routing rules + * split the moves if their move lines have different source or + destination locations and need routing + * apply the routing rules (pull and push) Important: if you inherit this method to skip the routing for some - moves, you have to call super()._action_assign() on them + moves, the method has to return the moves in ``self`` so they are + assigned. """ moves_routing = self._prepare_routing_pull() + if not moves_routing: + return self # apply the routing - if moves_routing: - moves = self._routing_pull_do_splits(moves_routing) - moves._apply_routing_rule_pull() - super(StockMove, moves)._action_assign() - else: - super()._action_assign() - - def _split_and_apply_routing_push(self): - """Apply destination routing operations - - * at this point, _action_assign should have been called by - ``_split_and_apply_routing_pull`` - * split the moves if their move lines have different destination locations - * apply the routing - """ - dest_moves = self._split_and_set_rule_for_routing_push() - dest_moves._apply_routing_rule_push() + moves = self._routing_splits(moves_routing) + moves._apply_routing_rule_pull() + moves._apply_routing_rule_push() + return moves def _prepare_routing_pull(self): """Prepare pull routing rules for moves - When a move has move lines with different routing operations or lines - with routing operations and lines without, on the source location, we have - to split the moves. This method assigns the moves in a savepoint to compute - the routing rules according to the source location of the move lines. + When a move has move lines with different routing rules or lines with + routing rules and lines without, on the source/dest location, we have + to split the moves. This method assigns the moves in a savepoint to + compute the routing rules according the move lines. - If no routing is applied, the savepoint is released. + If no routing has to be applied, the savepoint is released. If routing must be applied on at least one move, the savepoint is - rollbacked. + rollbacked and will be called after the routing rules have been applied. Return the computed routing rules for the next step, which will be to split the moves. @@ -91,7 +77,7 @@ def _prepare_routing_pull(self): ) super()._action_assign() - moves_routing = self._routing_pull_get_rules() + moves_routing = self._routing_compute_rules() if not any(rule for routing in moves_routing.values() for rule in routing): # no routing to apply, so the reservations done by _action_assign @@ -111,16 +97,14 @@ def _prepare_routing_pull(self): ) return moves_routing - def _routing_pull_get_rules(self): + def _routing_compute_rules(self): """Compute routing pull rules Called in a savepoint (_prepare_routing_pull). Return a dictionary {move: {rule: reserved quantity}}. The rule for a quantity can be an empty recordset, which means no routing rule. """ - move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( - "pull", self - ) + move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves(self) moves_routing = {} no_routing_rule = self.env["stock.routing.rule"].browse() for move in self: @@ -145,13 +129,13 @@ def _routing_pull_get_rules(self): ) return moves_routing - def _routing_pull_do_splits(self, moves_routing): + def _routing_splits(self, moves_routing): """Split moves according to routing rules This method splits the move in as many routing pull rules they have. - This method writes "pull_routing_rule_id" on the moves, this rule will be - used by ``_apply_routing_rule_pull`` + This method writes "routing_rule_id" on the moves, this rule will be + used by ``_apply_routing_rule_pull`` / ``_apply_routing_rule_push`` """ new_move_per_location = {} for move, routing_quantities in moves_routing.items(): @@ -171,7 +155,7 @@ def _routing_pull_do_splits(self, moves_routing): # explicitly check if we really need to split or not. new_move_id = move._split(qty) new_move = self.env["stock.move"].browse(new_move_id) - new_move.pull_routing_rule_id = routing_rule + new_move.routing_rule_id = routing_rule new_move_per_location.setdefault(routing_location.id, []) new_move_per_location[routing_location.id].append(new_move_id) @@ -189,8 +173,8 @@ def _apply_routing_rule_pull(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: - routing_rule = move.pull_routing_rule_id - if not routing_rule: + routing_rule = move.routing_rule_id + if not routing_rule.method == "pull": continue if move.picking_id.picking_type_id == routing_rule.picking_type_id: @@ -199,10 +183,9 @@ def _apply_routing_rule_pull(self): # we expect all the lines to go to the same destination for # pull routing rules - # original_destination = move.move_line_ids[0].location_dest_id - original_destination = move.location_dest_id - current_source_location = move.location_id + original_source = move.location_id + original_destination = move.location_dest_id current_picking_type = move.picking_id.picking_type_id move.location_id = routing_rule.location_src_id move.picking_type_id = routing_rule.picking_type_id @@ -232,7 +215,7 @@ def _apply_routing_rule_pull(self): # going to its original destination: it will be assigned to the # same picking as the original picking of our move move._insert_routing_moves( - current_picking_type, current_source_location, original_destination + current_picking_type, original_source, original_destination ) pickings_to_check_for_emptiness |= move.picking_id @@ -241,94 +224,6 @@ def _apply_routing_rule_pull(self): pickings_to_check_for_emptiness._routing_operation_handle_empty() - def _insert_routing_moves(self, picking_type, location, destination): - """Create a chained move for the source routing operation""" - self.ensure_one() - dest_moves = self.move_dest_ids - # Insert move between the source and destination for the new - # operation - routing_move_values = self._prepare_routing_move_values( - picking_type, location, destination - ) - routing_move = self.copy(routing_move_values) - - # modify the chain to include the new move - self.write( - {"move_dest_ids": [(3, m.id) for m in dest_moves] + [(4, routing_move.id)]} - ) - if dest_moves: - dest_moves.write({"move_orig_ids": [(3, self.id), (4, routing_move.id)]}) - routing_move._action_confirm(merge=False) - routing_move._assign_picking() - - def _prepare_routing_move_values(self, picking_type, source, destination): - return { - "picking_id": False, - "location_id": source.id, - "location_dest_id": destination.id, - "state": "waiting", - "picking_type_id": picking_type.id, - } - - def _split_and_set_rule_for_routing_push(self): - """Split moves per destination routing operations - - When a move has move lines with different routing operations or lines - with routing operations and lines without, on the destination, this - method split the move in as many destination routing operations they - have. - - The reason: the destination location of the moves with a routing - operation will change and their "move_dest_ids" will be modified to - target a new move for the routing operation. - - We don't need to cancel the reservation as done in - ``_split_per_src_routing_operation`` because the source location - doesn't change. - """ - new_moves = self.browse() - move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves( - "push", self - ) - for move in self: - if move.state not in ("assigned", "partially_available"): - continue - - routing_rules = move_routing_rules[move] - - if len(routing_rules) == 1: - # If we have no routing operation or only one routing - # operation, we don't need to split the moves. We need to split - # only if we have 2 different routing operations, or move - # without routing operation and one(s) with routing operations. - rule = next(iter(routing_rules)) - if rule: - # but if we have a rule, store it to apply it later - move.push_routing_rule_id = rule - continue - - # TODO split when we split pull - for rule, move_lines in routing_rules.items(): - if not rule: - # No routing operation is required for these moves, - # continue to the next - continue - # if we have a routing rule, split returns the same move if - # the qty is the same - qty = sum(move_lines.mapped("product_uom_qty")) - new_move_id = move._split(qty) - new_move = self.browse(new_move_id) - move_lines.move_id = new_move - new_move.push_routing_rule_id = rule - move_lines.modified(["product_uom_qty"]) - assert move.state in ("assigned", "partially_available") - # We know the new move is 'assigned' because we created it - # with the quantity matching the move lines that we move into - new_move.state = "assigned" - new_moves += new_move - - return self + new_moves - def _apply_routing_rule_push(self): """Apply routing operations @@ -340,19 +235,10 @@ def _apply_routing_rule_push(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: - if move.state not in ("assigned", "partially_available"): - continue - - # Group move lines per source location, some may need an additional - # operations while others not. Store the number of products to - # take from each location, so we'll be able to split the move - # if needed. # At this point, we should not have lines with different source - # locations, they have been split in - # _split_per_routing_operation(), so we can take the first one - destination = move.move_line_ids[0].location_dest_id - routing_rule = move.push_routing_rule_id - if not routing_rule: + # locations, they have been split by _routing_splits() + routing_rule = move.routing_rule_id + if not routing_rule.method == "push": continue if move.picking_id.picking_type_id == routing_rule.picking_type_id: # the routing rule has already been applied and re-classified @@ -387,7 +273,36 @@ def _apply_routing_rule_push(self): move._insert_routing_moves( routing_rule.picking_type_id, routing_rule.location_src_id, - destination, + routing_rule.location_dest_id, ) pickings_to_check_for_emptiness._routing_operation_handle_empty() + + def _insert_routing_moves(self, picking_type, location, destination): + """Create a chained move for a routing rule""" + self.ensure_one() + dest_moves = self.move_dest_ids + # Insert move between the source and destination for the new + # operation + routing_move_values = self._prepare_routing_move_values( + picking_type, location, destination + ) + routing_move = self.copy(routing_move_values) + + # modify the chain to include the new move + self.write( + {"move_dest_ids": [(3, m.id) for m in dest_moves] + [(4, routing_move.id)]} + ) + if dest_moves: + dest_moves.write({"move_orig_ids": [(3, self.id), (4, routing_move.id)]}) + routing_move._action_confirm(merge=False) + routing_move._assign_picking() + + def _prepare_routing_move_values(self, picking_type, source, destination): + return { + "picking_id": False, + "location_id": source.id, + "location_dest_id": destination.id, + "state": "waiting", + "picking_type_id": picking_type.id, + } diff --git a/stock_routing_operation/models/stock_routing.py b/stock_routing_operation/models/stock_routing.py index f39e8cba6e..3de8a53dab 100644 --- a/stock_routing_operation/models/stock_routing.py +++ b/stock_routing_operation/models/stock_routing.py @@ -1,13 +1,23 @@ # Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo import fields, models +from collections import defaultdict + +from odoo import fields, models, tools + + +def _default_sequence(model): + maxrule = model.search([], order="sequence desc", limit=1) + if maxrule: + return maxrule.sequence + 10 + else: + return 0 class StockRouting(models.Model): _name = "stock.routing" _description = "Stock Routing" - _order = "location_id" + _order = "sequence, id" _rec_name = "location_id" @@ -18,6 +28,7 @@ class StockRouting(models.Model): ondelete="cascade", index=True, ) + sequence = fields.Integer(default=lambda self: self._default_sequence()) active = fields.Boolean(default=True) rule_ids = fields.One2many( comodel_name="stock.routing.rule", inverse_name="routing_id" @@ -31,56 +42,78 @@ class StockRouting(models.Model): ) ] - def _routing_rule_for_moves(self, method, moves): + def _default_sequence(self): + return _default_sequence(self) + + # TODO write tests for this + # TODO would be nice to add a constraint that would prevent to + # have a pull + a pull routing that would apply on the same move + def _routing_rule_for_moves(self, moves): """Return a routing rule for moves - :param method: pull (pick) or push (put-away) + Look first for a pull routing rule, if no match, look for a push + routing rule. + :param move: recordset of the move :return: dict {move: {rule: move_lines}} """ - if method not in ("pull", "push"): - raise ValueError("routing_type must be one of ('pull', 'push')") - - result = {move: {} for move in moves} - valid_rules_for_move = set() + self.__cached_is_rule_valid_for_move.clear_cache(self) + result = { + move: defaultdict(self.env["stock.move.line"].browse) for move in moves + } + empty_rule = self.env["stock.routing.rule"].browse() for move_line in moves.mapped("move_line_ids"): - if method == "pull": - location = move_line.location_id - else: - location = move_line.location_dest_id - location_tree = location._location_parent_tree() - candidate_routings = self.search([("location_id", "in", location_tree.ids)]) - - result.setdefault(move_line.move_id, []) - # the first location is the current move line's source or dest - # location, then we climb up the tree of locations - for loc in location_tree: - # and search the first allowed rule in the routing - routing = candidate_routings.filtered(lambda r: r.location_id == loc) - rules = routing.rule_ids.filtered(lambda r: r.method == method) - # find the first valid rule - found = False - for rule in rules: - if not ( - (move_line.move_id, rule) in valid_rules_for_move - or rule._is_valid_for_moves(move_line.move_id) - ): - continue - # memorize the result so we don't compute it for - # every move line - valid_rules_for_move.add((move_line.move_id, rule)) - if rule in result[move_line.move_id]: - result[move_line.move_id][rule] |= move_line - else: - result[move_line.move_id][rule] = move_line - found = True - break - if found: - break - else: - empty_rule = self.env["stock.routing.rule"].browse() - if empty_rule in result[move_line.move_id]: - result[move_line.move_id][empty_rule] |= move_line - else: - result[move_line.move_id][empty_rule] = move_line + src_location = move_line.location_id + dest_location = move_line.location_dest_id + pull_location_tree = src_location._location_parent_tree() + push_location_tree = dest_location._location_parent_tree() + candidate_rules = self.env["stock.routing.rule"].search( + [ + "|", + "&", + ("routing_location_id", "in", pull_location_tree.ids), + ("method", "=", "pull"), + "&", + ("routing_location_id", "in", push_location_tree.ids), + ("method", "=", "push"), + ] + ) + candidate_rules.sorted(lambda r: (r.routing_id.sequence, r.sequence)) + rule = self._get_move_line_routing_rule( + move_line, pull_location_tree, candidate_rules + ) + if rule: + result[move_line.move_id][rule] |= move_line + continue + + rule = self._get_move_line_routing_rule( + move_line, push_location_tree, candidate_rules + ) + if rule: + result[move_line.move_id][rule] |= move_line + continue + + result[move_line.move_id][empty_rule] |= move_line + return result + + @tools.ormcache("rule", "move") + def __cached_is_rule_valid_for_move(self, rule, move): + """To be used only by _routing_rule_for_moves + + The method _routing_rule_for_moves reset the cache at beginning. + Cache the result so inside _routing_rule_for_moves, we compute it + only once for a move an a rule. + """ + return rule._is_valid_for_moves(move) + + def _get_move_line_routing_rule(self, move_line, location_tree, rules): + # the first location is the current move line's source or dest + # location, then we climb up the tree of locations + for loc in location_tree: + # find the first valid rule + for rule in rules.filtered(lambda r: r.routing_location_id == loc): + if not self.__cached_is_rule_valid_for_move(rule, move_line.move_id): + continue + return rule + return self.env["stock.routing.rule"].browse() diff --git a/stock_routing_operation/models/stock_routing_rule.py b/stock_routing_operation/models/stock_routing_rule.py index 2b3454abb9..087da29cc9 100644 --- a/stock_routing_operation/models/stock_routing_rule.py +++ b/stock_routing_operation/models/stock_routing_rule.py @@ -5,6 +5,8 @@ from odoo.osv import expression from odoo.tools.safe_eval import safe_eval +from .stock_routing import _default_sequence + class StockRoutingRule(models.Model): _name = "stock.routing.rule" @@ -15,7 +17,9 @@ class StockRoutingRule(models.Model): routing_id = fields.Many2one( comodel_name="stock.routing", required=True, ondelete="cascade" ) - routing_location_id = fields.Many2one(related="routing_id.location_id") + routing_location_id = fields.Many2one( + related="routing_id.location_id", store=True, index=True + ) method = fields.Selection( selection=[("pull", "Pull"), ("push", "Push")], help="On pull, the routing is applied when the source location of " @@ -38,11 +42,7 @@ class StockRoutingRule(models.Model): ) def _default_sequence(self): - maxrule = self.search([], order="sequence desc", limit=1) - if maxrule: - return maxrule.sequence + 10 - else: - return 0 + return _default_sequence(self) @api.constrains("picking_type_id") def _constrains_picking_type_location(self): diff --git a/stock_routing_operation/tests/test_routing_pull.py b/stock_routing_operation/tests/test_routing_pull.py index 4ec9294d5f..2982497d67 100644 --- a/stock_routing_operation/tests/test_routing_pull.py +++ b/stock_routing_operation/tests/test_routing_pull.py @@ -182,7 +182,7 @@ def test_change_location_to_routing_operation(self): ) pick_picking.action_assign() - self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -242,8 +242,8 @@ def test_several_moves(self): move_b_p2 = cust_moves.filtered(lambda r: r.product_id == product2) pick_picking.action_assign() - self.assertEqual(move_a_p1.pull_routing_rule_id, self.routing.rule_ids) - self.assertFalse(move_a_p2.pull_routing_rule_id) + self.assertEqual(move_a_p1.routing_rule_id, self.routing.rule_ids) + self.assertFalse(move_a_p2.routing_rule_id) # At this point, we should have 3 stock.picking: # @@ -362,14 +362,14 @@ def test_several_move_lines(self): move_a1 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 4 ) - self.assertFalse(move_a1.pull_routing_rule_id) + self.assertFalse(move_a1.routing_rule_id) move_a2 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 6 ) move_ho = move_a2.move_orig_ids # move_ho is the move which has been split from move_a and moved # to a different picking type - self.assertEqual(move_ho.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_ho.routing_rule_id, self.routing.rule_ids) self.assertTrue(move_ho) # At this point, we should have 3 stock.picking: @@ -470,7 +470,7 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -531,7 +531,7 @@ def test_destination_child_tree_change_picking_type(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -580,7 +580,7 @@ def test_domain_ignore_move(self): ) pick_picking.action_assign() - self.assertFalse(move_a.pull_routing_rule_id) + self.assertFalse(move_a.routing_rule_id) self.assertEqual(move_a.picking_id.picking_type_id, self.wh.pick_type_id) # the original chaining stays the same: we don't add any move here self.assertFalse(move_a.move_orig_ids) @@ -602,7 +602,7 @@ def test_domain_include_move(self): ) pick_picking.action_assign() - self.assertEqual(move_a.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_a.picking_id.picking_type_id, self.pick_type_routing_op) self.assertFalse(move_a.move_orig_ids) self.assertNotEqual(move_a.move_dest_ids, move_b) @@ -620,7 +620,7 @@ def test_partial_qty(self): self.assertEqual(move_a.picking_id, pick_picking) self.assertEqual(move_a.product_qty, 2) self.assertEqual(move_a.state, "confirmed") - self.assertFalse(move_a.pull_routing_rule_id) + self.assertFalse(move_a.routing_rule_id) # we have a new waiting move in the PICK with a qty of 8 split_move = move_a.move_dest_ids.move_orig_ids - move_a @@ -630,7 +630,7 @@ def test_partial_qty(self): # we have a new move for the routing before the split move routing_move = split_move.move_orig_ids - self.assertEqual(routing_move.pull_routing_rule_id, self.routing.rule_ids) + self.assertEqual(routing_move.routing_rule_id, self.routing.rule_ids) self.assertRecordValues( routing_move, [ diff --git a/stock_routing_operation/tests/test_routing_push.py b/stock_routing_operation/tests/test_routing_push.py index b0a90257d6..8202a3636a 100644 --- a/stock_routing_operation/tests/test_routing_push.py +++ b/stock_routing_operation/tests/test_routing_push.py @@ -19,7 +19,7 @@ def setUpClass(cls): cls.supplier_loc = cls.env.ref("stock.stock_location_suppliers") cls.location_hb = cls.env["stock.location"].create( - {"name": "Highbay", "location_id": cls.wh.lot_stock_id.id} + {"name": "Highbay", "location_id": cls.wh.view_location_id.id} ) cls.location_shelf_1 = cls.env["stock.location"].create( {"name": "Shelf 1", "location_id": cls.wh.lot_stock_id.id} @@ -35,12 +35,26 @@ def setUpClass(cls): ) cls.location_handover = cls.env["stock.location"].create( - {"name": "Handover", "location_id": cls.location_hb.id} + {"name": "Handover", "location_id": cls.wh.view_location_id.id} ) cls.product1 = cls.env["product.product"].create( {"name": "Product 1", "type": "product"} ) + cls.env["stock.putaway.rule"].create( + { + "product_id": cls.product1.id, + "location_in_id": cls.wh.lot_stock_id.id, + "location_out_id": cls.location_shelf_1.id, + } + ) + cls.env["stock.putaway.rule"].create( + { + "product_id": cls.product1.id, + "location_in_id": cls.location_hb.id, + "location_out_id": cls.location_hb_1_2.id, + } + ) cls.product2 = cls.env["product.product"].create( {"name": "Product 2", "type": "product"} ) @@ -154,6 +168,9 @@ def assert_src_shelf1(self, record): def assert_dest_shelf1(self, record): self.assertEqual(record.location_dest_id, self.location_shelf_1) + def assert_dest_highbay(self, record): + self.assertEqual(record.location_dest_id, self.location_hb) + def assert_src_highbay_1_2(self, record): self.assertEqual(record.location_id, self.location_hb_1_2) @@ -203,7 +220,7 @@ def test_change_location_to_routing_operation(self): self.assert_src_input(move_b) # the move stays B stays on the same dest location self.assert_dest_handover(move_b) - self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) # we should have a move added after move_b to put # the goods in their final location @@ -212,7 +229,7 @@ def test_change_location_to_routing_operation(self): # move: the move line will be in the sub-locations (handover) self.assert_src_handover(routing_move) - self.assert_dest_highbay_1_2(routing_move) + self.assert_dest_highbay(routing_move) self.assertEquals(routing_move.picking_type_id, self.pick_type_routing_op) self.assertEquals( @@ -314,7 +331,7 @@ def test_several_moves(self): self.assertEqual(routing_picking.move_lines, routing_move) self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) self.assert_src_handover(routing_picking) - self.assert_dest_highbay_1_2(routing_picking) + self.assert_dest_highbay(routing_picking) # check move and move line A for product1 self.assert_src_supplier(move_a_p1) @@ -352,7 +369,7 @@ def test_several_moves(self): # check routing move for product1 self.assert_src_handover(routing_move) - self.assert_dest_highbay_1_2(routing_move) + self.assert_dest_highbay(routing_move) # Deliver the internal picking (moves B), # the routing move for product1 should be assigned, @@ -389,32 +406,31 @@ def test_several_move_lines(self): self.process_operations(move_a) - # At this point, move_a being 'done', action_assign is executed - # on move_b. The standard put-away rules would put all the 10 - # products in a location. So with a standard odoo, we can't have - # the situation we want to test here. Using additional modules - # with more advanced put-away rules, the put-away could create - # several move lines with different destinations, for instance - # one in the highbay, one in the shelf. The highbay one will - # need an additional move, the one in the shelf not. - - # In order to simulate this, we'll manually change the move lines of - # move_b and call '_split_and_apply_routing_push' on it to force - # the application of the routing operation. - - first_ml = move_b.move_line_ids - # use _write to bypass the changes to quants done in the - # write of stock.move.line: we want to keep the same reservation - # of 10 units - first_ml._write( - {"product_uom_qty": 6.0, "location_dest_id": self.location_hb_1_2.id} - ) - move_b.move_line_ids.copy( - {"product_uom_qty": 4.0, "location_dest_id": self.location_shelf_1.id} - ) - move_b.move_line_ids.invalidate_cache(["product_uom_qty", "location_dest_id"]) - # assign moves ignoring the routing, then apply it manually - move_b.with_context(exclude_apply_routing_operation=True)._action_assign() + # At this point, move_a being 'done', action_assign is automatically + # executed on move_b. The standard put-away rules would put all the 10 + # products in a location. So with a standard odoo, we can't have the + # situation we want to test here. Using additional modules with more + # advanced put-away rules, the put-away could create several move lines + # with different destinations, for instance one in the highbay, one in + # the shelf. The highbay one will need an additional move, the one in + # the shelf not. + + # In order to simulate this, we'll unreserve move_b and manually call + # the routing machinery with a forged routing grid + move_b._do_unreserve() + # normally this is what is returned by _routing_compute_rules: + moves_routing = { + move_b: { + # qty of 6 using this routing rule + self.routing.rule_ids: 6, + # no routing for the 4 remaining + self.env["stock.routing"].browse(): 4, + } + } + # this is what is done in in _action_assign() + moves = move_b._routing_splits(moves_routing) + moves._apply_routing_rule_push() + moves._action_assign() # At this point, we should have this # @@ -431,7 +447,7 @@ def test_several_move_lines(self): # | 6x Product1 Input → Stock/HB-1-2 (available) | # | 4x Product1 Input → Stock/Shelf1 (available) | # +--------------------------------------------------------+ - move_b._split_and_apply_routing_push() + # move_b._split_and_apply_routing() # We expect the routing operation to split the move_b so # we'll be able to have a move_dest_ids for the Highbay: @@ -464,9 +480,9 @@ def test_several_move_lines(self): self.assertEqual(len(routing_move), 1) routing_picking = routing_move.picking_id - self.assertEqual(move_b_handover.push_routing_rule_id, self.routing.rule_ids) - self.assertFalse(move_b_shelf.push_routing_rule_id) - self.assertFalse(routing_move.push_routing_rule_id) + self.assertEqual(move_b_handover.routing_rule_id, self.routing.rule_ids) + self.assertFalse(move_b_shelf.routing_rule_id) + self.assertFalse(routing_move.routing_rule_id) # check chaining self.assertEqual(move_a.move_dest_ids, move_b_shelf + move_b_handover) @@ -475,6 +491,8 @@ def test_several_move_lines(self): self.assertFalse(routing_move.move_dest_ids) self.assertEqual(move_a.state, "done") + move_b_handover._action_assign() + self.assertEqual(move_b_shelf.state, "assigned") self.assertEqual(move_b_handover.state, "assigned") self.assertEqual(routing_move.state, "waiting") @@ -495,7 +513,7 @@ def test_several_move_lines(self): self.assertEqual(routing_picking.move_lines, routing_move) self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) self.assert_src_handover(routing_picking) - self.assert_dest_highbay_1_2(routing_picking) + self.assert_dest_highbay(routing_picking) # check move and move line A self.assert_src_supplier(move_a) @@ -531,7 +549,7 @@ def test_several_move_lines(self): # check routing move for product1 self.assert_src_handover(routing_move) - self.assert_dest_highbay_1_2(routing_move) + self.assert_dest_highbay(routing_move) # Deliver the internal picking (moves B), # the routing move should be assigned, @@ -592,7 +610,7 @@ def test_classify_picking_type_sub_location(self): self.assertEqual(move_a.state, "done") # move B is classified in a new picking - self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_b.state, "assigned") self.assertEqual(move_b.location_id, input_ho_location) self.assertEqual(move_b.move_line_ids.location_id, input_ho_location) @@ -629,7 +647,7 @@ def test_picking_type_super_location_extra_move(self): self.assertEqual(move_a.state, "done") - self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_b.state, "assigned") self.assert_src_input(move_b) self.assertEqual(move_b.location_dest_id, input_ho_location) @@ -637,8 +655,8 @@ def test_picking_type_super_location_extra_move(self): # we have an extra move to reach the Highbay from Input/Handover extra_move = move_b.move_dest_ids - self.assert_dest_highbay_1_2(extra_move) - self.assert_dest_highbay_1_2(extra_move.picking_id) + self.assert_dest_highbay(extra_move) + self.assert_dest_highbay(extra_move.picking_id) self.assertEqual( extra_move.picking_id.picking_type_id, self.pick_type_routing_op ) @@ -656,7 +674,7 @@ def test_domain_ignore_move(self): move_a = in_picking.move_lines move_b = internal_picking.move_lines self.process_operations(move_a) - self.assertFalse(move_b.push_routing_rule_id) + self.assertFalse(move_b.routing_rule_id) self.assertEqual(move_b.picking_id.picking_type_id, self.wh.int_type_id) # the original chaining stays the same: we don't add any move here self.assertFalse(move_a.move_orig_ids) @@ -674,7 +692,7 @@ def test_domain_include_move(self): move_a = in_picking.move_lines move_b = internal_picking.move_lines self.process_operations(move_a) - self.assertEqual(move_b.push_routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) # we have an extra move self.assertFalse(move_a.move_orig_ids) self.assertEqual(move_a.move_dest_ids, move_b) diff --git a/stock_routing_operation/views/stock_routing_views.xml b/stock_routing_operation/views/stock_routing_views.xml index 9ad7b8b71e..7ff6d9fd17 100644 --- a/stock_routing_operation/views/stock_routing_views.xml +++ b/stock_routing_operation/views/stock_routing_views.xml @@ -31,11 +31,10 @@
- @@ -71,6 +70,7 @@ stock.routing + From c24b200080f42eabe3fda5cdd355567b260cb36b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Apr 2020 11:29:16 +0200 Subject: [PATCH 15/33] Apply routing in chain When a move source location changes or a new move is inserted in the chain, reevaluate the routing rules so we can chain several rules. --- stock_routing_operation/models/stock_move.py | 98 +++++- .../models/stock_routing.py | 109 ++++--- .../tests/test_routing_pull.py | 298 +++++++++++++++++- .../tests/test_routing_push.py | 89 ++++++ 4 files changed, 532 insertions(+), 62 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 9367ce726f..d944536e74 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -20,6 +20,29 @@ class StockMove(models.Model): " selected for the move.", ) + def write(self, values): + result = super().write(values) + if not self.env.context.get("__applying_routing_rule") and values.get( + "location_id" + ): + self.filtered(lambda r: r.state == "waiting")._chain_apply_routing() + return result + + def _chain_apply_routing(self): + """Apply routing on moves waiting for another one in a chained flow + + When the first move of a chain is reserved, it might trigger a change + in the routing, we want to adapt the moves along the chained flow. + """ + if not self: + return + move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves(self) + for move, rule in move_routing_rules.items(): + if rule: + move.routing_rule_id = rule + self._apply_routing_rule_pull() + self._apply_routing_rule_push() + def _action_assign(self): if self.env.context.get("exclude_apply_routing_operation"): super()._action_assign() @@ -104,7 +127,9 @@ def _routing_compute_rules(self): Return a dictionary {move: {rule: reserved quantity}}. The rule for a quantity can be an empty recordset, which means no routing rule. """ - move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves(self) + move_routing_rules = self.env["stock.routing"]._routing_rule_for_move_lines( + self + ) moves_routing = {} no_routing_rule = self.env["stock.routing.rule"].browse() for move in self: @@ -153,6 +178,7 @@ def _routing_splits(self, moves_routing): # The _split() method returns the same move if the qty # is the same than the move's qty, so we don't need to # explicitly check if we really need to split or not. + # FIXME _split must be called on product_qty new_move_id = move._split(qty) new_move = self.env["stock.move"].browse(new_move_id) new_move.routing_rule_id = routing_rule @@ -183,11 +209,11 @@ def _apply_routing_rule_pull(self): # we expect all the lines to go to the same destination for # pull routing rules - - original_source = move.location_id original_destination = move.location_dest_id current_picking_type = move.picking_id.picking_type_id - move.location_id = routing_rule.location_src_id + move.with_context( + __applying_routing_rule=True + ).location_id = routing_rule.location_src_id move.picking_type_id = routing_rule.picking_type_id if self.env["stock.location"].search( [ @@ -197,8 +223,10 @@ def _apply_routing_rule_pull(self): ): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise - # enough: set the new destination to match the rule's one - move.location_dest_id = routing_rule.location_dest_id + # enough: set the new destination to match the rule's one. + # The source of the dest. move will be changed to match it, + # which may reapply a new routing rule on the dest. move. + move._routing_pull_switch_destination(routing_rule) elif not self.env["stock.location"].search( [ @@ -210,12 +238,8 @@ def _apply_routing_rule_pull(self): # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to # route the goods in the correct place - move.location_dest_id = routing_rule.location_dest_id - # create a copy of the move with the current picking type and - # going to its original destination: it will be assigned to the - # same picking as the original picking of our move - move._insert_routing_moves( - current_picking_type, original_source, original_destination + move._routing_pull_insert_move( + routing_rule, current_picking_type, original_destination ) pickings_to_check_for_emptiness |= move.picking_id @@ -224,6 +248,48 @@ def _apply_routing_rule_pull(self): pickings_to_check_for_emptiness._routing_operation_handle_empty() + def _routing_pull_switch_destination(self, routing_rule): + """Switch the destination of the move in place + + In this case, do not insert a new move but switch the destination + of the current move. The destination move source location is changed + as well, which might trigger a new routing on the destination move. + """ + self.location_dest_id = routing_rule.location_dest_id + next_move = self.move_dest_ids.filtered(lambda r: r.state == "waiting") + # FIXME what should happen if we have > 1 move? What would + # be the use case? + if next_move and len(next_move) == 1: + split_move = self.browse(next_move._split(self.product_qty)) + if split_move != next_move: + # No split occurs if the quantity was the same. + # But if it did split, detach it from the move on which + # we have a different routing + next_move.move_orig_ids -= self + split_move.move_orig_ids = self + next_move = split_move + + next_move.location_id = routing_rule.location_dest_id + + def _routing_pull_insert_move(self, routing_rule, picking_type, destination): + """Add a move after the current move to reach the destination + + The routing rules are applied on the new move in case it would trigger + a new move or a switch of picking type. + """ + self.location_dest_id = routing_rule.location_dest_id + # create a copy of the move with the current picking type and + # going to its original destination: it will be assigned to the + # same picking as the original picking of our move + routing_move = self._insert_routing_moves( + picking_type, + # the source of the next move has to be the same as the + # destination of the current move + routing_rule.location_dest_id, + destination, + ) + routing_move._chain_apply_routing() + def _apply_routing_rule_push(self): """Apply routing operations @@ -270,11 +336,14 @@ def _apply_routing_rule_push(self): # destination of the routing. move.location_dest_id = routing_rule.location_src_id move.move_line_ids.location_dest_id = routing_rule.location_src_id - move._insert_routing_moves( + routing_move = move._insert_routing_moves( routing_rule.picking_type_id, routing_rule.location_src_id, routing_rule.location_dest_id, ) + routing_move._assign_picking() + # recursively apply chain in case we have several routing steps + move._chain_apply_routing() pickings_to_check_for_emptiness._routing_operation_handle_empty() @@ -296,11 +365,10 @@ def _insert_routing_moves(self, picking_type, location, destination): if dest_moves: dest_moves.write({"move_orig_ids": [(3, self.id), (4, routing_move.id)]}) routing_move._action_confirm(merge=False) - routing_move._assign_picking() + return routing_move def _prepare_routing_move_values(self, picking_type, source, destination): return { - "picking_id": False, "location_id": source.id, "location_dest_id": destination.id, "state": "waiting", diff --git a/stock_routing_operation/models/stock_routing.py b/stock_routing_operation/models/stock_routing.py index 3de8a53dab..70fbfebe2a 100644 --- a/stock_routing_operation/models/stock_routing.py +++ b/stock_routing_operation/models/stock_routing.py @@ -45,11 +45,53 @@ class StockRouting(models.Model): def _default_sequence(self): return _default_sequence(self) - # TODO write tests for this # TODO would be nice to add a constraint that would prevent to # have a pull + a pull routing that would apply on the same move - def _routing_rule_for_moves(self, moves): - """Return a routing rule for moves + # TODO write tests for this + def _find_rule_for_location(self, move, src_location, dest_location): + """Return the routing rule for a source or destination location + + It searches first a routing pull rule based on the source location, + and if nothing is found, it searches for a routing push rule based + on the destination location. + + The source/destination locations are not an exact match: it looks + for the location or a parent. + """ + # the result of _location_parent_tree() is cached, so get the rules + # at once even if we don't use the "push" candidates, we can spare + # some queries + pull_location_tree = src_location._location_parent_tree() + push_location_tree = dest_location._location_parent_tree() + candidate_rules = self.env["stock.routing.rule"].search( + [ + "|", + "&", + ("routing_location_id", "in", pull_location_tree.ids), + ("method", "=", "pull"), + "&", + ("routing_location_id", "in", push_location_tree.ids), + ("method", "=", "push"), + ] + ) + candidate_rules.sorted(lambda r: (r.routing_id.sequence, r.sequence)) + rule = self._get_location_routing_rule( + move, pull_location_tree, candidate_rules + ) + if rule: + return rule + + rule = self._get_location_routing_rule( + move, push_location_tree, candidate_rules + ) + if rule: + return rule + + empty_rule = self.env["stock.routing.rule"].browse() + return empty_rule + + def _routing_rule_for_move_lines(self, moves): + """Return a routing rule for move lines Look first for a pull routing rule, if no match, look for a push routing rule. @@ -61,59 +103,50 @@ def _routing_rule_for_moves(self, moves): result = { move: defaultdict(self.env["stock.move.line"].browse) for move in moves } - empty_rule = self.env["stock.routing.rule"].browse() for move_line in moves.mapped("move_line_ids"): - src_location = move_line.location_id - dest_location = move_line.location_dest_id - pull_location_tree = src_location._location_parent_tree() - push_location_tree = dest_location._location_parent_tree() - candidate_rules = self.env["stock.routing.rule"].search( - [ - "|", - "&", - ("routing_location_id", "in", pull_location_tree.ids), - ("method", "=", "pull"), - "&", - ("routing_location_id", "in", push_location_tree.ids), - ("method", "=", "push"), - ] - ) - candidate_rules.sorted(lambda r: (r.routing_id.sequence, r.sequence)) - rule = self._get_move_line_routing_rule( - move_line, pull_location_tree, candidate_rules + rule = self._find_rule_for_location( + move_line.move_id, move_line.location_id, move_line.location_dest_id ) - if rule: - result[move_line.move_id][rule] |= move_line - continue + result[move_line.move_id][rule] |= move_line - rule = self._get_move_line_routing_rule( - move_line, push_location_tree, candidate_rules - ) - if rule: - result[move_line.move_id][rule] |= move_line - continue + return result + + def _routing_rule_for_moves(self, moves): + """Return a routing rule for moves - result[move_line.move_id][empty_rule] |= move_line + Look first for a pull routing rule, if no match, look for a push + routing rule. + + :param move: recordset of the move + :return: dict {move: rule}} + """ + self.__cached_is_rule_valid_for_move.clear_cache(self) + result = {} + for move in moves: + rule = self._find_rule_for_location( + move, move.location_id, move.location_dest_id + ) + result[move] = rule return result @tools.ormcache("rule", "move") def __cached_is_rule_valid_for_move(self, rule, move): - """To be used only by _routing_rule_for_moves + """To be used only by _routing_rule_for_move(_line)s - The method _routing_rule_for_moves reset the cache at beginning. - Cache the result so inside _routing_rule_for_moves, we compute it - only once for a move an a rule. + The method _routing_rule_for_move(_line)s reset the cache at beginning. + Cache the result so inside _routing_rule_for_move(_line)s, we compute it + only once for a move and a rule (if we have several move lines). """ return rule._is_valid_for_moves(move) - def _get_move_line_routing_rule(self, move_line, location_tree, rules): + def _get_location_routing_rule(self, move, location_tree, rules): # the first location is the current move line's source or dest # location, then we climb up the tree of locations for loc in location_tree: # find the first valid rule for rule in rules.filtered(lambda r: r.routing_location_id == loc): - if not self.__cached_is_rule_valid_for_move(rule, move_line.move_id): + if not self.__cached_is_rule_valid_for_move(rule, move): continue return rule return self.env["stock.routing.rule"].browse() diff --git a/stock_routing_operation/tests/test_routing_pull.py b/stock_routing_operation/tests/test_routing_pull.py index 2982497d67..1ceb9c39ed 100644 --- a/stock_routing_operation/tests/test_routing_pull.py +++ b/stock_routing_operation/tests/test_routing_pull.py @@ -198,10 +198,8 @@ def test_change_location_to_routing_operation(self): self.assert_dest_customer(move_b) move_middle = move_a.move_dest_ids - # the routing move stays in the same source location than the original - # move: the move line will be in the sub-locations (handover) - - self.assert_src_stock(move_middle) + # the routing move comes from the place where we put the goods + self.assert_src_handover(move_middle) # Output self.assert_dest_output(move_middle) @@ -300,8 +298,8 @@ def test_several_moves(self): self.assertEqual(move_b_p2.state, "waiting") # Check middle move - # Stock - self.assert_src_stock(move_middle) + # the new source is handover since we move the goods here + self.assert_src_handover(move_middle) # Output self.assert_dest_output(move_middle) self.assert_src_stock(move_middle.picking_id) @@ -413,7 +411,7 @@ def test_several_move_lines(self): # the split move is waiting for 'move_ho' self.assertEqual(len(ml), 1) - self.assert_src_stock(move_a2) + self.assert_src_handover(move_a2) self.assert_dest_output(move_a2) self.assertEqual(move_a2.picking_id.picking_type_id, self.wh.pick_type_id) self.assertEqual(move_a2.state, "waiting") @@ -481,8 +479,9 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.assert_src_highbay(move_a) self.assertEqual(move_a.location_dest_id, area1) - # the move stays B stays on the same source location - self.assert_src_output(move_b) + # the move B is changed to have the same source location as dest of + # the previous move + self.assertEqual(move_b.location_id, area1) self.assert_dest_customer(move_b) # the original chaining stays the same: we don't add any move here @@ -643,3 +642,284 @@ def test_partial_qty(self): ) self.assert_src_highbay(routing_move) self.assert_dest_handover(routing_move) + + def test_change_dest_move_source(self): + # Change the picking type destination so the move goes to a location + # which is a parent destination of the routing destination (move will + # go to Output, routing destination is Output/Area1). We want to check + # that the destination move source has been changed from Output to + # Output/Area1 and the routing Output/Area1 -> Customer applied + area1 = self.env["stock.location"].create( + {"location_id": self.wh.wh_output_stock_loc_id.id, "name": "Area1"} + ) + self.pick_type_routing_op.default_location_dest_id = area1 + + pick_type_routing_delivery = self.env["stock.picking.type"].create( + { + "name": "Delivery (after routing)", + "code": "outgoing", + "sequence_code": "OUT(R)", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": area1.id, + "default_location_dest_id": self.customer_loc.id, + } + ) + routing_delivery = self.env["stock.routing"].create( + { + "location_id": area1.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "pull", + "picking_type_id": pick_type_routing_delivery.id, + }, + ) + ], + } + ) + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) + self.assertEqual(move_b.location_id, area1) + + # routing has been applied + self.assertEqual(move_b.routing_rule_id, routing_delivery.rule_ids) + self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) + + def test_change_dest_move_source_split(self): + # Change the picking type destination so the move goes to a location + # which is a parent destination of the routing destination (move will + # go to Output, routing destination is Output/Area1). We want to check + # that the destination move source has been changed from Output to + # Output/Area1 + area1 = self.env["stock.location"].create( + {"location_id": self.wh.wh_output_stock_loc_id.id, "name": "Area1"} + ) + self.pick_type_routing_op.default_location_dest_id = area1 + + pick_type_routing_delivery = self.env["stock.picking.type"].create( + { + "name": "Delivery (after routing)", + "code": "outgoing", + "sequence_code": "OUT(R)", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": area1.id, + "default_location_dest_id": self.customer_loc.id, + } + ) + routing_delivery = self.env["stock.routing"].create( + { + "location_id": area1.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "pull", + "picking_type_id": pick_type_routing_delivery.id, + }, + ) + ], + } + ) + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location(self.location_hb_1_2, move_a.product_id, 6) + pick_picking.action_assign() + + # We now expect the chain of moves to have been split following this schema: + # Move A: + # +-------------------------------------------------------------------+ + # | WHTES/PICK/00001 confirmed + # | 4x Product Stock → Output (confirmed) move_a + # +-------------------------------------------------------------------+ + # Move B, waiting only on Move A + # +-------------------------------------------------------------------+ + # | WHTES/OUT/00001 waiting + # | 4x Product Output → Customers (waiting) move_b + # +-------------------------------------------------------------------+ + + # And since 6 products have been reserved in the Highbay, and we have a + # routing that go through Output/Area1 with a special picking type, the + # move for the 6 units has been split and moved to different picking + # types/pickings + + # +-------------------------------------------------------------------+ + # | WHTES/WH/HO/00001 Assigned + # | 6x Product Highbay → Output/Area1 (assigned) move_c + # +-------------------------------------------------------------------+ + # + # +-------------------------------------------------------------------+ + # | WHTES/WH/OUT(R)/00001 Waiting + # | 6x Product Output/Area1 → Customers (assigned) move_d + # +-------------------------------------------------------------------+ + move_c = ( + self.env["stock.picking"] + .search([("picking_type_id", "=", self.pick_type_routing_op.id)]) + .move_lines + ) + move_d = ( + self.env["stock.picking"] + .search([("picking_type_id", "=", pick_type_routing_delivery.id)]) + .move_lines + ) + self.assertRecordValues( + move_a | move_b | move_c | move_d, + [ + { + "move_orig_ids": [], + "move_dest_ids": move_b.ids, + "routing_rule_id": False, + "state": "confirmed", + "location_id": self.wh.lot_stock_id.id, + "location_dest_id": self.wh.wh_output_stock_loc_id.id, + }, + { + "move_orig_ids": move_a.ids, + "move_dest_ids": [], + "routing_rule_id": False, + "state": "waiting", + "location_id": self.wh.wh_output_stock_loc_id.id, + "location_dest_id": self.customer_loc.id, + }, + { + "move_orig_ids": [], + "move_dest_ids": move_d.ids, + "routing_rule_id": self.routing.rule_ids.id, + "state": "assigned", + "location_id": self.location_hb.id, + "location_dest_id": area1.id, + }, + { + "move_orig_ids": move_c.ids, + "move_dest_ids": [], + "routing_rule_id": routing_delivery.rule_ids.id, + "state": "waiting", + "location_id": area1.id, + "location_dest_id": self.customer_loc.id, + }, + ], + ) + + self.assertEqual(move_a.picking_id.picking_type_id, self.wh.pick_type_id) + self.assertEqual(move_b.picking_id.picking_type_id, self.wh.out_type_id) + self.assertEqual(move_c.picking_id.picking_type_id, self.pick_type_routing_op) + self.assertEqual(move_d.picking_id.picking_type_id, pick_type_routing_delivery) + + def test_change_dest_move_source_chain(self): + location_qa = self.env["stock.location"].create( + {"location_id": self.wh.wh_output_stock_loc_id.id, "name": "QA"} + ) + # The setup we want is: + # + # * When the initial move line reserves in Highbay, the move is + # classified in picking type "Routing operation" with locations + # Highbay -> Handover (a new move is inserted between Handover and + # Output) + # * When the next move source location is set to "Handover", we + # we want to classify the next move as "QA" with locations Handover + # -> Output/QA + # * When the last move source location is changed to "QA", it must be + # classified as "Delivery (after QA)" with locations Output/QA -> + # Customer + + pick_type_routing_qa = self.env["stock.picking.type"].create( + { + "name": "QA", + "code": "internal", + "sequence_code": "WH/QA", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": self.location_handover.id, + "default_location_dest_id": location_qa.id, + } + ) + routing_qa = self.env["stock.routing"].create( + { + "location_id": self.location_handover.id, + "rule_ids": [ + ( + 0, + 0, + {"method": "pull", "picking_type_id": pick_type_routing_qa.id}, + ) + ], + } + ) + + pick_type_routing_delivery = self.env["stock.picking.type"].create( + { + "name": "Delivery (after QA)", + "code": "outgoing", + "sequence_code": "OUT(R)", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": location_qa.id, + "default_location_dest_id": self.customer_loc.id, + } + ) + routing_delivery = self.env["stock.routing"].create( + { + "location_id": location_qa.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "pull", + "picking_type_id": pick_type_routing_delivery.id, + }, + ) + ], + } + ) + + pick_picking, customer_picking = self._create_pick_ship( + self.wh, [(self.product1, 10)] + ) + move_a = pick_picking.move_lines + move_b = customer_picking.move_lines + + self._update_product_qty_in_location( + self.location_hb_1_2, move_a.product_id, 100 + ) + pick_picking.action_assign() + self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) + move_middle = move_a.move_dest_ids + self.assertNotEqual(move_middle, move_b) + + self.assert_src_highbay(move_a) + self.assert_dest_handover(move_a) + self.assert_src_handover(move_middle) + self.assertEqual(move_middle.location_dest_id, location_qa) + self.assertEqual(move_b.location_id, location_qa) + self.assert_dest_customer(move_b) + + # routing has been applied + self.assertEqual(move_middle.routing_rule_id, routing_qa.rule_ids) + self.assertEqual(move_middle.picking_id.picking_type_id, pick_type_routing_qa) + + self.assertEqual(move_b.routing_rule_id, routing_delivery.rule_ids) + self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) diff --git a/stock_routing_operation/tests/test_routing_push.py b/stock_routing_operation/tests/test_routing_push.py index 8202a3636a..71031a5536 100644 --- a/stock_routing_operation/tests/test_routing_push.py +++ b/stock_routing_operation/tests/test_routing_push.py @@ -701,3 +701,92 @@ def test_domain_include_move(self): self.assertEqual( next_move.picking_id.picking_type_id, self.pick_type_routing_op ) + + def test_chain(self): + location_pre_handover = self.env["stock.location"].create( + {"name": "Pre-Handover", "location_id": self.wh.view_location_id.id} + ) + pick_type_pre_handover = self.env["stock.picking.type"].create( + { + "name": "Routing Pre-Handover", + "code": "internal", + "sequence_code": "WH/PHO", + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": location_pre_handover.id, + "default_location_dest_id": self.location_handover.id, + } + ) + routing_pre_handover = self.env["stock.routing"].create( + { + "location_id": self.location_handover.id, + "rule_ids": [ + ( + 0, + 0, + { + "method": "push", + "picking_type_id": pick_type_pre_handover.id, + }, + ) + ], + } + ) + + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10, self.location_hb_1_2)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + self.assertEqual(move_a.state, "assigned") + self.process_operations(move_a) + + move_pre_handover = move_b.move_dest_ids + move_hb = move_pre_handover.move_dest_ids + + self.assertRecordValues( + move_a | move_b | move_pre_handover | move_hb, + [ + { + "move_orig_ids": [], + "move_dest_ids": move_b.ids, + "routing_rule_id": False, + "state": "done", + "location_id": self.supplier_loc.id, + "location_dest_id": self.wh.wh_input_stock_loc_id.id, + }, + { + "move_orig_ids": move_a.ids, + "move_dest_ids": move_pre_handover.ids, + # only the last applied rule is kept... + "routing_rule_id": routing_pre_handover.rule_ids.id, + "state": "assigned", + "location_id": self.wh.wh_input_stock_loc_id.id, + "location_dest_id": location_pre_handover.id, + }, + { + "move_orig_ids": move_b.ids, + "move_dest_ids": move_hb.ids, + "routing_rule_id": False, + "state": "waiting", + "location_id": location_pre_handover.id, + "location_dest_id": self.location_handover.id, + }, + { + "move_orig_ids": move_pre_handover.ids, + "move_dest_ids": [], + "routing_rule_id": False, + "state": "waiting", + "location_id": self.location_handover.id, + "location_dest_id": self.location_hb.id, + }, + ], + ) + + self.assertEqual(move_a.picking_id.picking_type_id, self.wh.in_type_id) + self.assertEqual(move_b.picking_id.picking_type_id, self.wh.int_type_id) + self.assertEqual( + move_pre_handover.picking_id.picking_type_id, pick_type_pre_handover + ) + self.assertEqual(move_hb.picking_id.picking_type_id, self.pick_type_routing_op) From 12c92703bfd1f0036446368f3cb736370a19dce0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Apr 2020 14:20:45 +0200 Subject: [PATCH 16/33] Fix product_qty handling The _split method expects product_qty, not product_uom_qty. So get the missing reserved quantity and convert it in the unit of product_qty (as done in StockMove._action_assign()) --- stock_routing_operation/models/stock_move.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index d944536e74..746ec27d58 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -7,8 +7,6 @@ from odoo import fields, models -# TODO check product_qty / product_uom_qty - class StockMove(models.Model): _inherit = "stock.move" @@ -142,16 +140,26 @@ def _routing_compute_rules(self): # if needed. routing_rules = move_routing_rules[move] moves_routing[move] = { - rule: sum(move_lines.mapped("product_uom_qty")) + # use product_qty and not product_uom_qty, because we'll use + # this for the _split() and this method expect product_qty + # units + rule: sum(move_lines.mapped("product_qty")) for rule, move_lines in routing_rules.items() } if move.state == "partially_available": # consider unreserved quantity as without routing, so it will # be split if another part of the quantity need a routing moves_routing[move].setdefault(no_routing_rule, 0) - moves_routing[move][no_routing_rule] += ( + missing_reserved_uom_quantity = ( move.product_uom_qty - move.reserved_availability ) + missing_reserved_quantity = move.product_uom._compute_quantity( + missing_reserved_uom_quantity, + move.product_id.uom_id, + # this match what is done in StockMove._action_assign() + rounding_method="HALF-UP", + ) + moves_routing[move][no_routing_rule] += missing_reserved_quantity return moves_routing def _routing_splits(self, moves_routing): @@ -178,7 +186,6 @@ def _routing_splits(self, moves_routing): # The _split() method returns the same move if the qty # is the same than the move's qty, so we don't need to # explicitly check if we really need to split or not. - # FIXME _split must be called on product_qty new_move_id = move._split(qty) new_move = self.env["stock.move"].browse(new_move_id) new_move.routing_rule_id = routing_rule From 6901f38959994d8c64f55a8ab3aad53896f34655 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 23 Apr 2020 15:20:52 +0200 Subject: [PATCH 17/33] Fix assigned called twice on the same move In some conditions, new_moves can contain the same moves as self, so _action_assign() would be called twice on the same move and would have duplicate move lines. --- stock_routing_operation/models/stock_move.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index 746ec27d58..ddc7ee1253 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -193,7 +193,7 @@ def _routing_splits(self, moves_routing): new_move_per_location[routing_location.id].append(new_move_id) new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) - return self + new_moves + return self | new_moves def _apply_routing_rule_pull(self): """Apply routing operations From fb2443508317e1b3422a516e03ae092450b6d4e9 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Apr 2020 09:35:38 +0200 Subject: [PATCH 18/33] Remove dead code No longer needed after the last refactoring --- stock_routing_operation/models/__init__.py | 1 - stock_routing_operation/models/stock_quant.py | 94 ------------------- 2 files changed, 95 deletions(-) delete mode 100644 stock_routing_operation/models/stock_quant.py diff --git a/stock_routing_operation/models/__init__.py b/stock_routing_operation/models/__init__.py index e68dc48510..9e756f5e58 100644 --- a/stock_routing_operation/models/__init__.py +++ b/stock_routing_operation/models/__init__.py @@ -1,6 +1,5 @@ from . import stock_location from . import stock_move from . import stock_picking -from . import stock_quant from . import stock_routing from . import stock_routing_rule diff --git a/stock_routing_operation/models/stock_quant.py b/stock_routing_operation/models/stock_quant.py deleted file mode 100644 index 9242fc3ea4..0000000000 --- a/stock_routing_operation/models/stock_quant.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright 2019 Camptocamp (https://www.camptocamp.com) -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -"""Allow forcing reservations of quants in a location (or children) - -When the context key "gather_in_location_id" is passed, it will look -in this location or its children. - -Example:: - - moves.with_context( - gather_in_location_id=location.id, - )._action_assign() - -""" - -from odoo import models - - -class StockQuant(models.Model): - _inherit = "stock.quant" - - def _update_reserved_quantity( - self, - product_id, - location_id, - quantity, - lot_id=None, - package_id=None, - owner_id=None, - strict=False, - ): - gather_in_location_id = self.env.context.get("gather_in_location_id") - if gather_in_location_id: - location_model = self.env["stock.location"] - location_id = location_model.browse(gather_in_location_id) - result = super()._update_reserved_quantity( - product_id, - location_id, - quantity, - lot_id=lot_id, - package_id=package_id, - owner_id=owner_id, - strict=strict, - ) - return result - - def _update_available_quantity( - self, - product_id, - location_id, - quantity, - lot_id=None, - package_id=None, - owner_id=None, - in_date=None, - ): - gather_in_location_id = self.env.context.get("gather_in_location_id") - if gather_in_location_id: - location_model = self.env["stock.location"] - location_id = location_model.browse(gather_in_location_id) - result = super()._update_available_quantity( - product_id, - location_id, - quantity, - lot_id=lot_id, - package_id=package_id, - owner_id=owner_id, - in_date=in_date, - ) - return result - - def _get_available_quantity( - self, - product_id, - location_id, - lot_id=None, - package_id=None, - owner_id=None, - strict=False, - allow_negative=False, - ): - gather_in_location_id = self.env.context.get("gather_in_location_id") - if gather_in_location_id: - location_model = self.env["stock.location"] - location_id = location_model.browse(gather_in_location_id) - result = super()._get_available_quantity( - product_id, - location_id, - lot_id=lot_id, - package_id=package_id, - owner_id=owner_id, - strict=strict, - ) - return result From 719f6d44021bf2f5f8020978ebf9c7281cfa272f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 24 Apr 2020 09:59:05 +0200 Subject: [PATCH 19/33] Extract methods in the push method --- stock_routing_operation/models/stock_move.py | 46 ++++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index ddc7ee1253..d847b16e45 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -333,27 +333,47 @@ def _apply_routing_rule_push(self): ("id", "parent_of", move.location_id.id), ] ): - move.picking_type_id = routing_rule.picking_type_id - pickings_to_check_for_emptiness |= move.picking_id - move._assign_picking() + picking = move.picking_id + move._routing_push_switch_picking_type(routing_rule) + pickings_to_check_for_emptiness |= picking else: # Fall here when the source location is unrelated to the # routing's one. Redirect the move and move line to go through # the routing and add a new move after it to reach the # destination of the routing. - move.location_dest_id = routing_rule.location_src_id - move.move_line_ids.location_dest_id = routing_rule.location_src_id - routing_move = move._insert_routing_moves( - routing_rule.picking_type_id, - routing_rule.location_src_id, - routing_rule.location_dest_id, - ) - routing_move._assign_picking() - # recursively apply chain in case we have several routing steps - move._chain_apply_routing() + move._routing_push_insert_move(routing_rule) pickings_to_check_for_emptiness._routing_operation_handle_empty() + def _routing_push_switch_picking_type(self, routing_rule): + """Switch the picking type of the move in place + + In this case, do not insert a new move but only change the picking type + and reassign to a picking, so the move will be included in a transfer + of the same type or a new transfer will be created. + """ + self.picking_type_id = routing_rule.picking_type_id + self._assign_picking() + + def _routing_push_insert_move(self, routing_rule): + """Change destination of the current move and add a move after it + + The routing rules are applied on the move after its destination has + been changed in case it would need again a new move or a switch of + picking type. + """ + self.location_dest_id = routing_rule.location_src_id + self.move_line_ids.location_dest_id = routing_rule.location_src_id + routing_move = self._insert_routing_moves( + routing_rule.picking_type_id, + routing_rule.location_src_id, + routing_rule.location_dest_id, + ) + routing_move._assign_picking() + # recursively apply chain in case we have several routing steps (since + # the destination of the move has changed, a new push rule may apply) + self._chain_apply_routing() + def _insert_routing_moves(self, picking_type, location, destination): """Create a chained move for a routing rule""" self.ensure_one() From 9ddc2bbe80d72c8109fd31f6d8ddd3f2a2f19ec1 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 29 Apr 2020 08:15:33 +0200 Subject: [PATCH 20/33] Optimize calls to _action_assign() * Prevent calling _action_assign() twice in case the savepoint was released * Do not apply routing rules on the move we just routed as the routing rule to apply will be the same and already done * Add comments explaining why they are called --- stock_routing_operation/models/stock_move.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index d847b16e45..b2f7719220 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -65,7 +65,11 @@ def _split_and_apply_routing(self): """ moves_routing = self._prepare_routing_pull() if not moves_routing: - return self + # When we have no routing rules, _prepare_routing_pull already + # called _action_assign(), returning an empty recordset will + # prevent the caller of the method to call _action_assign() again + # on the same moves + return self.browse() # apply the routing moves = self._routing_splits(moves_routing) moves._apply_routing_rule_pull() @@ -251,7 +255,13 @@ def _apply_routing_rule_pull(self): pickings_to_check_for_emptiness |= move.picking_id move._assign_picking() - move._action_assign() + # Note: we have to call _action_assign() here because if the move + # has been split because of partial availability, we want to ensure + # to reserve the move which has been "routed" first. Even if + # _action_assign() is called again, it should not be an issue + # because the move's state will be "assigned" and will be excluded + # by the method. + move.with_context(exclude_apply_routing_operation=True)._action_assign() pickings_to_check_for_emptiness._routing_operation_handle_empty() From 2f52ab9f74cca3b8c34c7f8ae64b1d03b3ba56ad Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 29 Apr 2020 12:18:57 +0200 Subject: [PATCH 21/33] Keep final destination on push routing rules In case of a put-away, when using a push routing rule, we want to keep the expected destination. For instance, we have a move: * Input -> Highbay (draft) It's assigned and the put-away rules compute a move line going to Highbay/X1Y2. When a routing is applied, before this commit, it would add a move at the end, it would look like: * Input -> Handover (assigned) * Handover -> Highbay (confirmed) On reservation of the second move (Handover -> Highbay), it would compute again the put away rules to find a place in the whole highbay. With the change now, the moves would look like: * Input -> Handover (assigned) * Handover -> Highbay/X1Y2 (confirmed) So the move line cannot go elsewhere. To implement this, I decided to remove the 'routing_rule_id' field stored on 'stock.move', as this field is only needed by the methods _apply_routing_rule_pull/push and never afterwards. The original location has to be propagated to these methods as well, so now there is a single dict of values used to apply the rules. --- stock_routing_operation/models/stock_move.py | 125 ++++++++++++------ .../tests/test_routing_pull.py | 33 +---- .../tests/test_routing_push.py | 47 +++---- 3 files changed, 109 insertions(+), 96 deletions(-) diff --git a/stock_routing_operation/models/stock_move.py b/stock_routing_operation/models/stock_move.py index b2f7719220..e33dd4c7c5 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_routing_operation/models/stock_move.py @@ -1,23 +1,30 @@ # Copyright 2019-2020 Camptocamp (https://www.camptocamp.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) import uuid -from itertools import chain +from collections import defaultdict, namedtuple from psycopg2 import sql -from odoo import fields, models +from odoo import models class StockMove(models.Model): _inherit = "stock.move" - routing_rule_id = fields.Many2one( - comodel_name="stock.routing.rule", - copy=False, - help="Technical field. Store the routing rule that has been" - " selected for the move.", + RoutingDetails = namedtuple( + "RoutingDetails", + # rule is the routing rule to apply + # push_original_destination is used only for push rules, store the + # original location + "rule push_original_destination", ) + def _no_routing_details(self): + return self.RoutingDetails( + rule=self.env["stock.routing.rule"].browse(), + push_original_destination=self.env["stock.location"].browse(), + ) + def write(self, values): result = super().write(values) if not self.env.context.get("__applying_routing_rule") and values.get( @@ -35,11 +42,20 @@ def _chain_apply_routing(self): if not self: return move_routing_rules = self.env["stock.routing"]._routing_rule_for_moves(self) + moves_with_routing_details = {} for move, rule in move_routing_rules.items(): if rule: - move.routing_rule_id = rule - self._apply_routing_rule_pull() - self._apply_routing_rule_push() + moves_with_routing_details[move] = self.RoutingDetails( + rule=rule, + # Never change the destination of push rules in a chain, + # it's done only on the first move re-routed + push_original_destination=self.env["stock.location"].browse(), + ) + else: + moves_with_routing_details[move] = self._no_routing_details() + + self._apply_routing_rule_pull(moves_with_routing_details) + self._apply_routing_rule_push(moves_with_routing_details) def _action_assign(self): if self.env.context.get("exclude_apply_routing_operation"): @@ -71,9 +87,10 @@ def _split_and_apply_routing(self): # on the same moves return self.browse() # apply the routing - moves = self._routing_splits(moves_routing) - moves._apply_routing_rule_pull() - moves._apply_routing_rule_push() + moves_with_routing_details = self._routing_splits(moves_routing) + moves = self.browse(move.id for move in moves_with_routing_details) + moves._apply_routing_rule_pull(moves_with_routing_details) + moves._apply_routing_rule_push(moves_with_routing_details) return moves def _prepare_routing_pull(self): @@ -103,8 +120,9 @@ def _prepare_routing_pull(self): super()._action_assign() moves_routing = self._routing_compute_rules() - - if not any(rule for routing in moves_routing.values() for rule in routing): + if not any( + details.rule for routing in moves_routing.values() for details in routing + ): # no routing to apply, so the reservations done by _action_assign # are valid and we can resolve to a normal flow self.env["base"].flush() @@ -133,7 +151,7 @@ def _routing_compute_rules(self): self ) moves_routing = {} - no_routing_rule = self.env["stock.routing.rule"].browse() + no_loc = self.env["stock.location"].browse() for move in self: if move.state not in ("assigned", "partially_available"): continue @@ -143,17 +161,26 @@ def _routing_compute_rules(self): # take from each location, so we'll be able to split the move # if needed. routing_rules = move_routing_rules[move] - moves_routing[move] = { - # use product_qty and not product_uom_qty, because we'll use - # this for the _split() and this method expect product_qty - # units - rule: sum(move_lines.mapped("product_qty")) - for rule, move_lines in routing_rules.items() - } + moves_routing[move] = {} + # use product_qty and not product_uom_qty, because we'll use + # this for the _split() and this method expect product_qty + # units + for rule, move_lines in routing_rules.items(): + if rule.method == "push": + dests = defaultdict(lambda: 0.0) + for line in move_lines: + dests[line.location_dest_id] += line.product_qty + for destination, qty in dests.items(): + moves_routing[move][ + self.RoutingDetails(rule, destination) + ] = qty + else: + moves_routing[move][self.RoutingDetails(rule, no_loc)] = sum( + move_lines.mapped("product_qty") + ) if move.state == "partially_available": # consider unreserved quantity as without routing, so it will # be split if another part of the quantity need a routing - moves_routing[move].setdefault(no_routing_rule, 0) missing_reserved_uom_quantity = ( move.product_uom_qty - move.reserved_availability ) @@ -163,7 +190,9 @@ def _routing_compute_rules(self): # this match what is done in StockMove._action_assign() rounding_method="HALF-UP", ) - moves_routing[move][no_routing_rule] += missing_reserved_quantity + routing_details = self._no_routing_details() + moves_routing[move].setdefault(routing_details, 0) + moves_routing[move][routing_details] += missing_reserved_quantity return moves_routing def _routing_splits(self, moves_routing): @@ -171,18 +200,19 @@ def _routing_splits(self, moves_routing): This method splits the move in as many routing pull rules they have. - This method writes "routing_rule_id" on the moves, this rule will be - used by ``_apply_routing_rule_pull`` / ``_apply_routing_rule_push`` + This method returns the routing details that will be passed to + ``_apply_routing_rule_pull`` / ``_apply_routing_rule_push`` to apply + them. """ - new_move_per_location = {} + moves_with_routing_details = {} for move, routing_quantities in moves_routing.items(): - for routing_rule, qty in routing_quantities.items(): + moves_with_routing_details[move] = self._no_routing_details() + for routing_details, qty in routing_quantities.items(): # When the rule is empty, it means we have no routing # operation for the move, so we have nothing to do, # it will behave as normally. - if not routing_rule: + if not routing_details.rule: continue - routing_location = routing_rule.location_src_id # If we have a routing operation, the move may have several # lines with different routing operations (or lines with # a routing operation, lines without). We split the lines @@ -192,14 +222,11 @@ def _routing_splits(self, moves_routing): # explicitly check if we really need to split or not. new_move_id = move._split(qty) new_move = self.env["stock.move"].browse(new_move_id) - new_move.routing_rule_id = routing_rule - new_move_per_location.setdefault(routing_location.id, []) - new_move_per_location[routing_location.id].append(new_move_id) + moves_with_routing_details[new_move] = routing_details - new_moves = self.browse(chain.from_iterable(new_move_per_location.values())) - return self | new_moves + return moves_with_routing_details - def _apply_routing_rule_pull(self): + def _apply_routing_rule_pull(self, routing_details): """Apply routing operations When a move has a routing operation configured on its location and the @@ -210,7 +237,7 @@ def _apply_routing_rule_pull(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: - routing_rule = move.routing_rule_id + routing_rule = routing_details[move].rule if not routing_rule.method == "pull": continue @@ -307,7 +334,7 @@ def _routing_pull_insert_move(self, routing_rule, picking_type, destination): ) routing_move._chain_apply_routing() - def _apply_routing_rule_push(self): + def _apply_routing_rule_push(self, routing_details): """Apply routing operations When a move has a routing operation configured on its location and the @@ -318,9 +345,10 @@ def _apply_routing_rule_push(self): """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: + move_routing_details = routing_details[move] # At this point, we should not have lines with different source # locations, they have been split by _routing_splits() - routing_rule = move.routing_rule_id + routing_rule = move_routing_details.rule if not routing_rule.method == "push": continue if move.picking_id.picking_type_id == routing_rule.picking_type_id: @@ -351,7 +379,9 @@ def _apply_routing_rule_push(self): # routing's one. Redirect the move and move line to go through # the routing and add a new move after it to reach the # destination of the routing. - move._routing_push_insert_move(routing_rule) + move._routing_push_insert_move( + routing_rule, move_routing_details.push_original_destination + ) pickings_to_check_for_emptiness._routing_operation_handle_empty() @@ -365,19 +395,28 @@ def _routing_push_switch_picking_type(self, routing_rule): self.picking_type_id = routing_rule.picking_type_id self._assign_picking() - def _routing_push_insert_move(self, routing_rule): + def _routing_push_insert_move(self, routing_rule, destination): """Change destination of the current move and add a move after it The routing rules are applied on the move after its destination has been changed in case it would need again a new move or a switch of picking type. + + When the new move is the one that triggered the routing in the first + place (put-away assigned in the final Bin for instance), the initial + destination of the original move line is kept so we are sure the move + goes to the correct place. For all other moves, the destination is the + one of the picking type to respect the locations chain. """ self.location_dest_id = routing_rule.location_src_id self.move_line_ids.location_dest_id = routing_rule.location_src_id routing_move = self._insert_routing_moves( routing_rule.picking_type_id, routing_rule.location_src_id, - routing_rule.location_dest_id, + # destination should be set only for the initial move, which + # was the destination location of the stock.move.line generated + # for the initial move (by a put-away rule for instance). + destination or routing_rule.location_dest_id, ) routing_move._assign_picking() # recursively apply chain in case we have several routing steps (since diff --git a/stock_routing_operation/tests/test_routing_pull.py b/stock_routing_operation/tests/test_routing_pull.py index 1ceb9c39ed..5a454d36e3 100644 --- a/stock_routing_operation/tests/test_routing_pull.py +++ b/stock_routing_operation/tests/test_routing_pull.py @@ -3,7 +3,7 @@ from odoo.tests import common -class TestRoutingPull(common.SavepointCase): +class TestRoutingPullCommon(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -170,6 +170,8 @@ def process_operations(self, move): move.move_line_ids.qty_done = qty move.picking_id.action_done() + +class TestRoutingPull(TestRoutingPullCommon): def test_change_location_to_routing_operation(self): pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] @@ -182,8 +184,6 @@ def test_change_location_to_routing_operation(self): ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) - ml = move_a.move_line_ids self.assertEqual(len(ml), 1) self.assert_src_highbay_1_2(ml) @@ -240,8 +240,6 @@ def test_several_moves(self): move_b_p2 = cust_moves.filtered(lambda r: r.product_id == product2) pick_picking.action_assign() - self.assertEqual(move_a_p1.routing_rule_id, self.routing.rule_ids) - self.assertFalse(move_a_p2.routing_rule_id) # At this point, we should have 3 stock.picking: # @@ -360,14 +358,12 @@ def test_several_move_lines(self): move_a1 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 4 ) - self.assertFalse(move_a1.routing_rule_id) move_a2 = pick_picking.move_lines.filtered( lambda move: move.product_uom_qty == 6 ) move_ho = move_a2.move_orig_ids # move_ho is the move which has been split from move_a and moved # to a different picking type - self.assertEqual(move_ho.routing_rule_id, self.routing.rule_ids) self.assertTrue(move_ho) # At this point, we should have 3 stock.picking: @@ -468,7 +464,6 @@ def test_destination_parent_tree_change_picking_type_and_dest(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -530,7 +525,6 @@ def test_destination_child_tree_change_picking_type(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) ml = move_a.move_line_ids self.assertEqual(len(ml), 1) @@ -579,7 +573,6 @@ def test_domain_ignore_move(self): ) pick_picking.action_assign() - self.assertFalse(move_a.routing_rule_id) self.assertEqual(move_a.picking_id.picking_type_id, self.wh.pick_type_id) # the original chaining stays the same: we don't add any move here self.assertFalse(move_a.move_orig_ids) @@ -601,7 +594,6 @@ def test_domain_include_move(self): ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_a.picking_id.picking_type_id, self.pick_type_routing_op) self.assertFalse(move_a.move_orig_ids) self.assertNotEqual(move_a.move_dest_ids, move_b) @@ -619,7 +611,6 @@ def test_partial_qty(self): self.assertEqual(move_a.picking_id, pick_picking) self.assertEqual(move_a.product_qty, 2) self.assertEqual(move_a.state, "confirmed") - self.assertFalse(move_a.routing_rule_id) # we have a new waiting move in the PICK with a qty of 8 split_move = move_a.move_dest_ids.move_orig_ids - move_a @@ -629,7 +620,6 @@ def test_partial_qty(self): # we have a new move for the routing before the split move routing_move = split_move.move_orig_ids - self.assertEqual(routing_move.routing_rule_id, self.routing.rule_ids) self.assertRecordValues( routing_move, [ @@ -666,7 +656,7 @@ def test_change_dest_move_source(self): "default_location_dest_id": self.customer_loc.id, } ) - routing_delivery = self.env["stock.routing"].create( + self.env["stock.routing"].create( { "location_id": area1.id, "rule_ids": [ @@ -692,11 +682,9 @@ def test_change_dest_move_source(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_b.location_id, area1) # routing has been applied - self.assertEqual(move_b.routing_rule_id, routing_delivery.rule_ids) self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) def test_change_dest_move_source_split(self): @@ -722,7 +710,7 @@ def test_change_dest_move_source_split(self): "default_location_dest_id": self.customer_loc.id, } ) - routing_delivery = self.env["stock.routing"].create( + self.env["stock.routing"].create( { "location_id": area1.id, "rule_ids": [ @@ -789,7 +777,6 @@ def test_change_dest_move_source_split(self): { "move_orig_ids": [], "move_dest_ids": move_b.ids, - "routing_rule_id": False, "state": "confirmed", "location_id": self.wh.lot_stock_id.id, "location_dest_id": self.wh.wh_output_stock_loc_id.id, @@ -797,7 +784,6 @@ def test_change_dest_move_source_split(self): { "move_orig_ids": move_a.ids, "move_dest_ids": [], - "routing_rule_id": False, "state": "waiting", "location_id": self.wh.wh_output_stock_loc_id.id, "location_dest_id": self.customer_loc.id, @@ -805,7 +791,6 @@ def test_change_dest_move_source_split(self): { "move_orig_ids": [], "move_dest_ids": move_d.ids, - "routing_rule_id": self.routing.rule_ids.id, "state": "assigned", "location_id": self.location_hb.id, "location_dest_id": area1.id, @@ -813,7 +798,6 @@ def test_change_dest_move_source_split(self): { "move_orig_ids": move_c.ids, "move_dest_ids": [], - "routing_rule_id": routing_delivery.rule_ids.id, "state": "waiting", "location_id": area1.id, "location_dest_id": self.customer_loc.id, @@ -855,7 +839,7 @@ def test_change_dest_move_source_chain(self): "default_location_dest_id": location_qa.id, } ) - routing_qa = self.env["stock.routing"].create( + self.env["stock.routing"].create( { "location_id": self.location_handover.id, "rule_ids": [ @@ -880,7 +864,7 @@ def test_change_dest_move_source_chain(self): "default_location_dest_id": self.customer_loc.id, } ) - routing_delivery = self.env["stock.routing"].create( + self.env["stock.routing"].create( { "location_id": location_qa.id, "rule_ids": [ @@ -906,7 +890,6 @@ def test_change_dest_move_source_chain(self): self.location_hb_1_2, move_a.product_id, 100 ) pick_picking.action_assign() - self.assertEqual(move_a.routing_rule_id, self.routing.rule_ids) move_middle = move_a.move_dest_ids self.assertNotEqual(move_middle, move_b) @@ -918,8 +901,6 @@ def test_change_dest_move_source_chain(self): self.assert_dest_customer(move_b) # routing has been applied - self.assertEqual(move_middle.routing_rule_id, routing_qa.rule_ids) self.assertEqual(move_middle.picking_id.picking_type_id, pick_type_routing_qa) - self.assertEqual(move_b.routing_rule_id, routing_delivery.rule_ids) self.assertEqual(move_b.picking_id.picking_type_id, pick_type_routing_delivery) diff --git a/stock_routing_operation/tests/test_routing_push.py b/stock_routing_operation/tests/test_routing_push.py index 71031a5536..960c38adaf 100644 --- a/stock_routing_operation/tests/test_routing_push.py +++ b/stock_routing_operation/tests/test_routing_push.py @@ -220,7 +220,6 @@ def test_change_location_to_routing_operation(self): self.assert_src_input(move_b) # the move stays B stays on the same dest location self.assert_dest_handover(move_b) - self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) # we should have a move added after move_b to put # the goods in their final location @@ -229,7 +228,7 @@ def test_change_location_to_routing_operation(self): # move: the move line will be in the sub-locations (handover) self.assert_src_handover(routing_move) - self.assert_dest_highbay(routing_move) + self.assert_dest_highbay_1_2(routing_move) self.assertEquals(routing_move.picking_type_id, self.pick_type_routing_op) self.assertEquals( @@ -331,7 +330,7 @@ def test_several_moves(self): self.assertEqual(routing_picking.move_lines, routing_move) self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) self.assert_src_handover(routing_picking) - self.assert_dest_highbay(routing_picking) + self.assert_dest_highbay_1_2(routing_picking) # check move and move line A for product1 self.assert_src_supplier(move_a_p1) @@ -369,7 +368,7 @@ def test_several_moves(self): # check routing move for product1 self.assert_src_handover(routing_move) - self.assert_dest_highbay(routing_move) + self.assert_dest_highbay_1_2(routing_move) # Deliver the internal picking (moves B), # the routing move for product1 should be assigned, @@ -422,14 +421,21 @@ def test_several_move_lines(self): moves_routing = { move_b: { # qty of 6 using this routing rule - self.routing.rule_ids: 6, + self.env["stock.move"].RoutingDetails( + self.routing.rule_ids, self.location_hb_1_2 + ): 6, # no routing for the 4 remaining - self.env["stock.routing"].browse(): 4, + self.env["stock.move"].RoutingDetails( + self.env["stock.routing"].browse(), self.env["stock.move"].browse() + ): 4, } } # this is what is done in in _action_assign() - moves = move_b._routing_splits(moves_routing) - moves._apply_routing_rule_push() + moves_with_routing_details = move_b._routing_splits(moves_routing) + moves = self.env["stock.move"].browse( + move.id for move in moves_with_routing_details + ) + moves._apply_routing_rule_push(moves_with_routing_details) moves._action_assign() # At this point, we should have this @@ -480,10 +486,6 @@ def test_several_move_lines(self): self.assertEqual(len(routing_move), 1) routing_picking = routing_move.picking_id - self.assertEqual(move_b_handover.routing_rule_id, self.routing.rule_ids) - self.assertFalse(move_b_shelf.routing_rule_id) - self.assertFalse(routing_move.routing_rule_id) - # check chaining self.assertEqual(move_a.move_dest_ids, move_b_shelf + move_b_handover) self.assertFalse(move_b_shelf.move_dest_ids) @@ -513,7 +515,7 @@ def test_several_move_lines(self): self.assertEqual(routing_picking.move_lines, routing_move) self.assertEqual(routing_picking.picking_type_id, self.pick_type_routing_op) self.assert_src_handover(routing_picking) - self.assert_dest_highbay(routing_picking) + self.assert_dest_highbay_1_2(routing_picking) # check move and move line A self.assert_src_supplier(move_a) @@ -549,7 +551,7 @@ def test_several_move_lines(self): # check routing move for product1 self.assert_src_handover(routing_move) - self.assert_dest_highbay(routing_move) + self.assert_dest_highbay_1_2(routing_move) # Deliver the internal picking (moves B), # the routing move should be assigned, @@ -610,7 +612,6 @@ def test_classify_picking_type_sub_location(self): self.assertEqual(move_a.state, "done") # move B is classified in a new picking - self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_b.state, "assigned") self.assertEqual(move_b.location_id, input_ho_location) self.assertEqual(move_b.move_line_ids.location_id, input_ho_location) @@ -647,7 +648,6 @@ def test_picking_type_super_location_extra_move(self): self.assertEqual(move_a.state, "done") - self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) self.assertEqual(move_b.state, "assigned") self.assert_src_input(move_b) self.assertEqual(move_b.location_dest_id, input_ho_location) @@ -655,8 +655,8 @@ def test_picking_type_super_location_extra_move(self): # we have an extra move to reach the Highbay from Input/Handover extra_move = move_b.move_dest_ids - self.assert_dest_highbay(extra_move) - self.assert_dest_highbay(extra_move.picking_id) + self.assert_dest_highbay_1_2(extra_move) + self.assert_dest_highbay_1_2(extra_move.picking_id) self.assertEqual( extra_move.picking_id.picking_type_id, self.pick_type_routing_op ) @@ -674,7 +674,6 @@ def test_domain_ignore_move(self): move_a = in_picking.move_lines move_b = internal_picking.move_lines self.process_operations(move_a) - self.assertFalse(move_b.routing_rule_id) self.assertEqual(move_b.picking_id.picking_type_id, self.wh.int_type_id) # the original chaining stays the same: we don't add any move here self.assertFalse(move_a.move_orig_ids) @@ -692,7 +691,6 @@ def test_domain_include_move(self): move_a = in_picking.move_lines move_b = internal_picking.move_lines self.process_operations(move_a) - self.assertEqual(move_b.routing_rule_id, self.routing.rule_ids) # we have an extra move self.assertFalse(move_a.move_orig_ids) self.assertEqual(move_a.move_dest_ids, move_b) @@ -718,7 +716,7 @@ def test_chain(self): "default_location_dest_id": self.location_handover.id, } ) - routing_pre_handover = self.env["stock.routing"].create( + self.env["stock.routing"].create( { "location_id": self.location_handover.id, "rule_ids": [ @@ -751,7 +749,6 @@ def test_chain(self): { "move_orig_ids": [], "move_dest_ids": move_b.ids, - "routing_rule_id": False, "state": "done", "location_id": self.supplier_loc.id, "location_dest_id": self.wh.wh_input_stock_loc_id.id, @@ -759,8 +756,6 @@ def test_chain(self): { "move_orig_ids": move_a.ids, "move_dest_ids": move_pre_handover.ids, - # only the last applied rule is kept... - "routing_rule_id": routing_pre_handover.rule_ids.id, "state": "assigned", "location_id": self.wh.wh_input_stock_loc_id.id, "location_dest_id": location_pre_handover.id, @@ -768,7 +763,6 @@ def test_chain(self): { "move_orig_ids": move_b.ids, "move_dest_ids": move_hb.ids, - "routing_rule_id": False, "state": "waiting", "location_id": location_pre_handover.id, "location_dest_id": self.location_handover.id, @@ -776,10 +770,9 @@ def test_chain(self): { "move_orig_ids": move_pre_handover.ids, "move_dest_ids": [], - "routing_rule_id": False, "state": "waiting", "location_id": self.location_handover.id, - "location_dest_id": self.location_hb.id, + "location_dest_id": self.location_hb_1_2.id, }, ], ) From 3fc53bb2ea9359f658d2c1196bf5321d4b615b1c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 30 Apr 2020 15:07:01 +0200 Subject: [PATCH 22/33] Rename stock_routing_operation to stock_dynamic_routing --- .../odoo/addons/stock_dynamic_routing | 1 + .../setup.py | 0 .../odoo/addons/stock_routing_operation | 1 - .../README.rst | 0 .../__init__.py | 0 .../__manifest__.py | 4 +- .../demo/stock_location_demo.xml | 0 .../demo/stock_picking_type_demo.xml | 0 .../demo/stock_routing_demo.xml | 0 .../models/__init__.py | 0 .../models/stock_location.py | 0 .../models/stock_move.py | 52 +++++++++---------- .../models/stock_picking.py | 6 +-- .../models/stock_routing.py | 2 +- .../models/stock_routing_rule.py | 2 +- .../readme/CONFIGURE.rst | 0 .../readme/CONTRIBUTORS.rst | 0 .../readme/DESCRIPTION.rst | 10 ++-- .../readme/USAGE.rst | 0 .../security/ir.model.access.csv | 0 .../static/description/index.html | 0 .../tests/__init__.py | 0 .../tests/test_routing_pull.py | 6 +-- .../tests/test_routing_push.py | 6 +-- .../views/stock_routing_views.xml | 0 25 files changed, 45 insertions(+), 45 deletions(-) create mode 120000 setup/stock_dynamic_routing/odoo/addons/stock_dynamic_routing rename setup/{stock_routing_operation => stock_dynamic_routing}/setup.py (100%) delete mode 120000 setup/stock_routing_operation/odoo/addons/stock_routing_operation rename {stock_routing_operation => stock_dynamic_routing}/README.rst (100%) rename {stock_routing_operation => stock_dynamic_routing}/__init__.py (100%) rename {stock_routing_operation => stock_dynamic_routing}/__manifest__.py (84%) rename {stock_routing_operation => stock_dynamic_routing}/demo/stock_location_demo.xml (100%) rename {stock_routing_operation => stock_dynamic_routing}/demo/stock_picking_type_demo.xml (100%) rename {stock_routing_operation => stock_dynamic_routing}/demo/stock_routing_demo.xml (100%) rename {stock_routing_operation => stock_dynamic_routing}/models/__init__.py (100%) rename {stock_routing_operation => stock_dynamic_routing}/models/stock_location.py (100%) rename {stock_routing_operation => stock_dynamic_routing}/models/stock_move.py (92%) rename {stock_routing_operation => stock_dynamic_routing}/models/stock_picking.py (84%) rename {stock_routing_operation => stock_dynamic_routing}/models/stock_routing.py (99%) rename {stock_routing_operation => stock_dynamic_routing}/models/stock_routing_rule.py (98%) rename {stock_routing_operation => stock_dynamic_routing}/readme/CONFIGURE.rst (100%) rename {stock_routing_operation => stock_dynamic_routing}/readme/CONTRIBUTORS.rst (100%) rename {stock_routing_operation => stock_dynamic_routing}/readme/DESCRIPTION.rst (79%) rename {stock_routing_operation => stock_dynamic_routing}/readme/USAGE.rst (100%) rename {stock_routing_operation => stock_dynamic_routing}/security/ir.model.access.csv (100%) rename {stock_routing_operation => stock_dynamic_routing}/static/description/index.html (100%) rename {stock_routing_operation => stock_dynamic_routing}/tests/__init__.py (100%) rename {stock_routing_operation => stock_dynamic_routing}/tests/test_routing_pull.py (99%) rename {stock_routing_operation => stock_dynamic_routing}/tests/test_routing_push.py (99%) rename {stock_routing_operation => stock_dynamic_routing}/views/stock_routing_views.xml (100%) diff --git a/setup/stock_dynamic_routing/odoo/addons/stock_dynamic_routing b/setup/stock_dynamic_routing/odoo/addons/stock_dynamic_routing new file mode 120000 index 0000000000..ad8c54a0cb --- /dev/null +++ b/setup/stock_dynamic_routing/odoo/addons/stock_dynamic_routing @@ -0,0 +1 @@ +../../../../stock_dynamic_routing \ No newline at end of file diff --git a/setup/stock_routing_operation/setup.py b/setup/stock_dynamic_routing/setup.py similarity index 100% rename from setup/stock_routing_operation/setup.py rename to setup/stock_dynamic_routing/setup.py diff --git a/setup/stock_routing_operation/odoo/addons/stock_routing_operation b/setup/stock_routing_operation/odoo/addons/stock_routing_operation deleted file mode 120000 index d8b3613b6c..0000000000 --- a/setup/stock_routing_operation/odoo/addons/stock_routing_operation +++ /dev/null @@ -1 +0,0 @@ -../../../../stock_routing_operation \ No newline at end of file diff --git a/stock_routing_operation/README.rst b/stock_dynamic_routing/README.rst similarity index 100% rename from stock_routing_operation/README.rst rename to stock_dynamic_routing/README.rst diff --git a/stock_routing_operation/__init__.py b/stock_dynamic_routing/__init__.py similarity index 100% rename from stock_routing_operation/__init__.py rename to stock_dynamic_routing/__init__.py diff --git a/stock_routing_operation/__manifest__.py b/stock_dynamic_routing/__manifest__.py similarity index 84% rename from stock_routing_operation/__manifest__.py rename to stock_dynamic_routing/__manifest__.py index ffc54552a3..54dafd9509 100644 --- a/stock_routing_operation/__manifest__.py +++ b/stock_dynamic_routing/__manifest__.py @@ -1,7 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) { - "name": "Stock Routing Operations", - "summary": "Add extra routing operations for special locations", + "name": "Stock Dynamic Routing", + "summary": "Dynamic routing for special locations", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/stock-logistics-warehouse", "category": "Warehouse Management", diff --git a/stock_routing_operation/demo/stock_location_demo.xml b/stock_dynamic_routing/demo/stock_location_demo.xml similarity index 100% rename from stock_routing_operation/demo/stock_location_demo.xml rename to stock_dynamic_routing/demo/stock_location_demo.xml diff --git a/stock_routing_operation/demo/stock_picking_type_demo.xml b/stock_dynamic_routing/demo/stock_picking_type_demo.xml similarity index 100% rename from stock_routing_operation/demo/stock_picking_type_demo.xml rename to stock_dynamic_routing/demo/stock_picking_type_demo.xml diff --git a/stock_routing_operation/demo/stock_routing_demo.xml b/stock_dynamic_routing/demo/stock_routing_demo.xml similarity index 100% rename from stock_routing_operation/demo/stock_routing_demo.xml rename to stock_dynamic_routing/demo/stock_routing_demo.xml diff --git a/stock_routing_operation/models/__init__.py b/stock_dynamic_routing/models/__init__.py similarity index 100% rename from stock_routing_operation/models/__init__.py rename to stock_dynamic_routing/models/__init__.py diff --git a/stock_routing_operation/models/stock_location.py b/stock_dynamic_routing/models/stock_location.py similarity index 100% rename from stock_routing_operation/models/stock_location.py rename to stock_dynamic_routing/models/stock_location.py diff --git a/stock_routing_operation/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py similarity index 92% rename from stock_routing_operation/models/stock_move.py rename to stock_dynamic_routing/models/stock_move.py index e33dd4c7c5..74aaaaf271 100644 --- a/stock_routing_operation/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -58,7 +58,7 @@ def _chain_apply_routing(self): self._apply_routing_rule_push(moves_with_routing_details) def _action_assign(self): - if self.env.context.get("exclude_apply_routing_operation"): + if self.env.context.get("exclude_apply_dynamic_routing"): super()._action_assign() else: # these methods will call _action_assign in a savepoint @@ -187,7 +187,7 @@ def _routing_compute_rules(self): missing_reserved_quantity = move.product_uom._compute_quantity( missing_reserved_uom_quantity, move.product_id.uom_id, - # this match what is done in StockMove._action_assign() + # this matches what is done in StockMove._action_assign() rounding_method="HALF-UP", ) routing_details = self._no_routing_details() @@ -208,18 +208,18 @@ def _routing_splits(self, moves_routing): for move, routing_quantities in moves_routing.items(): moves_with_routing_details[move] = self._no_routing_details() for routing_details, qty in routing_quantities.items(): - # When the rule is empty, it means we have no routing - # operation for the move, so we have nothing to do, - # it will behave as normally. + # When the rule is empty, it means we have no dynamic routing + # for the move, so we have nothing to do, it will behave as + # normally. if not routing_details.rule: continue - # If we have a routing operation, the move may have several - # lines with different routing operations (or lines with - # a routing operation, lines without). We split the lines - # according to these. - # The _split() method returns the same move if the qty - # is the same than the move's qty, so we don't need to - # explicitly check if we really need to split or not. + # If we have a dynamic routing, the move may have several + # lines with different routing (or lines with a dynamic + # routing, lines without). We split the lines according to + # these. + # The _split() method returns the same move if the qty is the + # same than the move's qty, so we don't need to explicitly + # check if we really need to split or not. new_move_id = move._split(qty) new_move = self.env["stock.move"].browse(new_move_id) moves_with_routing_details[new_move] = routing_details @@ -227,13 +227,12 @@ def _routing_splits(self, moves_routing): return moves_with_routing_details def _apply_routing_rule_pull(self, routing_details): - """Apply routing operations + """Apply pull dynamic routing - When a move has a routing operation configured on its location and the - destination of the move does not match the destination of the routing - operation, this method updates the move's destination and it's picking - type with the routing operation ones and creates a new chained move - after it. + When a move has a dynamic routing configured on its location and the + destination of the move does not match the destination of the routing, + this method updates the move's destination and it's picking type with + the routing ones and creates a new chained move after it. """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: @@ -288,9 +287,9 @@ def _apply_routing_rule_pull(self, routing_details): # _action_assign() is called again, it should not be an issue # because the move's state will be "assigned" and will be excluded # by the method. - move.with_context(exclude_apply_routing_operation=True)._action_assign() + move.with_context(exclude_apply_dynamic_routing=True)._action_assign() - pickings_to_check_for_emptiness._routing_operation_handle_empty() + pickings_to_check_for_emptiness._dynamic_routing_handle_empty() def _routing_pull_switch_destination(self, routing_rule): """Switch the destination of the move in place @@ -335,13 +334,12 @@ def _routing_pull_insert_move(self, routing_rule, picking_type, destination): routing_move._chain_apply_routing() def _apply_routing_rule_push(self, routing_details): - """Apply routing operations + """Apply push dynamic routing - When a move has a routing operation configured on its location and the - destination of the move does not match the destination of the routing - operation, this method updates the move's destination and it's picking - type with the routing operation ones and creates a new chained move - after it. + When a move has a dynamic routing configured on its location and the + destination of the move does not match the destination of the routing, + this method updates the move's destination and it's picking type with + the routing ones and creates a new chained move after it. """ pickings_to_check_for_emptiness = self.env["stock.picking"] for move in self: @@ -383,7 +381,7 @@ def _apply_routing_rule_push(self, routing_details): routing_rule, move_routing_details.push_original_destination ) - pickings_to_check_for_emptiness._routing_operation_handle_empty() + pickings_to_check_for_emptiness._dynamic_routing_handle_empty() def _routing_push_switch_picking_type(self, routing_rule): """Switch the picking type of the move in place diff --git a/stock_routing_operation/models/stock_picking.py b/stock_dynamic_routing/models/stock_picking.py similarity index 84% rename from stock_routing_operation/models/stock_picking.py rename to stock_dynamic_routing/models/stock_picking.py index fc901cef76..a297f64083 100644 --- a/stock_routing_operation/models/stock_picking.py +++ b/stock_dynamic_routing/models/stock_picking.py @@ -9,7 +9,7 @@ class StockPicking(models.Model): canceled_by_routing = fields.Boolean( default=False, help="Technical field. Indicates the transfer is" - " canceled because it was left empty after a routing operation.", + " canceled because it was left empty after a dynamic routing.", ) @api.depends("canceled_by_routing") @@ -19,8 +19,8 @@ def _compute_state(self): if picking.canceled_by_routing: picking.state = "cancel" - def _routing_operation_handle_empty(self): - """Handle pickings emptied during a routing operation""" + def _dynamic_routing_handle_empty(self): + """Handle pickings emptied during a dynamic routing""" for picking in self: if not picking.move_lines: # When the picking type changes, it will create a new picking diff --git a/stock_routing_operation/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py similarity index 99% rename from stock_routing_operation/models/stock_routing.py rename to stock_dynamic_routing/models/stock_routing.py index 70fbfebe2a..0ace5fa942 100644 --- a/stock_routing_operation/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -16,7 +16,7 @@ def _default_sequence(model): class StockRouting(models.Model): _name = "stock.routing" - _description = "Stock Routing" + _description = "Stock Dynamic Routing" _order = "sequence, id" _rec_name = "location_id" diff --git a/stock_routing_operation/models/stock_routing_rule.py b/stock_dynamic_routing/models/stock_routing_rule.py similarity index 98% rename from stock_routing_operation/models/stock_routing_rule.py rename to stock_dynamic_routing/models/stock_routing_rule.py index 087da29cc9..56a19ecb0b 100644 --- a/stock_routing_operation/models/stock_routing_rule.py +++ b/stock_dynamic_routing/models/stock_routing_rule.py @@ -10,7 +10,7 @@ class StockRoutingRule(models.Model): _name = "stock.routing.rule" - _description = "Stock Routing Rule" + _description = "Stock Dynamic Routing Rule" _order = "sequence, id" sequence = fields.Integer(default=lambda self: self._default_sequence()) diff --git a/stock_routing_operation/readme/CONFIGURE.rst b/stock_dynamic_routing/readme/CONFIGURE.rst similarity index 100% rename from stock_routing_operation/readme/CONFIGURE.rst rename to stock_dynamic_routing/readme/CONFIGURE.rst diff --git a/stock_routing_operation/readme/CONTRIBUTORS.rst b/stock_dynamic_routing/readme/CONTRIBUTORS.rst similarity index 100% rename from stock_routing_operation/readme/CONTRIBUTORS.rst rename to stock_dynamic_routing/readme/CONTRIBUTORS.rst diff --git a/stock_routing_operation/readme/DESCRIPTION.rst b/stock_dynamic_routing/readme/DESCRIPTION.rst similarity index 79% rename from stock_routing_operation/readme/DESCRIPTION.rst rename to stock_dynamic_routing/readme/DESCRIPTION.rst index d68ae1e068..afa0545c5c 100644 --- a/stock_routing_operation/readme/DESCRIPTION.rst +++ b/stock_dynamic_routing/readme/DESCRIPTION.rst @@ -1,5 +1,6 @@ -Route explains the steps you want to produce whereas the “Routing Rules” defines how operations are grouped according to their final source -and destination location. +Standard Stock Routes explain the steps you want to produce whereas the +“Dynamic Routing” defines how operations are grouped according to their final +source and destination location. This allows for example: @@ -24,13 +25,14 @@ need an extra operation: Pick(Highbay)-Handover-Pack-Ship. This is what this feature is doing: on the High-Bay location, you define a "routing rule". A routing rule selects a different operation type for the move. -The extra transfer will have the selected operation type, and be added before the chain of moves. +The extra transfer will have the selected operation type, and be added +dynamically, on reservation, before the chain of moves. When putting away: A put-away rule targets the High-Bay location. An operation Input-Highbay is created. You expect Input-Handover-Highbay. -You can configure a routing operation for the put-away on the High-Bay Location. +You can configure a dynamic routing for the put-away on the High-Bay Location. The operation type of the new Handover move will the one of the matching routing rule, and its destination will be the destination of the operation type. diff --git a/stock_routing_operation/readme/USAGE.rst b/stock_dynamic_routing/readme/USAGE.rst similarity index 100% rename from stock_routing_operation/readme/USAGE.rst rename to stock_dynamic_routing/readme/USAGE.rst diff --git a/stock_routing_operation/security/ir.model.access.csv b/stock_dynamic_routing/security/ir.model.access.csv similarity index 100% rename from stock_routing_operation/security/ir.model.access.csv rename to stock_dynamic_routing/security/ir.model.access.csv diff --git a/stock_routing_operation/static/description/index.html b/stock_dynamic_routing/static/description/index.html similarity index 100% rename from stock_routing_operation/static/description/index.html rename to stock_dynamic_routing/static/description/index.html diff --git a/stock_routing_operation/tests/__init__.py b/stock_dynamic_routing/tests/__init__.py similarity index 100% rename from stock_routing_operation/tests/__init__.py rename to stock_dynamic_routing/tests/__init__.py diff --git a/stock_routing_operation/tests/test_routing_pull.py b/stock_dynamic_routing/tests/test_routing_pull.py similarity index 99% rename from stock_routing_operation/tests/test_routing_pull.py rename to stock_dynamic_routing/tests/test_routing_pull.py index 5a454d36e3..89be03c918 100644 --- a/stock_routing_operation/tests/test_routing_pull.py +++ b/stock_dynamic_routing/tests/test_routing_pull.py @@ -47,7 +47,7 @@ def setUpClass(cls): cls.pick_type_routing_op = cls.env["stock.picking.type"].create( { - "name": "Routing operation", + "name": "Dynamic Routing", "code": "internal", "sequence_code": "WH/HO", "warehouse_id": cls.wh.id, @@ -172,7 +172,7 @@ def process_operations(self, move): class TestRoutingPull(TestRoutingPullCommon): - def test_change_location_to_routing_operation(self): + def test_change_location_to_dynamic_routing(self): pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] ) @@ -817,7 +817,7 @@ def test_change_dest_move_source_chain(self): # The setup we want is: # # * When the initial move line reserves in Highbay, the move is - # classified in picking type "Routing operation" with locations + # classified in picking type "Dynamic Routing" with locations # Highbay -> Handover (a new move is inserted between Handover and # Output) # * When the next move source location is set to "Handover", we diff --git a/stock_routing_operation/tests/test_routing_push.py b/stock_dynamic_routing/tests/test_routing_push.py similarity index 99% rename from stock_routing_operation/tests/test_routing_push.py rename to stock_dynamic_routing/tests/test_routing_push.py index 960c38adaf..a60a215036 100644 --- a/stock_routing_operation/tests/test_routing_push.py +++ b/stock_dynamic_routing/tests/test_routing_push.py @@ -61,7 +61,7 @@ def setUpClass(cls): cls.pick_type_routing_op = cls.env["stock.picking.type"].create( { - "name": "Routing operation", + "name": "Dynamic Routing", "code": "internal", "sequence_code": "WH/HO", "warehouse_id": cls.wh.id, @@ -186,7 +186,7 @@ def process_operations(self, moves): move.move_line_ids.qty_done = qty move.mapped("picking_id").action_done() - def test_change_location_to_routing_operation(self): + def test_change_location_to_dynamic_routing(self): in_picking, internal_picking = self._create_supplier_input_highbay( self.wh, [(self.product1, 10, self.location_hb_1_2)] ) @@ -399,7 +399,7 @@ def test_several_move_lines(self): ) move_a = in_picking.move_lines move_b = internal_picking.move_lines - # We do not want to trigger the routing operation now (see explanation + # We do not want to trigger the dynamic routing now (see explanation # below) move_b.location_dest_id = self.wh.lot_stock_id diff --git a/stock_routing_operation/views/stock_routing_views.xml b/stock_dynamic_routing/views/stock_routing_views.xml similarity index 100% rename from stock_routing_operation/views/stock_routing_views.xml rename to stock_dynamic_routing/views/stock_routing_views.xml From dc6ba5f66747e7d54186be827a90ec1da7077b3c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 1 May 2020 15:33:06 +0200 Subject: [PATCH 23/33] Add comment for a needed fix --- stock_dynamic_routing/models/stock_routing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index 0ace5fa942..4dca85b255 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -120,6 +120,9 @@ def _routing_rule_for_moves(self, moves): :param move: recordset of the move :return: dict {move: rule}} """ + # FIXME clear_cache triggers a cache invalidation on *all* the + # workers, we don't need this here! we only want a cache local + # to the current thread, replace ormcache by a local cache self.__cached_is_rule_valid_for_move.clear_cache(self) result = {} for move in moves: From 493cf5e9aa3fdf1c0881a493285bc663e130c8be Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 4 May 2020 08:06:52 +0200 Subject: [PATCH 24/33] Replace ormcache by a local lru_cache When an ormcache is cleared, it propagates the invalidation to other workers, blowing all their caches for all models. Since we use this cache only for the duration of a method, it's pointless. --- stock_dynamic_routing/models/stock_routing.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index 4dca85b255..45ef396370 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -2,8 +2,9 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) from collections import defaultdict +from functools import lru_cache -from odoo import fields, models, tools +from odoo import fields, models def _default_sequence(model): @@ -99,7 +100,9 @@ def _routing_rule_for_move_lines(self, moves): :param move: recordset of the move :return: dict {move: {rule: move_lines}} """ - self.__cached_is_rule_valid_for_move.clear_cache(self) + # ensure the cache is clean + self.__cached_is_rule_valid_for_move.cache_clear() + result = { move: defaultdict(self.env["stock.move.line"].browse) for move in moves } @@ -109,6 +112,8 @@ def _routing_rule_for_move_lines(self, moves): ) result[move_line.move_id][rule] |= move_line + # free memory used for the cache + self.__cached_is_rule_valid_for_move.cache_clear() return result def _routing_rule_for_moves(self, moves): @@ -120,10 +125,9 @@ def _routing_rule_for_moves(self, moves): :param move: recordset of the move :return: dict {move: rule}} """ - # FIXME clear_cache triggers a cache invalidation on *all* the - # workers, we don't need this here! we only want a cache local - # to the current thread, replace ormcache by a local cache - self.__cached_is_rule_valid_for_move.clear_cache(self) + # ensure the cache is clean + self.__cached_is_rule_valid_for_move.cache_clear() + result = {} for move in moves: rule = self._find_rule_for_location( @@ -131,9 +135,14 @@ def _routing_rule_for_moves(self, moves): ) result[move] = rule + # free memory used for the cache + self.__cached_is_rule_valid_for_move.cache_clear() return result - @tools.ormcache("rule", "move") + # Do not use ormcache, which would invalidate cache of other workers every + # time we clear it. We only need a local cache used for the duration of the + # execution of + @lru_cache() def __cached_is_rule_valid_for_move(self, rule, move): """To be used only by _routing_rule_for_move(_line)s From 8cc3c66cfdbe419d54cd9f768a9825322701c16d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 15 May 2020 12:08:16 +0200 Subject: [PATCH 25/33] Reduce number of SQL queries on stock_location By using the parent path of the records, we can avoid many SQL requests to compare children or find parent locations --- .../models/stock_location.py | 31 ++++----------- stock_dynamic_routing/models/stock_move.py | 38 ++++++++----------- stock_dynamic_routing/models/stock_routing.py | 3 -- 3 files changed, 22 insertions(+), 50 deletions(-) diff --git a/stock_dynamic_routing/models/stock_location.py b/stock_dynamic_routing/models/stock_location.py index cf85bf5a63..30177775f9 100644 --- a/stock_dynamic_routing/models/stock_location.py +++ b/stock_dynamic_routing/models/stock_location.py @@ -1,33 +1,16 @@ # Copyright 2019 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -from odoo import api, models, tools +from odoo import models class StockLocation(models.Model): _inherit = "stock.location" - @tools.ormcache("self.id") def _location_parent_tree(self): self.ensure_one() - tree = self.search( - [("id", "parent_of", self.id)], - # the recordset will be ordered bottom location to top location - order="parent_path desc", - ) - return tree - - @api.model_create_multi - def create(self, vals_list): - locations = super().create(vals_list) - self._location_parent_tree.clear_cache(self) - return locations - - def write(self, values): - res = super().write(values) - self._location_parent_tree.clear_cache(self) - return res - - def unlink(self): - res = super().unlink() - self._location_parent_tree.clear_cache(self) - return res + # Build the tree of parent locations, we don't need SQL + # at all since the parent ids are all in the parent path. + tree_ids = [int(tree_id) for tree_id in self.parent_path.rstrip("/").split("/")] + # the recordset will be ordered bottom location to top location + tree_ids.reverse() + return self.browse(tree_ids) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 74aaaaf271..a3785f0074 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -252,12 +252,10 @@ def _apply_routing_rule_pull(self, routing_details): __applying_routing_rule=True ).location_id = routing_rule.location_src_id move.picking_type_id = routing_rule.picking_type_id - if self.env["stock.location"].search( - [ - ("id", "=", routing_rule.location_dest_id.id), - ("id", "child_of", move.location_dest_id.id), - ] - ): + location_path = move.location_dest_id.parent_path + rule_location_path = routing_rule.location_dest_id.parent_path + # if the move location is a parent of the rule's location + if rule_location_path.startswith(location_path): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one. @@ -265,12 +263,8 @@ def _apply_routing_rule_pull(self, routing_details): # which may reapply a new routing rule on the dest. move. move._routing_pull_switch_destination(routing_rule) - elif not self.env["stock.location"].search( - [ - ("id", "=", routing_rule.location_dest_id.id), - ("id", "parent_of", move.location_dest_id.id), - ] - ): + # if the move location is not a child of the rule's location + elif not location_path.startswith(rule_location_path): # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to @@ -358,17 +352,15 @@ def _apply_routing_rule_push(self, routing_details): # routing move after this one continue - if self.env["stock.location"].search( - [ - # the source is already correct (more precise than the routing), - # but we still want to classify the move in the routing's picking - # type - ("id", "=", routing_rule.location_src_id.id), - # if the source location of the move is a child of the routing's - # source location, we don't need to change it - ("id", "parent_of", move.location_id.id), - ] - ): + rule_location_path = routing_rule.location_src_id.parent_path + location_path = move.location_id.parent_path + # if rule location is parent of location + if location_path.startswith(rule_location_path): + # The source is already correct (more precise than the routing), + # but we still want to classify the move in the routing's picking + # type. + # If the source location of the move is a child of the routing's + # source location, we don't need to change it. picking = move.picking_id move._routing_push_switch_picking_type(routing_rule) pickings_to_check_for_emptiness |= picking diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index 45ef396370..5e98f335ec 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -59,9 +59,6 @@ def _find_rule_for_location(self, move, src_location, dest_location): The source/destination locations are not an exact match: it looks for the location or a parent. """ - # the result of _location_parent_tree() is cached, so get the rules - # at once even if we don't use the "push" candidates, we can spare - # some queries pull_location_tree = src_location._location_parent_tree() push_location_tree = dest_location._location_parent_tree() candidate_rules = self.env["stock.routing.rule"].search( From 19fca58f837f153cb6c4820be27dfec89fb43dae Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 18 May 2020 11:12:04 +0200 Subject: [PATCH 26/33] Add tests for finding rules --- stock_dynamic_routing/models/stock_routing.py | 3 +- stock_dynamic_routing/tests/__init__.py | 1 + .../tests/test_routing_rule.py | 180 ++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 stock_dynamic_routing/tests/test_routing_rule.py diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index 5e98f335ec..8477331d24 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -47,8 +47,7 @@ def _default_sequence(self): return _default_sequence(self) # TODO would be nice to add a constraint that would prevent to - # have a pull + a pull routing that would apply on the same move - # TODO write tests for this + # have a pull + a push routing that would apply on the same move def _find_rule_for_location(self, move, src_location, dest_location): """Return the routing rule for a source or destination location diff --git a/stock_dynamic_routing/tests/__init__.py b/stock_dynamic_routing/tests/__init__.py index 32e07f5e56..661afbcdc9 100644 --- a/stock_dynamic_routing/tests/__init__.py +++ b/stock_dynamic_routing/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_routing_pull from . import test_routing_push +from . import test_routing_rule diff --git a/stock_dynamic_routing/tests/test_routing_rule.py b/stock_dynamic_routing/tests/test_routing_rule.py new file mode 100644 index 0000000000..50a3b9fd28 --- /dev/null +++ b/stock_dynamic_routing/tests/test_routing_rule.py @@ -0,0 +1,180 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo.tests import common + + +class TestRoutingRule(common.SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product = cls.env["product.product"].create( + {"name": "Product", "type": "product"} + ) + cls.suppliers_loc = cls.env.ref("stock.stock_location_suppliers") + cls.customer_loc = cls.env.ref("stock.stock_location_customers") + cls.wh = cls.env["stock.warehouse"].create( + { + "name": "Base Warehouse", + "reception_steps": "one_step", + "delivery_steps": "pick_ship", + "code": "WHTEST", + } + ) + cls.location_shelf = cls.env["stock.location"].create( + {"name": "Shelf", "location_id": cls.wh.lot_stock_id.id} + ) + cls.location_shelf_1 = cls.env["stock.location"].create( + {"name": "Shelf 1", "location_id": cls.location_shelf.id} + ) + cls.location_shelf_2 = cls.env["stock.location"].create( + {"name": "Shelf 2", "location_id": cls.location_shelf.id} + ) + + def _create_picking_type(self, name, src, dest): + return self.env["stock.picking.type"].create( + { + "name": name, + "code": "internal", + "sequence_code": "WH/{}".format(name), + "warehouse_id": self.wh.id, + "use_create_lots": False, + "use_existing_lots": True, + "default_location_src_id": src.id, + "default_location_dest_id": dest.id, + } + ) + + def _create_stock_move(self, product, qty, picking_type): + return self.env["stock.move"].create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": product.uom_id.id, + "location_id": picking_type.default_location_src_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "state": "confirmed", + } + ) + + def test_find_pull_rule(self): + pick_type_a = self._create_picking_type( + "A", self.location_shelf, self.customer_loc + ) + routing = self.env["stock.routing"].create( + {"location_id": self.location_shelf.id} + ) + self.env["stock.routing.rule"].create( + { + "method": "pull", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 12, + } + ) + rule = self.env["stock.routing.rule"].create( + { + "method": "pull", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 10, + } + ) + + move = self._create_stock_move(self.product, 10, pick_type_a) + found_rule = self.env["stock.routing"]._find_rule_for_location( + move, self.location_shelf_1, self.customer_loc + ) + self.assertEqual(found_rule, rule) + + def test_find_push_rule(self): + pick_type_a = self._create_picking_type( + "A", self.suppliers_loc, self.location_shelf + ) + routing = self.env["stock.routing"].create( + {"location_id": self.location_shelf.id} + ) + self.env["stock.routing.rule"].create( + { + "method": "push", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 12, + } + ) + rule = self.env["stock.routing.rule"].create( + { + "method": "push", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 10, + } + ) + move = self._create_stock_move(self.product, 10, pick_type_a) + found_rule = self.env["stock.routing"]._find_rule_for_location( + move, self.suppliers_loc, self.location_shelf_1 + ) + self.assertEqual(found_rule, rule) + + def test_find_pull_rule_domain(self): + pick_type_a = self._create_picking_type( + "A", self.location_shelf, self.customer_loc + ) + routing = self.env["stock.routing"].create( + {"location_id": self.location_shelf.id} + ) + self.env["stock.routing.rule"].create( + { + "method": "pull", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 1, + # rule not selected because of this: + "rule_domain": [("product_id", "!=", self.product.id)], + } + ) + rule = self.env["stock.routing.rule"].create( + { + "method": "pull", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 10, + } + ) + + move = self._create_stock_move(self.product, 10, pick_type_a) + found_rule = self.env["stock.routing"]._find_rule_for_location( + move, self.location_shelf_1, self.customer_loc + ) + self.assertEqual(found_rule, rule) + + def test_find_push_rule_domain(self): + pick_type_a = self._create_picking_type( + "A", self.suppliers_loc, self.location_shelf + ) + routing = self.env["stock.routing"].create( + {"location_id": self.location_shelf.id} + ) + self.env["stock.routing.rule"].create( + { + "method": "push", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 1, + # rule not selected because of this: + "rule_domain": [("product_id", "!=", self.product.id)], + } + ) + rule = self.env["stock.routing.rule"].create( + { + "method": "push", + "routing_id": routing.id, + "picking_type_id": pick_type_a.id, + "sequence": 10, + } + ) + move = self._create_stock_move(self.product, 10, pick_type_a) + found_rule = self.env["stock.routing"]._find_rule_for_location( + move, self.suppliers_loc, self.location_shelf_1 + ) + self.assertEqual(found_rule, rule) From 4816a8d79e8f57dd44a7f2ee6002645afb314cbf Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 May 2020 10:16:31 +0200 Subject: [PATCH 27/33] Add mandatory picking type on dynamic routing The picking type must be selected to filter on routing to apply --- .../demo/stock_routing_demo.xml | 15 ++- stock_dynamic_routing/models/stock_routing.py | 21 ++-- .../models/stock_routing_rule.py | 12 +- .../tests/test_routing_pull.py | 5 + .../tests/test_routing_push.py | 2 + .../tests/test_routing_rule.py | 117 ++++++++++-------- .../views/stock_routing_views.xml | 16 ++- 7 files changed, 119 insertions(+), 69 deletions(-) diff --git a/stock_dynamic_routing/demo/stock_routing_demo.xml b/stock_dynamic_routing/demo/stock_routing_demo.xml index 8709edf443..2281bc4239 100644 --- a/stock_dynamic_routing/demo/stock_routing_demo.xml +++ b/stock_dynamic_routing/demo/stock_routing_demo.xml @@ -1,24 +1,33 @@ - + + - + pull + + + + - + push stock.routing.form stock.routing - +
+ + + + + @@ -54,7 +59,7 @@ stock.routing.search stock.routing - + stock.routing stock.routing - + +
- Stock Routing + Dynamic Routing stock.routing ir.actions.act_window @@ -84,7 +90,7 @@

- Add a Stock Routing + Add a Dynamic Routing

From 7f38e5de7668e0b8593de65826ea1e864c6e2aab Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 May 2020 10:57:58 +0200 Subject: [PATCH 28/33] Allow to select sub-location's picking types in rules There is no real reason to force using the exact same location. A sub-location should be fine. --- .../models/stock_routing_rule.py | 22 ++++++++++++++----- .../views/stock_routing_views.xml | 4 ++-- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/stock_dynamic_routing/models/stock_routing_rule.py b/stock_dynamic_routing/models/stock_routing_rule.py index 741184c4a2..56f3876a35 100644 --- a/stock_dynamic_routing/models/stock_routing_rule.py +++ b/stock_dynamic_routing/models/stock_routing_rule.py @@ -59,19 +59,29 @@ def _constrains_picking_type_location(self): for record in self: base_location = record.routing_location_id - if record.method == "pull" and record.location_src_id != base_location: + if record.method == "pull" and ( + not record.location_src_id + or not record.location_src_id.parent_path.startswith( + base_location.parent_path + ) + ): raise exceptions.ValidationError( _( - "Operation type of a rule used as 'pull' must have '{}' as" - " source location." + "Operation type of a rule used as 'pull' must have '{}'" + " or a sub-location as source location." ).format(base_location.display_name) ) - elif record.method == "push" and record.location_dest_id != base_location: + elif record.method == "push" and ( + not record.location_dest_id + or not record.location_dest_id.parent_path.startswith( + base_location.parent_path + ) + ): raise exceptions.ValidationError( _( - "Operation type of a rule used as 'push' must have '{}' as" - " destination location." + "Operation type of a rule used as 'push' must have '{}'" + " or a sub-location as destination location." ).format(base_location.display_name) ) diff --git a/stock_dynamic_routing/views/stock_routing_views.xml b/stock_dynamic_routing/views/stock_routing_views.xml index a05fbbb2d6..4f5cce6bcb 100644 --- a/stock_dynamic_routing/views/stock_routing_views.xml +++ b/stock_dynamic_routing/views/stock_routing_views.xml @@ -38,8 +38,8 @@ From 2b538599074a234979d2e58bfbaddb8a0ad8849d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 25 May 2020 12:05:35 +0200 Subject: [PATCH 29/33] Improve usability, show routing description --- stock_dynamic_routing/models/stock_routing.py | 97 ++++++++++++++++++- .../views/stock_routing_views.xml | 89 +++++++++-------- 2 files changed, 143 insertions(+), 43 deletions(-) diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index 6b60eb0378..a43952da03 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -4,7 +4,7 @@ from collections import defaultdict from functools import lru_cache -from odoo import fields, models +from odoo import _, api, fields, models def _default_sequence(model): @@ -36,6 +36,7 @@ class StockRouting(models.Model): rule_ids = fields.One2many( comodel_name="stock.routing.rule", inverse_name="routing_id" ) + routing_message = fields.Html(compute="_compute_routing_message") _sql_constraints = [ ( @@ -45,6 +46,100 @@ class StockRouting(models.Model): ) ] + def _routing_message_template(self): + pull_rules = self.rule_ids.filtered(lambda r: r.method == "pull") + push_rules = self.rule_ids.filtered(lambda r: r.method == "push") + pull_message = "" + push_message = "" + + if pull_rules: + rule_messages = [] + for rule in pull_rules: + msg = _( + "If the destination of the move is already" + " {rule.location_dest_id.display_name}," + " the operation type of the move is changed to" + " {rule.picking_type_id.display_name}." + "
" + "If the destination of the move is a parent location of " + " {rule.location_dest_id.display_name}," + " the destination is set to " + "{rule.location_dest_id.display_name} " + " and the operation type of the move is changed to" + " {rule.picking_type_id.display_name}." + "
" + "If the destination of the move is unrelated to " + " {rule.location_dest_id.display_name}, " + "a new move is added before, from" + " {rule.location_src_id.display_name} to " + " {rule.location_dest_id.display_name}, " + "using the operation type " + " {rule.picking_type_id.display_name}." + ).format(rule=rule) + rule_messages.append("
  • " + msg + "
  • ") + pull_message = _( + "

    Pull rules:

    " + "When a move with operation type " + "{routing.picking_type_id.display_name}" + " has a source location" + " {routing.location_id.display_name}" + " (or a sub-location), one of these rules can apply (depending" + " of their domain):" + "
      " + "{rule_messages}" + "
    " + ).format(routing=self, rule_messages="\n".join(rule_messages)) + + if push_rules: + rule_messages = [] + for rule in push_rules: + msg = _( + "If the source of the move is already" + " {rule.location_src_id.display_name}" + " or a sub-location, the operation type of the move" + " is changed to" + " {rule.picking_type_id.display_name}." + "
    " + "If the source of the move is outside or a parent location of " + " {rule.location_dest_id.display_name}," + " the destination of the move is set to " + " {rule.location_src_id.display_name}, " + " a new move is added after it, from" + " {rule.location_src_id.display_name} to " + " {rule.location_dest_id.display_name} " + "using the operation type " + " {rule.picking_type_id.display_name}." + ).format(rule=rule) + rule_messages.append("
  • " + msg + "
  • ") + push_message = _( + "

    Push rules:

    " + "When a move with operation type " + "{routing.picking_type_id.display_name}" + " has a destination location" + " {routing.location_id.display_name}" + " (or a sub-location), one of these rules can apply (depending" + " of their domain):" + "
      " + "{rule_messages}" + "
    " + ).format(routing=self, rule_messages="\n".join(rule_messages)) + return pull_message + "
    " + push_message + + @api.depends( + "location_id", "picking_type_id", "rule_ids.method", "rule_ids.picking_type_id" + ) + def _compute_routing_message(self): + """Generate a description of the routing for humans""" + for routing in self: + if not ( + routing.picking_type_id and routing.location_id and routing.rule_ids + ): + routing.routing_message = "" + continue + routing.routing_message = routing._routing_message_template().format( + routing=routing + ) + def _default_sequence(self): return _default_sequence(self) diff --git a/stock_dynamic_routing/views/stock_routing_views.xml b/stock_dynamic_routing/views/stock_routing_views.xml index 4f5cce6bcb..71dc56f62d 100644 --- a/stock_dynamic_routing/views/stock_routing_views.xml +++ b/stock_dynamic_routing/views/stock_routing_views.xml @@ -5,53 +5,58 @@ stock.routing -
    -
    - -
    + + +
    + + + + + + + +
    +
    + +
    From f0f6460b531314ba3d94f759ef2fb33d578ceb8a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 27 May 2020 08:13:49 +0200 Subject: [PATCH 30/33] Improve readability for location comparisons --- .../models/stock_location.py | 10 ++++++++++ stock_dynamic_routing/models/stock_move.py | 19 ++++++++----------- .../models/stock_routing_rule.py | 8 ++------ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/stock_dynamic_routing/models/stock_location.py b/stock_dynamic_routing/models/stock_location.py index 30177775f9..7d8a1244d3 100644 --- a/stock_dynamic_routing/models/stock_location.py +++ b/stock_dynamic_routing/models/stock_location.py @@ -14,3 +14,13 @@ def _location_parent_tree(self): # the recordset will be ordered bottom location to top location tree_ids.reverse() return self.browse(tree_ids) + + def is_sublocation_of(self, others): + """Return True if self is a sublocation of at least one other + + It is equivalent to the "child_of" operator, so it includes itself. + """ + 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_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index a3785f0074..4aefb18c43 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -252,10 +252,9 @@ def _apply_routing_rule_pull(self, routing_details): __applying_routing_rule=True ).location_id = routing_rule.location_src_id move.picking_type_id = routing_rule.picking_type_id - location_path = move.location_dest_id.parent_path - rule_location_path = routing_rule.location_dest_id.parent_path - # if the move location is a parent of the rule's location - if rule_location_path.startswith(location_path): + dest_location = move.location_dest_id + rule_location = routing_rule.location_dest_id + if rule_location.is_sublocation_of(dest_location): # The destination of the move, as a parent of the destination # of the routing, goes to the correct place, but is not precise # enough: set the new destination to match the rule's one. @@ -263,8 +262,7 @@ def _apply_routing_rule_pull(self, routing_details): # which may reapply a new routing rule on the dest. move. move._routing_pull_switch_destination(routing_rule) - # if the move location is not a child of the rule's location - elif not location_path.startswith(rule_location_path): + elif not dest_location.is_sublocation_of(rule_location): # The destination of the move is unrelated (nor identical, nor # a parent or a child) to the routing destination: in this case # we have to add a routing move before the current move to @@ -352,11 +350,10 @@ def _apply_routing_rule_push(self, routing_details): # routing move after this one continue - rule_location_path = routing_rule.location_src_id.parent_path - location_path = move.location_id.parent_path - # if rule location is parent of location - if location_path.startswith(rule_location_path): - # The source is already correct (more precise than the routing), + rule_location = routing_rule.location_src_id + location = move.location_id + if location.is_sublocation_of(rule_location): + # The source is already correct (or more precise than the routing), # but we still want to classify the move in the routing's picking # type. # If the source location of the move is a child of the routing's diff --git a/stock_dynamic_routing/models/stock_routing_rule.py b/stock_dynamic_routing/models/stock_routing_rule.py index 56f3876a35..2b7b91bd00 100644 --- a/stock_dynamic_routing/models/stock_routing_rule.py +++ b/stock_dynamic_routing/models/stock_routing_rule.py @@ -61,9 +61,7 @@ def _constrains_picking_type_location(self): if record.method == "pull" and ( not record.location_src_id - or not record.location_src_id.parent_path.startswith( - base_location.parent_path - ) + or not record.location_src_id.is_sublocation_of(base_location) ): raise exceptions.ValidationError( _( @@ -73,9 +71,7 @@ def _constrains_picking_type_location(self): ) elif record.method == "push" and ( not record.location_dest_id - or not record.location_dest_id.parent_path.startswith( - base_location.parent_path - ) + or not record.location_dest_id.is_sublocation_of(base_location) ): raise exceptions.ValidationError( From 1f61afe111e8c4244d26d4a4d844299d8a273553 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 3 Jun 2020 11:57:10 +0200 Subject: [PATCH 31/33] Fix bug on move copy, reset package level On stock.move, the `package_level_id` field did not have a `copy=False` attribute when version 13.0 of Odoo was released. So when we create a new move, the package level of the move being copied was linked as well to the new move. It has been fixed recently in odoo in https://github.com/odoo/odoo/commit/ecf726ae8221e6871c1e391294c633d8b6bcaa9a but to be on the safe side if the code is not up-to-date, it's anyway better to force a False value. --- stock_dynamic_routing/models/stock_move.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/stock_dynamic_routing/models/stock_move.py b/stock_dynamic_routing/models/stock_move.py index 4aefb18c43..d9ec26eac8 100644 --- a/stock_dynamic_routing/models/stock_move.py +++ b/stock_dynamic_routing/models/stock_move.py @@ -436,4 +436,8 @@ def _prepare_routing_move_values(self, picking_type, source, destination): "location_dest_id": destination.id, "state": "waiting", "picking_type_id": picking_type.id, + # copy=False was missing on this field up to + # https://github.com/odoo/odoo/commit/ecf726ae + # to be on the safe side, force it to False + "package_level_id": False, } From ac8b9bda377217abc225589c0b36521bb61920e0 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 12 Jun 2020 09:31:43 +0200 Subject: [PATCH 32/33] Change stock_dynamic_routing to Beta --- stock_dynamic_routing/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_dynamic_routing/__manifest__.py b/stock_dynamic_routing/__manifest__.py index 54dafd9509..cbade46dd5 100644 --- a/stock_dynamic_routing/__manifest__.py +++ b/stock_dynamic_routing/__manifest__.py @@ -15,5 +15,5 @@ ], "data": ["views/stock_routing_views.xml", "security/ir.model.access.csv"], "installable": True, - "development_status": "Alpha", + "development_status": "Beta", } From f5d93ca514753201345879da796de712f5048392 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 15 Jun 2020 08:11:11 +0200 Subject: [PATCH 33/33] Apply code review suggestions --- stock_dynamic_routing/__manifest__.py | 4 ++-- stock_dynamic_routing/models/stock_routing.py | 7 +++++++ stock_dynamic_routing/tests/test_routing_pull.py | 1 + stock_dynamic_routing/tests/test_routing_push.py | 1 + stock_dynamic_routing/tests/test_routing_rule.py | 1 + 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/stock_dynamic_routing/__manifest__.py b/stock_dynamic_routing/__manifest__.py index cbade46dd5..b600b97960 100644 --- a/stock_dynamic_routing/__manifest__.py +++ b/stock_dynamic_routing/__manifest__.py @@ -1,9 +1,9 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) { "name": "Stock Dynamic Routing", - "summary": "Dynamic routing for special locations", + "summary": "Dynamic routing of stock moves", "author": "Camptocamp, Odoo Community Association (OCA)", - "website": "https://github.com/OCA/stock-logistics-warehouse", + "website": "https://github.com/OCA/wms", "category": "Warehouse Management", "version": "13.0.1.0.0", "license": "AGPL-3", diff --git a/stock_dynamic_routing/models/stock_routing.py b/stock_dynamic_routing/models/stock_routing.py index a43952da03..9bb0808c4a 100644 --- a/stock_dynamic_routing/models/stock_routing.py +++ b/stock_dynamic_routing/models/stock_routing.py @@ -247,6 +247,13 @@ def __cached_is_rule_valid_for_move(self, rule, move): The method _routing_rule_for_move(_line)s reset the cache at beginning. Cache the result so inside _routing_rule_for_move(_line)s, we compute it only once for a move and a rule (if we have several move lines). + + This method is part of the internal machinery of the algorithm to + find rules. It exists so we can have a local cache during the time of a + transaction, and is not meant to be overriden in any way. + + If you wish to customize the validity of a rule, you should extend + ``StockRoutingRule._is_valid_for_moves()`` """ return rule._is_valid_for_moves(move) diff --git a/stock_dynamic_routing/tests/test_routing_pull.py b/stock_dynamic_routing/tests/test_routing_pull.py index dff7ef27b7..20e8a1f29e 100644 --- a/stock_dynamic_routing/tests/test_routing_pull.py +++ b/stock_dynamic_routing/tests/test_routing_pull.py @@ -7,6 +7,7 @@ class TestRoutingPullCommon(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.partner_delta = cls.env.ref("base.res_partner_4") cls.wh = cls.env["stock.warehouse"].create( { diff --git a/stock_dynamic_routing/tests/test_routing_push.py b/stock_dynamic_routing/tests/test_routing_push.py index 376175c4bc..91d4618273 100644 --- a/stock_dynamic_routing/tests/test_routing_push.py +++ b/stock_dynamic_routing/tests/test_routing_push.py @@ -7,6 +7,7 @@ class TestRoutingPush(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.partner_delta = cls.env.ref("base.res_partner_4") cls.wh = cls.env["stock.warehouse"].create( { diff --git a/stock_dynamic_routing/tests/test_routing_rule.py b/stock_dynamic_routing/tests/test_routing_rule.py index b5cde52e4d..569218ab87 100644 --- a/stock_dynamic_routing/tests/test_routing_rule.py +++ b/stock_dynamic_routing/tests/test_routing_rule.py @@ -7,6 +7,7 @@ class TestRoutingRule(common.SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.product = cls.env["product.product"].create( {"name": "Product", "type": "product"} )