diff --git a/setup/stock_picking_completion_info/odoo/addons/stock_picking_completion_info b/setup/stock_picking_completion_info/odoo/addons/stock_picking_completion_info new file mode 120000 index 000000000..9c5a54b68 --- /dev/null +++ b/setup/stock_picking_completion_info/odoo/addons/stock_picking_completion_info @@ -0,0 +1 @@ +../../../../stock_picking_completion_info \ No newline at end of file diff --git a/setup/stock_picking_completion_info/setup.py b/setup/stock_picking_completion_info/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/stock_picking_completion_info/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_move_common_dest/models/stock_move.py b/stock_move_common_dest/models/stock_move.py index 395c77f83..4cf44b567 100644 --- a/stock_move_common_dest/models/stock_move.py +++ b/stock_move_common_dest/models/stock_move.py @@ -10,17 +10,12 @@ class StockMove(models.Model): common_dest_move_ids = fields.Many2many( "stock.move", compute="_compute_common_dest_move_ids", + search="_search_compute_dest_move_ids", help="All the stock moves having a chained destination move sharing the" " same picking as the actual move's destination move", ) - @api.depends( - "move_dest_ids", - "move_dest_ids.picking_id", - "move_dest_ids.picking_id.move_lines", - "move_dest_ids.picking_id.move_lines.move_orig_ids", - ) - def _compute_common_dest_move_ids(self): + def _common_dest_move_query(self): sql = """SELECT smmr.move_orig_id move_id , array_agg(smmr2.move_orig_id) common_move_dest_ids FROM stock_move_move_rel smmr @@ -36,6 +31,16 @@ class StockMove(models.Model): AND smmr.move_orig_id IN %s GROUP BY smmr.move_orig_id; """ + return sql + + @api.depends( + "move_dest_ids", + "move_dest_ids.picking_id", + "move_dest_ids.picking_id.move_lines", + "move_dest_ids.picking_id.move_lines.move_orig_ids", + ) + def _compute_common_dest_move_ids(self): + sql = self._common_dest_move_query() self.env.cr.execute(sql, (tuple(self.ids),)) res = { row.get("move_id"): row.get("common_move_dest_ids") @@ -47,3 +52,16 @@ class StockMove(models.Model): move.common_dest_move_ids = [(6, 0, common_move_ids)] else: move.common_dest_move_ids = [(5, 0, 0)] + + def _search_compute_dest_move_ids(self, operator, value): + moves = self.search([("id", operator, value)]) + if not moves: + return [("id", "=", 0)] + sql = self._common_dest_move_query() + self.env.cr.execute(sql, (tuple(moves.ids),)) + res = [ + move_dest_id + for row in self.env.cr.dictfetchall() + for move_dest_id in row.get("common_move_dest_ids") or [] + ] + return [("id", "in", res)] diff --git a/stock_move_common_dest/tests/test_move_common_dest.py b/stock_move_common_dest/tests/test_move_common_dest.py index c32132f36..9af07ff05 100644 --- a/stock_move_common_dest/tests/test_move_common_dest.py +++ b/stock_move_common_dest/tests/test_move_common_dest.py @@ -154,3 +154,21 @@ class TestCommonMoveDest(SavepointCase): self.assertEqual(pack_move_1b.common_dest_move_ids, pack_move_1a) self.assertFalse(ship_move_1a.common_dest_move_ids) self.assertFalse(ship_move_1b.common_dest_move_ids) + self.assertEqual( + self.env["stock.move"].search( + [("common_dest_move_ids", "=", pick_move_1b.id)] + ), + pick_move_1a, + ) + self.assertEqual( + self.env["stock.move"].search( + [("common_dest_move_ids", "=", pick_move_1a.id)] + ), + pick_move_1b, + ) + self.assertEqual( + self.env["stock.move"].search( + [("common_dest_move_ids", "in", (pick_move_1a | pick_move_1b).ids)] + ), + pick_move_1a | pick_move_1b, + ) diff --git a/stock_picking_completion_info/__init__.py b/stock_picking_completion_info/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_picking_completion_info/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_picking_completion_info/__manifest__.py b/stock_picking_completion_info/__manifest__.py new file mode 100644 index 000000000..55093c1d1 --- /dev/null +++ b/stock_picking_completion_info/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Stock Picking Completion Info", + "summary": "Display on current document completion information according " + "to next operations", + "version": "13.0.1.0.0", + "development_status": "Alpha", + "category": "Warehouse Management", + "website": "https://github.com/OCA/stock-logistics-warehouse", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": ["stock_move_common_dest"], + "data": ["views/stock_picking.xml"], +} diff --git a/stock_picking_completion_info/models/__init__.py b/stock_picking_completion_info/models/__init__.py new file mode 100644 index 000000000..ae4c27227 --- /dev/null +++ b/stock_picking_completion_info/models/__init__.py @@ -0,0 +1 @@ +from . import stock_picking diff --git a/stock_picking_completion_info/models/stock_picking.py b/stock_picking_completion_info/models/stock_picking.py new file mode 100644 index 000000000..abaafa975 --- /dev/null +++ b/stock_picking_completion_info/models/stock_picking.py @@ -0,0 +1,86 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, fields, models + + +class PickingType(models.Model): + + _inherit = "stock.picking.type" + + display_completion_info = fields.Boolean( + help="Inform operator of a completed operation at processing and at" + " completion" + ) + + +class StockPicking(models.Model): + + _inherit = "stock.picking" + + completion_info = fields.Selection( + [ + ("no", "No"), + ( + "last_picking", + "Last picking: Completion of this operation allows next " + "operations to be processed.", + ), + ("next_picking_ready", "Next operations are ready to be processed."), + ( + "full_order_picking", + "Full order picking: You are processing a full order picking " + "that will allow next operation to be processed", + ), + ], + compute="_compute_completion_info", + ) + + @api.depends( + "picking_type_id.display_completion_info", + "move_lines.common_dest_move_ids.state", + ) + def _compute_completion_info(self): + for picking in self: + if ( + picking.state == "draft" + or not picking.picking_type_id.display_completion_info + ): + picking.completion_info = "no" + continue + # Depending moves are all the origin moves linked to the + # destination pickings' moves + depending_moves = picking.move_lines.mapped("common_dest_move_ids") + # If all the depending moves are done or canceled then next picking + # is ready to be processed + if picking.state == "done" and all( + m.state in ("done", "cancel") for m in depending_moves + ): + picking.completion_info = "next_picking_ready" + continue + # If all the depending moves are the moves on the actual picking + # then it's a full order and next picking is ready to be processed + if depending_moves == picking.move_lines: + picking.completion_info = "full_order_picking" + continue + # If there aren't any depending move from another picking that is + # not done, then actual picking is the last to process + other_depending_moves = (depending_moves - picking.move_lines).filtered( + lambda m: m.state not in ("done", "cancel") + ) + if not other_depending_moves: + picking.completion_info = "last_picking" + continue + picking.completion_info = "no" + + +class StockMove(models.Model): + + _inherit = "stock.move" + + def write(self, vals): + super().write(vals) + if "state" in vals: + # invalidate cache, the api.depends do not allow to find all + # the conditions to invalidate the field + self.env["stock.picking"].invalidate_cache(fnames=["completion_info"]) + return True diff --git a/stock_picking_completion_info/readme/CONTRIBUTORS.rst b/stock_picking_completion_info/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..e31e2f0c4 --- /dev/null +++ b/stock_picking_completion_info/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Akim Juillerat diff --git a/stock_picking_completion_info/readme/DESCRIPTION.rst b/stock_picking_completion_info/readme/DESCRIPTION.rst new file mode 100644 index 000000000..70b915d0c --- /dev/null +++ b/stock_picking_completion_info/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This module adds completion information on stock picking. + +If activated on the picking type, completion information is computed according +to the next chained pickings related to the stock moves of the actual picking. + +In other words, if all the previous moves linked to the destination pickings +moves are done, the completion of the actual picking allows the destination +pickings to be processed. In such case, a ribbon will appear on the stock +picking form view, to inform the stock operator. diff --git a/stock_picking_completion_info/tests/__init__.py b/stock_picking_completion_info/tests/__init__.py new file mode 100644 index 000000000..c496adc0f --- /dev/null +++ b/stock_picking_completion_info/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_picking_completion_info diff --git a/stock_picking_completion_info/tests/test_stock_picking_completion_info.py b/stock_picking_completion_info/tests/test_stock_picking_completion_info.py new file mode 100644 index 000000000..dac491e90 --- /dev/null +++ b/stock_picking_completion_info/tests/test_stock_picking_completion_info.py @@ -0,0 +1,285 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo.tests import SavepointCase + + +class TestStockPickingCompletionInfo(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.warehouse = cls.env.ref("stock.warehouse0") + cls.warehouse.write({"delivery_steps": "pick_pack_ship"}) + cls.customers_location = cls.env.ref("stock.stock_location_customers") + cls.output_location = cls.env.ref("stock.stock_location_output") + cls.packing_location = cls.env.ref("stock.location_pack_zone") + cls.stock_shelf_location = cls.env.ref("stock.stock_location_components") + cls.stock_shelf_2_location = cls.env.ref("stock.stock_location_14") + + cls.out_type = cls.warehouse.out_type_id + cls.pack_type = cls.warehouse.pack_type_id + cls.pick_type = cls.warehouse.pick_type_id + cls.pick_type.write({"display_completion_info": True}) + + cls.product_1 = cls.env["product.product"].create( + {"name": "Product 1", "type": "product"} + ) + cls.product_2 = cls.env["product.product"].create( + {"name": "Product 2", "type": "product"} + ) + + def _init_inventory(self, same_location=True): + # Product 1 on shelf 1 + # Product 2 on shelf 2 + inventory = self.env["stock.inventory"].create({"name": "Test init"}) + inventory.action_start() + if not same_location: + product_location_list = [ + (self.product_1, self.stock_shelf_location), + (self.product_2, self.stock_shelf_2_location), + ] + else: + product_location_list = [ + (self.product_1, self.stock_shelf_location), + (self.product_2, self.stock_shelf_location), + ] + lines_vals = list() + for product, location in product_location_list: + lines_vals.append( + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "product_qty": 10.0, + "location_id": location.id, + }, + ) + ) + inventory.write({"line_ids": lines_vals}) + inventory.action_validate() + + def _create_pickings(self, same_pick_location=True): + # Create delivery order + ship_order = self.env["stock.picking"].create( + { + "partner_id": self.partner_delta.id, + "location_id": self.output_location.id, + "location_dest_id": self.customers_location.id, + "picking_type_id": self.out_type.id, + } + ) + pack_order = self.env["stock.picking"].create( + { + "partner_id": self.partner_delta.id, + "location_id": self.packing_location.id, + "location_dest_id": self.output_location.id, + "picking_type_id": self.pack_type.id, + } + ) + pick_order = self.env["stock.picking"].create( + { + "partner_id": self.partner_delta.id, + "location_id": self.stock_shelf_location.id, + "location_dest_id": self.packing_location.id, + "picking_type_id": self.pick_type.id, + } + ) + if same_pick_location: + return ship_order, pack_order, pick_order + pick_order_2 = self.env["stock.picking"].create( + { + "partner_id": self.partner_delta.id, + "location_id": self.stock_shelf_2_location.id, + "location_dest_id": self.packing_location.id, + "picking_type_id": self.pick_type.id, + } + ) + return ship_order, pack_order, pick_order, pick_order_2 + + def _create_move( + self, + picking, + product, + state="waiting", + procure_method="make_to_order", + move_dest=None, + ): + move_vals = { + "name": product.name, + "product_id": product.id, + "product_uom_qty": 2.0, + "product_uom": product.uom_id.id, + "picking_id": picking.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + "state": state, + "procure_method": procure_method, + } + if move_dest: + move_vals["move_dest_ids"] = [(4, move_dest.id, False)] + return self.env["stock.move"].create(move_vals) + + def test_picking_all_at_once(self): + self._init_inventory() + ship_order, pack_order, pick_order = self._create_pickings() + ship_move_1 = self._create_move(ship_order, self.product_1) + pack_move_1 = self._create_move( + pack_order, self.product_1, move_dest=ship_move_1 + ) + pick_move_1 = self._create_move( + pick_order, + self.product_1, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_1, + ) + ship_move_2 = self._create_move(ship_order, self.product_2) + pack_move_2 = self._create_move( + pack_order, self.product_2, move_dest=ship_move_2 + ) + pick_move_2 = self._create_move( + pick_order, + self.product_2, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_2, + ) + self.assertEqual(pick_move_1.state, "confirmed") + self.assertEqual(pick_move_2.state, "confirmed") + self.assertEqual(pick_order.state, "confirmed") + self.assertEqual(pick_order.completion_info, "full_order_picking") + pick_order.action_assign() + self.assertEqual(pick_move_1.state, "assigned") + self.assertEqual(pick_move_2.state, "assigned") + self.assertEqual(pick_order.state, "assigned") + self.assertEqual(pick_order.completion_info, "full_order_picking") + wiz = self.env["stock.immediate.transfer"].create( + {"pick_ids": [(4, pick_order.id)]} + ) + wiz.process() + self.assertEqual(pick_move_1.state, "done") + self.assertEqual(pick_move_2.state, "done") + self.assertEqual(pick_order.state, "done") + self.assertEqual(pick_order.completion_info, "next_picking_ready") + + def test_picking_from_different_locations(self): + self._init_inventory(same_location=False) + ship_order, pack_order, pick_order_1, pick_order_2 = self._create_pickings( + same_pick_location=False + ) + ship_move_1 = self._create_move(ship_order, self.product_1) + pack_move_1 = self._create_move( + pack_order, self.product_1, move_dest=ship_move_1 + ) + pick_move_1 = self._create_move( + pick_order_1, + self.product_1, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_1, + ) + ship_move_2 = self._create_move(ship_order, self.product_2) + pack_move_2 = self._create_move( + pack_order, self.product_2, move_dest=ship_move_2 + ) + pick_move_2 = self._create_move( + pick_order_2, + self.product_2, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_2, + ) + self.assertEqual(pick_move_1.state, "confirmed") + self.assertEqual(pick_move_2.state, "confirmed") + self.assertEqual(pick_order_1.state, "confirmed") + self.assertEqual(pick_order_1.completion_info, "no") + self.assertEqual(pick_order_2.state, "confirmed") + self.assertEqual(pick_order_2.completion_info, "no") + pick_order_1.action_assign() + self.assertEqual(pick_move_1.state, "assigned") + self.assertEqual(pick_order_1.state, "assigned") + self.assertEqual(pick_order_1.completion_info, "no") + pick_order_2.action_assign() + self.assertEqual(pick_move_2.state, "assigned") + self.assertEqual(pick_order_2.state, "assigned") + self.assertEqual(pick_order_2.completion_info, "no") + wiz = self.env["stock.immediate.transfer"].create( + {"pick_ids": [(4, pick_order_1.id)]} + ) + wiz.process() + self.assertEqual(pick_move_1.state, "done") + self.assertEqual(pick_order_1.state, "done") + self.assertEqual(pick_order_1.completion_info, "no") + self.assertNotEqual(pick_move_2.state, "done") + self.assertNotEqual(pick_order_2.state, "done") + self.assertEqual(pick_order_2.completion_info, "last_picking") + wiz = self.env["stock.immediate.transfer"].create( + {"pick_ids": [(4, pick_order_2.id)]} + ) + wiz.process() + self.assertEqual(pick_move_2.state, "done") + self.assertEqual(pick_order_2.state, "done") + self.assertEqual(pick_order_2.completion_info, "next_picking_ready") + self.assertEqual(pick_order_1.completion_info, "next_picking_ready") + + def test_picking_with_backorder(self): + self._init_inventory() + ship_order, pack_order, pick_order = self._create_pickings() + ship_move_1 = self._create_move(ship_order, self.product_1) + pack_move_1 = self._create_move( + pack_order, self.product_1, move_dest=ship_move_1 + ) + pick_move_1 = self._create_move( + pick_order, + self.product_1, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_1, + ) + ship_move_2 = self._create_move(ship_order, self.product_2) + pack_move_2 = self._create_move( + pack_order, self.product_2, move_dest=ship_move_2 + ) + pick_move_2 = self._create_move( + pick_order, + self.product_2, + state="confirmed", + procure_method="make_to_stock", + move_dest=pack_move_2, + ) + self.assertEqual(pick_move_1.state, "confirmed") + self.assertEqual(pick_move_2.state, "confirmed") + self.assertEqual(pick_order.state, "confirmed") + self.assertEqual(pick_order.completion_info, "full_order_picking") + pick_order.action_assign() + self.assertEqual(pick_move_1.state, "assigned") + self.assertEqual(pick_move_2.state, "assigned") + self.assertEqual(pick_order.state, "assigned") + self.assertEqual(pick_order.completion_info, "full_order_picking") + # Process partially to create backorder + pick_move_1.move_line_ids.qty_done = 1.0 + pick_move_2.move_line_ids.qty_done = pick_move_2.move_line_ids.product_uom_qty + pick_order.action_done() + pick_backorder = self.env["stock.picking"].search( + [("backorder_id", "=", pick_order.id)] + ) + pick_backorder_move = pick_backorder.move_lines + self.assertEqual(pick_move_1.state, "done") + self.assertEqual(pick_move_2.state, "done") + self.assertEqual(pick_order.state, "done") + self.assertEqual(pick_backorder_move.state, "assigned") + self.assertEqual(pick_backorder.state, "assigned") + self.assertEqual(pick_order.completion_info, "no") + self.assertEqual(pick_backorder.completion_info, "last_picking") + # Process backorder + pick_backorder_move.move_line_ids.qty_done = ( + pick_backorder_move.move_line_ids.product_uom_qty + ) + pick_backorder.action_done() + self.assertEqual(pick_backorder_move.state, "done") + self.assertEqual(pick_backorder.state, "done") + self.assertEqual(pick_order.completion_info, "next_picking_ready") + self.assertEqual(pick_backorder.completion_info, "next_picking_ready") diff --git a/stock_picking_completion_info/views/stock_picking.xml b/stock_picking_completion_info/views/stock_picking.xml new file mode 100644 index 000000000..318dbf62f --- /dev/null +++ b/stock_picking_completion_info/views/stock_picking.xml @@ -0,0 +1,44 @@ + + + + Operation Types inherit + + stock.picking.type + + + + + + + + stock.picking.form.inherit + + stock.picking + + + + + +