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..bd75be529 --- /dev/null +++ b/stock_picking_consolidation_priority/models/stock_move.py @@ -0,0 +1,122 @@ +# 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 ┛ + INT/001 ━► PICK/003 ┛ + + If the flag "consolidate_priority" is set on the picking type of OUT, + as soon as one of the move of PACK/001 or PACK/002 is done, all the + moves of the INT, 3 PICK, and the 2 PACK must be returned to have their + priority raised, as we want to consolidate everything asap in OUT/001. + + If the flag is set on PACK and one move in PICK/001 is set to done, + other moves of PICK/001 are returned to finish PACK/001. + + If the flag is set on PACK and one move in PICK/002 is set to done, all + the moves of INT/001 and PICK/003 and the other moves of PICK/002 have + to be returned as they will help to consolidate PACK/002. + + + If the flag is set on PACK, when a move in INT/001 is set to done, + nothing happens, but when a move in PICK/003 is set to done, all the + moves of INT/001 and PICK/002 and the other moves of PICK/003 have to + be returned as they will help to consolidate PACK/002. + + 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 + -- 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, picking_id, is_consolidation_dest) AS ( + -- Find the destination move of the current moves which have the flag + -- consolidate_priority on their picking type. From there, find all + -- the other moves of the consolidation transfer. + -- They are the starting point to search the origin moves. + -- In the final query, the rows with "consolidate_priority" equal + -- to true are excluded, because we want to raise the priority of + -- the moves *before* the picking type with the flag + SELECT consolidation_dest_moves.id, + stock_picking.id, + stock_picking_type.consolidate_priority as is_consolidation_dest + FROM stock_move_move_rel + INNER JOIN stock_move move_dest + ON move_dest.id = stock_move_move_rel.move_dest_id + INNER JOIN stock_picking + ON stock_picking.id = move_dest.picking_id + -- select *all* the moves of the transfer with the consolidation flag, + -- origin moves will be searched for all of them in the recursive part + INNER JOIN stock_move consolidation_dest_moves + ON consolidation_dest_moves.picking_id = stock_picking.id + INNER JOIN stock_picking_type + ON stock_picking_type.id = stock_picking.picking_type_id + WHERE stock_move_move_rel.move_orig_id IN %s + AND stock_picking_type.consolidate_priority = 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 (e.g, option + -- activated both on PACK and OUT). 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, + move_orig.picking_id, + false as is_consolidation_dest + 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 id FROM origins WHERE is_consolidation_dest = 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): + self.flush(["move_dest_ids", "move_orig_ids", "picking_id"]) + self.env["stock.picking"].flush(["picking_type_id"]) + self.env["stock.picking.type"].flush(["consolidate_priority"]) + 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..83469d999 --- /dev/null +++ b/stock_picking_consolidation_priority/tests/test_consolidation_priority.py @@ -0,0 +1,345 @@ +# 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_a = cls.env["product.product"].create( + {"name": "Product A", "type": "product"} + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "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(cls, name, picking_type, product, quantity, move_orig=None): + move_vals = { + "name": 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)], + } + ) + return cls.env["stock.move"].create(move_vals) + + @classmethod + def _create_picking(cls, moves): + picking = cls.env["stock.picking"].create(moves._get_new_picking_values()) + moves.picking_id = picking.id + return picking + + @classmethod + def build_moves(cls): + """Build a chain of moves and transfer + + That looks like: + + PICK/001 ━► PACK/001 ┓ + ┃ + PICK/002 ┓ ┣━► OUT/001 + ┣━► PACK/002 ┛ + INT/001 ━► PICK/003 ┛ + + Each contains 2 moves, one for product and one for product2 + """ + Chain = namedtuple( + "chain", + "int1_a int1_b" + # slots for the moves (product a, product b) + " pick1_a pick1_b" + " pick2_a pick2_b" + " pick3_a pick3_b" + " pack1_a pack1_b" + " pack2_a pack2_b" + " out1_a out1_b", + ) + + int1_a = cls._create_move("int1_a", cls.int_type, cls.product_a, 2) + int1_b = cls._create_move("int1_b", cls.int_type, cls.product_b, 2) + cls._create_picking(int1_a + int1_b) + + pick1_a = cls._create_move("pick1_a", cls.pick_type, cls.product_a, 2) + pick1_b = cls._create_move("pick1_b", cls.pick_type, cls.product_b, 2) + cls._create_picking(pick1_a + pick1_b) + + pick2_a = cls._create_move("pick2_a", cls.pick_type, cls.product_a, 2) + pick2_b = cls._create_move("pick2_b", cls.pick_type, cls.product_b, 2) + cls._create_picking(pick2_a + pick2_b) + + pick3_a = cls._create_move( + "pick3_a", cls.pick_type, cls.product_a, 2, move_orig=int1_a + ) + pick3_b = cls._create_move( + "pick3_b", cls.pick_type, cls.product_b, 2, move_orig=int1_b + ) + cls._create_picking(pick3_a + pick3_b) + + pack1_a = cls._create_move( + "pack1_a", cls.pack_type, cls.product_a, 2, move_orig=pick1_a + ) + pack1_b = cls._create_move( + "pack1_b", cls.pack_type, cls.product_b, 2, move_orig=pick1_b + ) + cls._create_picking(pack1_a + pack1_b) + + pack2_a = cls._create_move( + "pack2_a", cls.pack_type, cls.product_a, 4, move_orig=pick2_a + pick3_a + ) + pack2_b = cls._create_move( + "pack2_b", cls.pack_type, cls.product_b, 4, move_orig=pick2_b + pick3_b + ) + cls._create_picking(pack2_a + pack2_b) + + out1_a = cls._create_move( + "out1_a", cls.out_type, cls.product_a, 6, move_orig=pack1_a + pack2_a + ) + out1_b = cls._create_move( + "out2_b", cls.out_type, cls.product_b, 6, move_orig=pack1_b + pack2_b + ) + cls._create_picking(out1_a + out1_b) + + return Chain( + int1_a, + int1_b, + pick1_a, + pick1_b, + pick2_a, + pick2_b, + pick3_a, + pick3_b, + pack1_a, + pack1_b, + pack2_a, + pack2_b, + out1_a, + out1_b, + ) + + 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()] + moves = self.env["stock.move"].browse(move_ids) + self.assertEqual( + moves, + expected, + "Priorities are not correct.\n\nExpected:\n{}\n\nGot:\n{}".format( + "\n".join( + [ + "* {}: {}".format(move.id, move.name) + for move in expected.sorted() + ] + ), + "\n".join( + ["* {}: {}".format(move.id, move.name) for move in moves.sorted()] + ), + ), + ) + + def test_query_graph_out1(self): + # enable on OUT + self._enable_consolidate_priority(self.chain.out1_a.picking_type_id) + c = self.chain + # these ones do not directly bring goods to OUT + self._test_query(c.pick1_a, self.env["stock.move"]) + self._test_query(c.pick2_a, self.env["stock.move"]) + self._test_query(c.int1_a, self.env["stock.move"]) + self._test_query(c.pick3_a, self.env["stock.move"]) + all_before_out = ( + c.int1_a + + c.int1_b + + c.pick1_a + + c.pick1_b + + c.pick2_a + + c.pick2_b + + c.pick3_a + + c.pick3_b + + c.pack1_a + + c.pack1_b + + c.pack2_a + + c.pack2_b + ) + # these ones do directly bring goods to OUT, they consolidate the moves + # before + self._test_query(c.pack1_a, all_before_out) + self._test_query(c.pack2_a, all_before_out) + + def test_query_graph_pack1(self): + # enable on PACK + self._enable_consolidate_priority(self.chain.pack1_a.picking_type_id) + c = self.chain + # this one does not directly bring goods to PACK + self._test_query(c.int1_a, self.env["stock.move"]) + # these ones do directly bring goods to PACK, they consolidate the moves + # before + self._test_query(c.pick1_a, c.pick1_a + c.pick1_b) + all_before_pack2 = ( + c.int1_a + c.int1_b + c.pick2_a + c.pick2_b + c.pick3_a + c.pick3_b + ) + self._test_query(c.pick2_a, all_before_pack2) + self._test_query(c.pick3_a, all_before_pack2) + + def test_query_graph_out1_pack1(self): + # enable on OUT and PACK + self._enable_consolidate_priority( + self.chain.out1_a.picking_type_id | self.chain.pack1_a.picking_type_id + ) + c = self.chain + all_before_out = ( + c.int1_a + + c.int1_b + + c.pick1_a + + c.pick1_b + + c.pick2_a + + c.pick2_b + + c.pick3_a + + c.pick3_b + + c.pack1_a + + c.pack1_b + + c.pack2_a + + c.pack2_b + ) + self._test_query(c.pack1_a, all_before_out) + self._test_query(c.pack2_a, all_before_out) + self._test_query(c.pick1_a, c.pick1_a + c.pick1_b) + all_before_pack2 = ( + c.int1_a + c.int1_b + c.pick2_a + c.pick2_b + c.pick3_a + c.pick3_b + ) + self._test_query(c.pick2_a, all_before_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 all_chain_moves(self): + return self.env["stock.move"].union(*[c for c in self.chain]) + + def assert_priority(self, changed_moves, unchanged_moves): + expected_priority = self.env["stock.move"]._consolidate_priority_value + changed_ok = all(move.priority == expected_priority for move in changed_moves) + unchanged_ok = all(move.priority == "1" for move in unchanged_moves) + self.assertTrue( + changed_ok and unchanged_ok, + "Priorities are not correct.\n\nExpected:\n{}\n{}\n\nGot:\n{}".format( + "\n".join( + [ + "* {}: {}".format(move.name, expected_priority) + for move in changed_moves.sorted("id") + ] + ), + "\n".join( + [ + "* {}: {}".format(move.name, "1") + for move in unchanged_moves.sorted("id") + ] + ), + "\n".join( + [ + "* {}: {}".format(move.name, move.priority) + for move in self.all_chain_moves().sorted( + lambda m: (-int(m.priority), m.id) + ) + ] + ), + ), + ) + + def assert_default_priority(self): + self.assert_priority( + # nothing is changed yet + self.env["stock.move"], + # all moves are unchanged + self.all_chain_moves(), + ) + + def test_flow_pick1_done_out1_consolidate(self): + c = self.chain + # enable on OUT + self._enable_consolidate_priority(c.out1_a.picking_type_id) + self._move_to_done(self.chain.pick1_a) + self.assert_default_priority() + self._move_to_done(self.chain.pack1_a) + self.assert_priority( + c.int1_a + + c.int1_b + + c.pick1_b + + c.pick2_a + + c.pick2_b + + c.pick3_a + + c.pick3_b + + c.pack1_b + + c.pack2_a, + # pick1 and pack2 are unchanged as already done + c.pack1_a + c.pick1_a + c.out1_a + c.out1_b, + ) + + def test_flow_int1_done_pack_consolidate(self): + c = self.chain + # enable on PACK + self._enable_consolidate_priority(c.pack2_a.picking_type_id) + self._move_to_done(c.int1_a) + self.assert_default_priority() + self._move_to_done(c.pick3_a) + self.assert_priority( + c.int1_b + c.pick2_a + c.pick2_b + c.pick3_b, + # int1 and pick3 are unchanged as already done + c.int1_a + c.pick3_a + # only moves *before* packs are changed + + c.pack1_a + c.pack1_b + c.pack2_a + c.pack2_b + # pick1 is unchanged because they don't + # go to the same transfer + + c.pick1_a + c.pick1_b + # outgoing moves are late to the party, no impact on them + + c.out1_a + c.out1_b, + )