From d6b34b968f43779b90b387b9c19934709b9db653 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 7 Jul 2020 15:43:51 +0200 Subject: [PATCH] Add stock_picking_consolidation_priority This module adds an option "Raise priority when partially available" on operation types. When the option is set on an operation type, the first time a packing operation is made partially available (goods have been moved for instance from a picking), all the other moves that will bring goods for the same packing transfer have their priority raised to "Very Urgent". If the moves that bring goods to the packing transfer involves a chain of several moves, all the moves of the chain have their priority raised. --- .../stock_picking_consolidation_priority | 1 + .../setup.py | 6 + .../__init__.py | 1 + .../__manifest__.py | 15 ++ .../models/__init__.py | 2 + .../models/stock_move.py | 118 +++++++++ .../models/stock_picking_type.py | 19 ++ .../readme/CONFIGURATION.rst | 1 + .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 14 + .../readme/ROADMAP.rst | 3 + .../tests/__init__.py | 1 + .../tests/test_consolidation_priority.py | 249 ++++++++++++++++++ 13 files changed, 431 insertions(+) create mode 120000 setup/stock_picking_consolidation_priority/odoo/addons/stock_picking_consolidation_priority create mode 100644 setup/stock_picking_consolidation_priority/setup.py create mode 100644 stock_picking_consolidation_priority/__init__.py create mode 100644 stock_picking_consolidation_priority/__manifest__.py create mode 100644 stock_picking_consolidation_priority/models/__init__.py create mode 100644 stock_picking_consolidation_priority/models/stock_move.py create mode 100644 stock_picking_consolidation_priority/models/stock_picking_type.py create mode 100644 stock_picking_consolidation_priority/readme/CONFIGURATION.rst create mode 100644 stock_picking_consolidation_priority/readme/CONTRIBUTORS.rst create mode 100644 stock_picking_consolidation_priority/readme/DESCRIPTION.rst create mode 100644 stock_picking_consolidation_priority/readme/ROADMAP.rst create mode 100644 stock_picking_consolidation_priority/tests/__init__.py create mode 100644 stock_picking_consolidation_priority/tests/test_consolidation_priority.py diff --git a/setup/stock_picking_consolidation_priority/odoo/addons/stock_picking_consolidation_priority b/setup/stock_picking_consolidation_priority/odoo/addons/stock_picking_consolidation_priority new file mode 120000 index 000000000..a6884e436 --- /dev/null +++ b/setup/stock_picking_consolidation_priority/odoo/addons/stock_picking_consolidation_priority @@ -0,0 +1 @@ +../../../../stock_picking_consolidation_priority \ No newline at end of file diff --git a/setup/stock_picking_consolidation_priority/setup.py b/setup/stock_picking_consolidation_priority/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_picking_consolidation_priority/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_picking_consolidation_priority/__init__.py b/stock_picking_consolidation_priority/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_picking_consolidation_priority/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_picking_consolidation_priority/__manifest__.py b/stock_picking_consolidation_priority/__manifest__.py new file mode 100644 index 000000000..a809cf136 --- /dev/null +++ b/stock_picking_consolidation_priority/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Stock Transfers Consolidation Priority", + "summary": "Raise priority of all transfers for a chain when started", + "version": "13.0.1.0.0", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "category": "Stock Management", + "depends": ["stock"], + "data": [], + "installable": True, + "license": "AGPL-3", +} diff --git a/stock_picking_consolidation_priority/models/__init__.py b/stock_picking_consolidation_priority/models/__init__.py new file mode 100644 index 000000000..1e2ccbb54 --- /dev/null +++ b/stock_picking_consolidation_priority/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_picking_type +from . import stock_move diff --git a/stock_picking_consolidation_priority/models/stock_move.py b/stock_picking_consolidation_priority/models/stock_move.py new file mode 100644 index 000000000..b8ef3c9fc --- /dev/null +++ b/stock_picking_consolidation_priority/models/stock_move.py @@ -0,0 +1,118 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import models +from odoo.osv import expression + + +class StockMove(models.Model): + _inherit = "stock.move" + + _consolidate_priority_value = "3" + + def _action_done(self, cancel_backorder=False): + moves_to_check = super()._action_done(cancel_backorder=cancel_backorder) + moves_to_check.filtered(lambda move: move.state == "done") + if moves_to_check: + moves_to_check._consolidate_priority() + return moves_to_check + + def _query_get_consolidate_moves(self): + """Return a query to find the moves to consolidate in priority + + Consider this chain of moves:: + + PICK/001 ━► PACK/001 ┓ + ┃ + PICK/002 ┓ ┣━► OUT/001 + ┣━► PACK/002 ┛ + PICK/003 ┛ + + If the flag "consolidate_priority" is set on the picking type of OUT, + when we set any of the PICK to done, the 3 PICK and the 2 PACK must + be returned to have their priority raised. + + If the flag is set on PACK and PICK/002 or PICK/003 is set to done, the + other PICK that leads to PACK/002 must be returned to have its priority + raised. But if only PICK/001 is set to done, nothing is returned. + + If the flag is set on both PACK and OUT, the result is the same as the + first case: all PICK and PACK are returned. + """ + query = """ + WITH RECURSIVE + + -- Walk through all relations in stock_move_move_rel to find the + -- destination moves. For each move, join on stock_picking_type + -- to check if the flag "consolidate_priority" is set + destinations (id, consolidate_origins) AS ( + -- starting move + SELECT stock_move.id, + -- the moves here are done: their origin moves are + -- supposed to be done too, we don't need to raise their + -- priority + false as consolidate_origins + FROM stock_move + WHERE stock_move.id IN %s + UNION + -- recurse to find all the destinations + SELECT move_dest.id, + stock_picking_type.consolidate_priority as consolidate_origins + FROM stock_move move_dest + INNER JOIN stock_move_move_rel + ON move_dest.id = stock_move_move_rel.move_dest_id + INNER JOIN stock_picking + ON stock_picking.id = move_dest.picking_id + INNER JOIN stock_picking_type + ON stock_picking_type.id = stock_picking.picking_type_id + INNER JOIN destinations + ON destinations.id = stock_move_move_rel.move_orig_id + ), + + -- For every destination move for which we have the flag set, walk back + -- through stock_move_move_rel to find all the origin moves + origins (id, consolidate_origins) AS ( + -- for all the destinations which have to consolidate their origins, + -- it finds all the origin moves, in the final query, the rows with + -- "consolidate_origins" equal to true are excluded, because we want + -- to raise the priority of the moves *before* the picking type with + -- the flag + SELECT destinations.id, true + FROM destinations + WHERE consolidate_origins = true + -- We use union here to keep duplicate in case a move both has the flag + -- "consolidate_priority" on its picking type AND is the origin move + -- for another move with the flag. Anyway, the final query filters + -- on the second part of the union. + UNION ALL + -- recurse to find all the origin moves which have a destination that + -- needs priority consolidation + SELECT move_orig.id, false + FROM stock_move move_orig + INNER JOIN stock_move_move_rel + ON move_orig.id = stock_move_move_rel.move_orig_id + INNER JOIN origins + ON origins.id = stock_move_move_rel.move_dest_id + ) + SELECT DISTINCT id FROM origins + WHERE consolidate_origins = false + """ + return (query, (tuple(self.ids),)) + + def _consolidate_priority_domain(self): + return [("state", "not in", ("cancel", "done"))] + + def _consolidate_priority_values(self): + return {"priority": self._consolidate_priority_value} + + def _consolidate_priority(self): + query, params = self._query_get_consolidate_moves() + self.env.cr.execute(query, params) + move_ids = [row[0] for row in self.env.cr.fetchall()] + if not move_ids: + return + moves = self.search( + expression.AND( + [[("id", "in", move_ids)], self._consolidate_priority_domain()] + ) + ) + moves.write(self._consolidate_priority_values()) diff --git a/stock_picking_consolidation_priority/models/stock_picking_type.py b/stock_picking_consolidation_priority/models/stock_picking_type.py new file mode 100644 index 000000000..a0c145796 --- /dev/null +++ b/stock_picking_consolidation_priority/models/stock_picking_type.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class StockPickingType(models.Model): + _inherit = "stock.picking.type" + + consolidate_priority = fields.Boolean( + string="Raise priority when partially available", + help="Tick this box to raise the priority of all previous related" + " picking when current transfer will be made partially available." + " This is usually used in packing zone when several people work" + " on different transfers to be consolidated in the packing zone." + " When the first one finish, all other related pickings gets with" + " a high priority. The goal is to reduce the number of order being" + " packed at the same time as the space is often limited.", + default=False, + ) diff --git a/stock_picking_consolidation_priority/readme/CONFIGURATION.rst b/stock_picking_consolidation_priority/readme/CONFIGURATION.rst new file mode 100644 index 000000000..c04bb1dde --- /dev/null +++ b/stock_picking_consolidation_priority/readme/CONFIGURATION.rst @@ -0,0 +1 @@ +Update the flag "Raise priority when partially available" on Operation Types. diff --git a/stock_picking_consolidation_priority/readme/CONTRIBUTORS.rst b/stock_picking_consolidation_priority/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..48286263c --- /dev/null +++ b/stock_picking_consolidation_priority/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Guewen Baconnier diff --git a/stock_picking_consolidation_priority/readme/DESCRIPTION.rst b/stock_picking_consolidation_priority/readme/DESCRIPTION.rst new file mode 100644 index 000000000..c4c09fbe8 --- /dev/null +++ b/stock_picking_consolidation_priority/readme/DESCRIPTION.rst @@ -0,0 +1,14 @@ +As the number of packing space is usually limited, if someone starts to prepare +an order by picking it in a zone, all other related picking in the other zones +should be of a higher priority. This should make a "started" order a priority +for all. + +This module adds an option "Raise priority when partially available" on +operation types. + +When the option is set on an operation type, the first time a packing operation +is made partially available (goods have been moved for instance from a picking), +all the other moves that will bring goods for the same packing transfer have +their priority raised to "Very Urgent". If the moves that bring goods to the +packing transfer involves a chain of several moves, all the moves of the chain +have their priority raised. diff --git a/stock_picking_consolidation_priority/readme/ROADMAP.rst b/stock_picking_consolidation_priority/readme/ROADMAP.rst new file mode 100644 index 000000000..7c199c149 --- /dev/null +++ b/stock_picking_consolidation_priority/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +* The priority is hardcoded to "Very Urgent", it could be made configurable. + That being said, it's easy enough to override ``_consolidate_priority_value`` + to customize the level. diff --git a/stock_picking_consolidation_priority/tests/__init__.py b/stock_picking_consolidation_priority/tests/__init__.py new file mode 100644 index 000000000..317c70333 --- /dev/null +++ b/stock_picking_consolidation_priority/tests/__init__.py @@ -0,0 +1 @@ +from . import test_consolidation_priority diff --git a/stock_picking_consolidation_priority/tests/test_consolidation_priority.py b/stock_picking_consolidation_priority/tests/test_consolidation_priority.py new file mode 100644 index 000000000..20caacdda --- /dev/null +++ b/stock_picking_consolidation_priority/tests/test_consolidation_priority.py @@ -0,0 +1,249 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from collections import namedtuple + +from odoo.tests.common import SavepointCase + + +class TestConsolidationPriority(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.warehouse = cls.env.ref("stock.warehouse0") + cls.warehouse.write({"delivery_steps": "pick_pack_ship"}) + cls.stock_shelf_location = cls.env.ref("stock.stock_location_components") + cls.customers_location = cls.env.ref("stock.stock_location_customers") + cls.product = cls.env["product.product"].create( + {"name": "Product", "type": "product"} + ) + + cls.pick_type = cls.warehouse.pick_type_id + cls.pack_type = cls.warehouse.pack_type_id + cls.out_type = cls.warehouse.out_type_id + cls.int_type = cls.warehouse.int_type_id + cls.procurement_group_1 = cls.env["procurement.group"].create( + {"name": "Test 1"} + ) + + cls.chain = cls.build_moves() + + @classmethod + def _update_qty_in_location(cls, location, product, quantity): + cls.env["stock.quant"]._update_available_quantity(product, location, quantity) + + @classmethod + def _create_move_with_picking(cls, picking_type, product, quantity, move_orig=None): + move_vals = { + "name": product.name, + "picking_type_id": picking_type.id, + "product_id": product.id, + "product_uom_qty": 2.0, + "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 + or cls.customers_location.id, + "state": "confirmed", + "procure_method": "make_to_stock", + "group_id": cls.procurement_group_1.id, + } + if move_orig: + move_vals.update( + { + "procure_method": "make_to_order", + "state": "waiting", + "move_orig_ids": [(6, 0, move_orig.ids)], + } + ) + move = cls.env["stock.move"].create(move_vals) + picking = cls.env["stock.picking"].create(move._get_new_picking_values()) + move.picking_id = picking.id + return move + + @classmethod + def build_moves(cls): + """Build a chain of moves + + That looks like: + + PICK/001 ━► PACK/001 ┓ + ┃ + PICK/002 ┓ ┣━► OUT/001 + ┣━► PACK/002 ┛ + INT/001 ━► PICK/003 ┛ + """ + Chain = namedtuple("chain", "int1 pick1 pick2 pick3 pack1 pack2 out1") + + int1 = cls._create_move_with_picking(cls.int_type, cls.product, 2) + + pick1 = cls._create_move_with_picking(cls.pick_type, cls.product, 2) + pick2 = cls._create_move_with_picking(cls.pick_type, cls.product, 2) + pick3 = cls._create_move_with_picking( + cls.pick_type, cls.product, 2, move_orig=int1 + ) + + pack1 = cls._create_move_with_picking( + cls.pack_type, cls.product, 2, move_orig=pick1 + ) + + pack2 = cls._create_move_with_picking( + cls.pack_type, cls.product, 4, move_orig=pick2 + pick3 + ) + + out1 = cls._create_move_with_picking( + cls.out_type, cls.product, 6, move_orig=pack1 + pack2 + ) + + return Chain(int1, pick1, pick2, pick3, pack1, pack2, out1) + + def _enable_consolidate_priority(self, picking_types): + picking_types.consolidate_priority = True + picking_types.flush() + + def _test_query(self, starting_move, expected): + # we test only the result of the graph in this test, cancel/done move + # are not filtered out, as they are filtered later + query, params = starting_move._query_get_consolidate_moves() + self.env.cr.execute(query, params) + move_ids = [row[0] for row in self.env.cr.fetchall()] + self.assertEqual(set(move_ids), set(expected.ids)) + + def test_query_graph_out1(self): + self._enable_consolidate_priority(self.chain.out1.picking_type_id) + self._test_query( + self.chain.pick1, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + self._test_query( + self.chain.pick2, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + self._test_query( + self.chain.int1, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + + def test_query_graph_pack1(self): + self._enable_consolidate_priority(self.chain.pack1.picking_type_id) + self._test_query(self.chain.pick1, self.chain.pick1) + self._test_query( + self.chain.pick2, self.chain.int1 | self.chain.pick2 | self.chain.pick3 + ) + self._test_query( + self.chain.int1, self.chain.int1 | self.chain.pick2 | self.chain.pick3 + ) + + def test_query_graph_out1_pack1(self): + self._enable_consolidate_priority( + self.chain.out1.picking_type_id | self.chain.pack1.picking_type_id + ) + self._test_query( + self.chain.pick1, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + self._test_query( + self.chain.pick1, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + self._test_query( + self.chain.int1, + self.chain.pick1 + | self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + ) + + def _move_to_done(self, move): + self._update_qty_in_location( + move.location_id, move.product_id, move.product_uom_qty + ) + move.picking_id.action_assign() + for line in move.move_line_ids: + line.qty_done = line.product_uom_qty + move.picking_id.action_done() + self.assertEqual(move.state, "done") + + def assert_priority(self, changed_moves, unchanged_moves): + expected_priority = self.env["stock.move"]._consolidate_priority_value + for move in changed_moves: + self.assertEqual(move.priority, expected_priority) + for move in unchanged_moves: + self.assertEqual(move.priority, "1") + + def test_flow_pick1_done_out1_consolidate(self): + self._enable_consolidate_priority(self.chain.out1.picking_type_id) + self._move_to_done(self.chain.pick1) + self.assert_priority( + self.chain.pick2 + | self.chain.int1 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + # pick1 is unchanged as already done + self.chain.pick1 | self.chain.out1, + ) + + def test_flow_int1_done_out1_consolidate(self): + self._enable_consolidate_priority(self.chain.out1.picking_type_id) + self._move_to_done(self.chain.int1) + self.assert_priority( + self.chain.pick1 + | self.chain.pick2 + | self.chain.pick3 + | self.chain.pack1 + | self.chain.pack2, + # int1 is unchanged as already done + self.chain.int1 | self.chain.out1, + ) + + def test_flow_int1_done_pack_consolidate(self): + self._enable_consolidate_priority(self.chain.pack2.picking_type_id) + self._move_to_done(self.chain.int1) + self.assert_priority( + self.chain.pick2 | self.chain.pick3, + self.chain.pick1 | self.chain.pack1 + # pack2 is unchanged as the priority is raised *before* it + | self.chain.pack2 + # int1 is unchanged as already done + | self.chain.int1 | self.chain.out1, + ) + + def test_flow_pick2_done_pack_consolidate(self): + self._enable_consolidate_priority(self.chain.pack2.picking_type_id) + self._move_to_done(self.chain.pick2) + self.assert_priority( + self.chain.int1 | self.chain.pick3, + self.chain.pick1 | self.chain.pack1 + # pack2 is unchanged as the priority is raised *before* it + | self.chain.pack2 + # pick2 is unchanged as already done + | self.chain.pick2 | self.chain.out1, + )