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, + )