From 4ab233456a789cafa8652b9f6abb73dd527644fc Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 2 Jul 2019 12:23:11 +0200 Subject: [PATCH 01/21] Add stock_picking_zone When a move is assigned and its source location (or one of its parent) corresponds to a picking type flagged "is_zone", a new move will be inserted with this picking type. --- .../odoo/addons/stock_picking_zone | 1 + setup/stock_picking_zone/setup.py | 6 + stock_picking_zone/__init__.py | 1 + stock_picking_zone/__manifest__.py | 19 +++ .../demo/stock_location_demo.xml | 28 ++++ .../demo/stock_picking_type_demo.xml | 25 +++ stock_picking_zone/models/__init__.py | 2 + stock_picking_zone/models/stock_move.py | 74 +++++++++ .../models/stock_picking_type.py | 56 +++++++ stock_picking_zone/readme/CONTRIBUTORS.rst | 2 + stock_picking_zone/readme/DESCRIPTION.rst | 10 ++ stock_picking_zone/tests/__init__.py | 1 + stock_picking_zone/tests/test_picking_zone.py | 146 ++++++++++++++++++ .../views/stock_picking_type_views.xml | 15 ++ 14 files changed, 386 insertions(+) create mode 120000 setup/stock_picking_zone/odoo/addons/stock_picking_zone create mode 100644 setup/stock_picking_zone/setup.py create mode 100644 stock_picking_zone/__init__.py create mode 100644 stock_picking_zone/__manifest__.py create mode 100644 stock_picking_zone/demo/stock_location_demo.xml create mode 100644 stock_picking_zone/demo/stock_picking_type_demo.xml create mode 100644 stock_picking_zone/models/__init__.py create mode 100644 stock_picking_zone/models/stock_move.py create mode 100644 stock_picking_zone/models/stock_picking_type.py create mode 100644 stock_picking_zone/readme/CONTRIBUTORS.rst create mode 100644 stock_picking_zone/readme/DESCRIPTION.rst create mode 100644 stock_picking_zone/tests/__init__.py create mode 100644 stock_picking_zone/tests/test_picking_zone.py create mode 100644 stock_picking_zone/views/stock_picking_type_views.xml diff --git a/setup/stock_picking_zone/odoo/addons/stock_picking_zone b/setup/stock_picking_zone/odoo/addons/stock_picking_zone new file mode 120000 index 000000000000..a5f5df4d25bb --- /dev/null +++ b/setup/stock_picking_zone/odoo/addons/stock_picking_zone @@ -0,0 +1 @@ +../../../../stock_picking_zone \ No newline at end of file diff --git a/setup/stock_picking_zone/setup.py b/setup/stock_picking_zone/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/stock_picking_zone/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/stock_picking_zone/__init__.py b/stock_picking_zone/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/stock_picking_zone/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_picking_zone/__manifest__.py b/stock_picking_zone/__manifest__.py new file mode 100644 index 000000000000..13d742919c2d --- /dev/null +++ b/stock_picking_zone/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) +{ + 'name': "Stock Picking Zone", + 'summary': """Warehouse Operations By Zones""", + 'author': 'Camptocamp, Odoo Community Association (OCA)', + 'website': "https://github.com/OCA/stock-logistics-warehouse", + 'category': 'Warehouse Management', + 'version': '12.0.1.0.0', + 'license': 'AGPL-3', + 'depends': [ + 'stock', + ], + 'data': [ + 'views/stock_picking_type_views.xml', + 'demo/stock_location_demo.xml', + 'demo/stock_picking_type_demo.xml', + ], + 'installable': True, +} diff --git a/stock_picking_zone/demo/stock_location_demo.xml b/stock_picking_zone/demo/stock_location_demo.xml new file mode 100644 index 000000000000..2d247286ec62 --- /dev/null +++ b/stock_picking_zone/demo/stock_location_demo.xml @@ -0,0 +1,28 @@ + + + + + Highbay + + + + Bay A + + + + Bin 1 + + + + Bin 2 + + + + + Handover + + + + diff --git a/stock_picking_zone/demo/stock_picking_type_demo.xml b/stock_picking_zone/demo/stock_picking_type_demo.xml new file mode 100644 index 000000000000..91d51a83555d --- /dev/null +++ b/stock_picking_zone/demo/stock_picking_type_demo.xml @@ -0,0 +1,25 @@ + + + + + Highbay Handover + stock.ho + HO/ + 5 + 1 + 1 + + + + Highbay Handover + internal + + + + + + + + + + diff --git a/stock_picking_zone/models/__init__.py b/stock_picking_zone/models/__init__.py new file mode 100644 index 000000000000..90f60bebb8fa --- /dev/null +++ b/stock_picking_zone/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_move +from . import stock_picking_type diff --git a/stock_picking_zone/models/stock_move.py b/stock_picking_zone/models/stock_move.py new file mode 100644 index 000000000000..1917db76a1c9 --- /dev/null +++ b/stock_picking_zone/models/stock_move.py @@ -0,0 +1,74 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo import models + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _action_assign(self): + super()._action_assign() + self._apply_move_location_zone() + + def _apply_move_location_zone(self): + for move in self: + if move.state != 'assigned': + continue + pick_type_model = self.env['stock.picking.type'] + # TODO what if we have more than one move line? + # split? + source = move.move_line_ids[0].location_id + zone = pick_type_model._find_zone_for_location(source) + if not zone: + continue + if move.location_dest_id == zone.default_location_dest_id: + continue + move._do_unreserve() + move.write({ + 'location_dest_id': zone.default_location_dest_id.id, + 'picking_type_id': zone.id, + }) + move._insert_middle_moves() + move._assign_picking() + move._action_assign() + + def _insert_middle_moves(self): + self.ensure_one() + dest_moves = self.move_dest_ids + dest_location = self.location_dest_id + for dest_move in dest_moves: + final_location = dest_move.location_id + if dest_location == final_location: + # shortcircuit to avoid a query checking if it is a child + continue + child_locations = self.env['stock.location'].search([ + ('id', 'child_of', final_location.id) + ]) + if dest_location in child_locations: + # normal behavior, we don't need a move between A and B + continue + # Insert move between the source and destination for the new + # operation + middle_move_values = self._prepare_middle_move_values( + final_location + ) + middle_move = self.copy(middle_move_values) + dest_move.write({ + 'move_orig_ids': [(3, self.id), (4, middle_move.id)], + }) + # FIXME: if we have more than one move line on a move, + # the move will only have the dest of the last one. + # We have to split the move. + self.write({ + 'move_dest_ids': [(3, dest_move.id), (4, middle_move.id)], + }) + middle_move._action_confirm() + + def _prepare_middle_move_values(self, destination): + return { + 'picking_id': False, + 'location_id': self.location_dest_id.id, + 'location_dest_id': destination.id, + 'state': 'waiting', + 'picking_type_id': self.picking_id.picking_type_id.id, + } diff --git a/stock_picking_zone/models/stock_picking_type.py b/stock_picking_zone/models/stock_picking_type.py new file mode 100644 index 000000000000..796b82a76622 --- /dev/null +++ b/stock_picking_zone/models/stock_picking_type.py @@ -0,0 +1,56 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo import _, api, exceptions, fields, models + + +class StockPickingType(models.Model): + _inherit = 'stock.picking.type' + + is_zone = fields.Boolean( + help="Change destination of the move line according to the" + " default destination setup after reservation occurs", + ) + + @api.constrains('is_zone', 'default_location_src_id') + def _check_zone_location_src_unique(self): + for zone in self: + src_location = zone.default_location_src_id + domain = [ + ('is_zone', '=', True), + ('default_location_src_id', '=', src_location.id), + ('id', '!=', zone.id) + ] + other = self.search(domain) + if other: + raise exceptions.ValidationError( + _('Another zone picking type (%s) exists for' + ' the some source location.') % (other.display_name,) + ) + + @api.model + def _find_zone_for_location(self, location): + # First select all the parent locations and the matching + # zones. In a second step, the zone matching the closest location + # is searched in memory. This is to avoid doing an SQL query + # for each location in the tree. + tree = self.env['stock.location'].search( + [('id', 'parent_of', location.id)], + # the recordset will be ordered bottom location to top location + order='parent_path desc' + ) + zones = self.search([ + ('is_zone', '=', True), + ('default_location_src_id', 'in', tree.ids) + ]) + # the first location is the current move line's source location, + # then we climb up the tree of locations + for location in tree: + match = [ + zone for zone in zones + if zone.default_location_src_id == location + ] + if match: + # we can only have one match as we have a unique + # constraint on is_zone + source location + return match[0] + return self.browse() diff --git a/stock_picking_zone/readme/CONTRIBUTORS.rst b/stock_picking_zone/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000000..63ccd231e8e3 --- /dev/null +++ b/stock_picking_zone/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Joël Grand-Guillaume +* Guewen Baconnier diff --git a/stock_picking_zone/readme/DESCRIPTION.rst b/stock_picking_zone/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..da4e3a5e0d2b --- /dev/null +++ b/stock_picking_zone/readme/DESCRIPTION.rst @@ -0,0 +1,10 @@ +Route explains the steps you want to produce whereas the “picking zone” defines +how operations are grouped according to their final source and destination +location. + +This allows for example: + +* To parallelize picking operations in two main zone of a warehouse, splitting + them in two different picking type +* To define pre-picking (wave) in some sub-zones, then roundtrip picking of the + sub-zone waves diff --git a/stock_picking_zone/tests/__init__.py b/stock_picking_zone/tests/__init__.py new file mode 100644 index 000000000000..5d0c7ce96977 --- /dev/null +++ b/stock_picking_zone/tests/__init__.py @@ -0,0 +1 @@ +from . import test_picking_zone diff --git a/stock_picking_zone/tests/test_picking_zone.py b/stock_picking_zone/tests/test_picking_zone.py new file mode 100644 index 000000000000..8a2d17018275 --- /dev/null +++ b/stock_picking_zone/tests/test_picking_zone.py @@ -0,0 +1,146 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +from odoo.tests import common + + +class TestPickingZone(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_hb_1 = cls.env['stock.location'].create({ + 'name': 'Highbay Shelve 1', + 'location_id': cls.location_hb.id, + }) + cls.location_hb_1_1 = cls.env['stock.location'].create({ + 'name': 'Highbay Shelve 1 Bin 1', + 'location_id': cls.location_hb_1.id, + }) + cls.location_hb_1_2 = cls.env['stock.location'].create({ + 'name': 'Highbay Shelve 1 Bin 2', + 'location_id': cls.location_hb_1.id, + }) + + cls.location_handover = cls.env['stock.location'].create({ + 'name': 'Handover', + 'location_id': cls.wh.view_location_id.id, + }) + + cls.product_a = cls.env['product.product'].create({ + 'name': 'Product A', 'type': 'product', + }) + + picking_type_sequence = cls.env['ir.sequence'].create({ + 'name': 'WH/Handover', + 'prefix': 'WH/HO/', + 'padding': 5, + 'company_id': cls.wh.company_id.id, + }) + cls.pick_type_zone = cls.env['stock.picking.type'].create({ + 'name': 'Zone', + 'code': 'internal', + 'use_create_lots': False, + 'use_existing_lots': True, + 'default_location_src_id': cls.location_hb.id, + 'default_location_dest_id': cls.location_handover.id, + 'is_zone': True, + 'sequence_id': picking_type_sequence.id, + }) + + def _create_pick_ship(self, wh): + 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, + }) + dest = self.env['stock.move'].create({ + 'name': self.product_a.name, + 'product_id': self.product_a.id, + 'product_uom_qty': 10, + 'product_uom': self.product_a.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', + }) + + 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, + }) + + self.env['stock.move'].create({ + 'name': self.product_a.name, + 'product_id': self.product_a.id, + 'product_uom_qty': 10, + 'product_uom': self.product_a.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 test_change_location_to_zone(self): + + pick_picking, customer_picking = self._create_pick_ship(self.wh) + 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.assertEqual(ml.location_id, self.location_hb_1_2) + self.assertEqual(ml.location_dest_id, self.location_handover) + + self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_zone) + + self.assertEqual(move_a.location_id, self.wh.lot_stock_id) + self.assertEqual(move_a.location_dest_id, self.location_handover) + # the move stays B stays on the same source location (sticky) + self.assertEqual(move_b.location_id, self.wh.wh_output_stock_loc_id) + self.assertEqual(move_b.location_dest_id, self.customer_loc) + + move_middle = move_a.move_dest_ids + self.assertEqual(move_middle.location_id, move_a.location_dest_id) + self.assertEqual(move_middle.location_dest_id, move_b.location_id) + + self.assertEqual( + move_a.picking_id.location_dest_id, + self.location_handover + ) + self.assertEqual( + move_middle.picking_id.location_id, + self.location_handover + ) + + self.assertEqual(move_a.state, 'assigned') + self.assertEqual(move_middle.state, 'waiting') + self.assertEqual(move_b.state, 'waiting') diff --git a/stock_picking_zone/views/stock_picking_type_views.xml b/stock_picking_zone/views/stock_picking_type_views.xml new file mode 100644 index 000000000000..cd57aa8a2d7c --- /dev/null +++ b/stock_picking_zone/views/stock_picking_type_views.xml @@ -0,0 +1,15 @@ + + + + + Operation Types + stock.picking.type + + + + + + + + + From a7dc12526045725eff513dd27d6468487030f039 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 5 Jul 2019 15:57:21 +0200 Subject: [PATCH 02/21] Fix use case with different moves, keep only one picking --- .../demo/stock_location_demo.xml | 2 +- stock_picking_zone/models/stock_move.py | 4 +- stock_picking_zone/tests/test_picking_zone.py | 279 +++++++++++++++--- 3 files changed, 234 insertions(+), 51 deletions(-) diff --git a/stock_picking_zone/demo/stock_location_demo.xml b/stock_picking_zone/demo/stock_location_demo.xml index 2d247286ec62..d721e0a8f957 100644 --- a/stock_picking_zone/demo/stock_location_demo.xml +++ b/stock_picking_zone/demo/stock_location_demo.xml @@ -22,7 +22,7 @@ Handover + eval="obj().env.ref('stock.warehouse0').lot_stock_id.id"/> diff --git a/stock_picking_zone/models/stock_move.py b/stock_picking_zone/models/stock_move.py index 1917db76a1c9..d3ec93b6bd25 100644 --- a/stock_picking_zone/models/stock_move.py +++ b/stock_picking_zone/models/stock_move.py @@ -62,12 +62,12 @@ def _insert_middle_moves(self): self.write({ 'move_dest_ids': [(3, dest_move.id), (4, middle_move.id)], }) - middle_move._action_confirm() + middle_move._action_confirm(merge=False) def _prepare_middle_move_values(self, destination): return { 'picking_id': False, - 'location_id': self.location_dest_id.id, + 'location_id': self.location_id.id, 'location_dest_id': destination.id, 'state': 'waiting', 'picking_type_id': self.picking_id.picking_type_id.id, diff --git a/stock_picking_zone/tests/test_picking_zone.py b/stock_picking_zone/tests/test_picking_zone.py index 8a2d17018275..7a3e23484d8c 100644 --- a/stock_picking_zone/tests/test_picking_zone.py +++ b/stock_picking_zone/tests/test_picking_zone.py @@ -21,26 +21,33 @@ def setUpClass(cls): '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 Shelve 1', + 'name': 'Highbay Shelf 1', 'location_id': cls.location_hb.id, }) cls.location_hb_1_1 = cls.env['stock.location'].create({ - 'name': 'Highbay Shelve 1 Bin 1', + '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 Shelve 1 Bin 2', + '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.view_location_id.id, + 'location_id': cls.wh.lot_stock_id.id, }) - cls.product_a = cls.env['product.product'].create({ - 'name': 'Product A', 'type': 'product', + cls.product1 = cls.env['product.product'].create({ + 'name': 'Product 1', 'type': 'product', + }) + cls.product2 = cls.env['product.product'].create({ + 'name': 'Product 2', 'type': 'product', }) picking_type_sequence = cls.env['ir.sequence'].create({ @@ -60,24 +67,18 @@ def setUpClass(cls): 'sequence_id': picking_type_sequence.id, }) - def _create_pick_ship(self, wh): + def _create_pick_ship(self, wh, products=[]): + """Create pick+ship pickings + + Products must be a list of tuples (product, quantity). + One stock move will be create for each tuple. + """ 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, }) - dest = self.env['stock.move'].create({ - 'name': self.product_a.name, - 'product_id': self.product_a.id, - 'product_uom_qty': 10, - 'product_uom': self.product_a.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', - }) pick_picking = self.env['stock.picking'].create({ 'location_id': wh.lot_stock_id.id, @@ -86,17 +87,30 @@ def _create_pick_ship(self, wh): 'picking_type_id': wh.pick_type_id.id, }) - self.env['stock.move'].create({ - 'name': self.product_a.name, - 'product_id': self.product_a.id, - 'product_uom_qty': 10, - 'product_uom': self.product_a.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', - }) + 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): @@ -104,9 +118,41 @@ def _update_product_qty_in_location(self, location, product, quantity): product, location, quantity ) - def test_change_location_to_zone(self): + 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) - pick_picking, customer_picking = self._create_pick_ship(self.wh) + 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 test_change_location_to_zone(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 @@ -117,30 +163,167 @@ def test_change_location_to_zone(self): ml = move_a.move_line_ids self.assertEqual(len(ml), 1) - self.assertEqual(ml.location_id, self.location_hb_1_2) - self.assertEqual(ml.location_dest_id, self.location_handover) + self.assert_src_highbay_1_2(ml) + self.assert_dest_handover(ml) self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_zone) - self.assertEqual(move_a.location_id, self.wh.lot_stock_id) - self.assertEqual(move_a.location_dest_id, self.location_handover) - # the move stays B stays on the same source location (sticky) - self.assertEqual(move_b.location_id, self.wh.wh_output_stock_loc_id) - self.assertEqual(move_b.location_dest_id, self.customer_loc) + 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 - self.assertEqual(move_middle.location_id, move_a.location_dest_id) - self.assertEqual(move_middle.location_dest_id, move_b.location_id) + # the middle move stays in the same source location than the original + # move: the move line will be in the sub-locations (handover) - self.assertEqual( - move_a.picking_id.location_dest_id, - self.location_handover - ) - self.assertEqual( - move_middle.picking_id.location_id, - self.location_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 + move_a.move_line_ids.qty_done = move_a.move_line_ids.product_uom_qty + move_a._action_done() + 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_move_lines(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_zone) + 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 + qty = move_a_p1.move_line_ids.product_uom_qty + move_a_p1.move_line_ids.qty_done = qty + move_a_p1.picking_id.action_done() + + 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) + + qty = move_middle.move_line_ids.product_uom_qty + move_middle.move_line_ids.qty_done = qty + qty = move_a_p2.move_line_ids.product_uom_qty + move_a_p2.move_line_ids.qty_done = qty + pick_picking.action_done() + + 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') + + qty = move_b_p1.move_line_ids.product_uom_qty + move_b_p1.move_line_ids.qty_done = qty + qty = move_b_p2.move_line_ids.product_uom_qty + move_b_p2.move_line_ids.qty_done = qty + customer_picking.action_done() + + 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') From f8a5a7cdee8e9480c405d1d5802c870ef6493e2c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 8 Jul 2019 17:06:42 +0200 Subject: [PATCH 03/21] Support split of moves for sourcing from different zones When a move has several move lines because it is sourced from different locations, and these locations come from different zones, or from a zone and another location which is not a zone, we have to split the move in as many moves as we have zones, and chain them properly. This is needed because otherwise, the move for, for instance taking goods from a shelf would have to wait on the move taking from the Higbay which has been added in front of it. We expect that we can already process the Shelf one. The algorithm is to find all the zones of a move, split the move if any. But to do so, we have to unreserve the move first. As we want to keep the same quantities from the same locations (eg. 6 from the highbay bin 1), when we reserve again the quants, we have to force the reservation system to take the goods from the same location than we had originally (otherwise, the quantities that we used to split the move may change and we are back to the beginning). --- stock_picking_zone/models/__init__.py | 1 + stock_picking_zone/models/stock_move.py | 91 ++++++++++-- stock_picking_zone/models/stock_quant.py | 59 ++++++++ stock_picking_zone/tests/test_picking_zone.py | 134 +++++++++++++++--- 4 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 stock_picking_zone/models/stock_quant.py diff --git a/stock_picking_zone/models/__init__.py b/stock_picking_zone/models/__init__.py index 90f60bebb8fa..b2c5936e1170 100644 --- a/stock_picking_zone/models/__init__.py +++ b/stock_picking_zone/models/__init__.py @@ -1,2 +1,3 @@ from . import stock_move from . import stock_picking_type +from . import stock_quant diff --git a/stock_picking_zone/models/stock_move.py b/stock_picking_zone/models/stock_move.py index d3ec93b6bd25..a2afed1eaf67 100644 --- a/stock_picking_zone/models/stock_move.py +++ b/stock_picking_zone/models/stock_move.py @@ -1,5 +1,6 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) +from itertools import chain from odoo import models @@ -8,21 +9,98 @@ class StockMove(models.Model): def _action_assign(self): super()._action_assign() - self._apply_move_location_zone() + if not self.env.context.get('exclude_apply_zone'): + moves = self._split_per_zone() + moves._apply_move_location_zone() + + def _split_per_zone(self): + move_to_assign_ids = set() + new_move_per_location = {} + for move in self: + if move.state not in ('assigned', 'partially_available'): + continue + + pick_type_model = self.env['stock.picking.type'] + + # 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 zones where + # we have to take products + zone_quantities = {} + for source_location, qty in move_lines.items(): + zone = pick_type_model._find_zone_for_location(source_location) + zone_quantities.setdefault(zone, 0.0) + zone_quantities[zone] += qty + + if len(zone_quantities) == 1: + # The whole quantity can be taken from only one zone (a + # non-zone being equal to a zone here), nothing to split. + continue + + move._do_unreserve() + move_to_assign_ids.add(move.id) + zone_location = zone.default_location_src_id + for zone, qty in zone_quantities.items(): + # if zone is False-ish, we take in a location which is + # not a zone + if zone: + # split returns the same move if the qty is the same + new_move_id = move._split(qty) + new_move_per_location.setdefault(zone_location.id, []) + new_move_per_location[zone_location.id].append(new_move_id) + + # it is important to assign the zones 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_zone, will be called + # when all lines are processed. + exclude_apply_zone=True, + # Force reservation of quants in the zone 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_zone(self): for move in self: - if move.state != 'assigned': + if move.state not in ('assigned', 'partially_available'): continue + pick_type_model = self.env['stock.picking.type'] - # TODO what if we have more than one move line? - # split? + + # 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 zones, + # they have been split in _split_per_zone(), so we can take the + # first one source = move.move_line_ids[0].location_id zone = pick_type_model._find_zone_for_location(source) if not zone: continue - if move.location_dest_id == zone.default_location_dest_id: + if (move.picking_type_id == zone and + move.location_dest_id == zone.default_location_dest_id): + # already done continue + move._do_unreserve() move.write({ 'location_dest_id': zone.default_location_dest_id.id, @@ -56,9 +134,6 @@ def _insert_middle_moves(self): dest_move.write({ 'move_orig_ids': [(3, self.id), (4, middle_move.id)], }) - # FIXME: if we have more than one move line on a move, - # the move will only have the dest of the last one. - # We have to split the move. self.write({ 'move_dest_ids': [(3, dest_move.id), (4, middle_move.id)], }) diff --git a/stock_picking_zone/models/stock_quant.py b/stock_picking_zone/models/stock_quant.py new file mode 100644 index 000000000000..f076476ea91a --- /dev/null +++ b/stock_picking_zone/models/stock_quant.py @@ -0,0 +1,59 @@ +# Copyright 2019 Camptocamp (https://www.camptocamp.com) + +"""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_picking_zone/tests/test_picking_zone.py b/stock_picking_zone/tests/test_picking_zone.py index 7a3e23484d8c..032bb8bf2cb3 100644 --- a/stock_picking_zone/tests/test_picking_zone.py +++ b/stock_picking_zone/tests/test_picking_zone.py @@ -149,6 +149,11 @@ def assert_dest_output(self, record): 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_zone(self): pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] @@ -191,8 +196,7 @@ def test_change_location_to_zone(self): # we deliver move A to check that our middle move line properly takes # goods from the handover - move_a.move_line_ids.qty_done = move_a.move_line_ids.product_uom_qty - move_a._action_done() + self.process_operations(move_a) self.assertEqual(move_a.state, 'done') self.assertEqual(move_middle.state, 'assigned') self.assertEqual(move_b.state, 'waiting') @@ -202,7 +206,7 @@ def test_change_location_to_zone(self): self.assert_src_handover(move_line_middle) self.assert_dest_output(move_line_middle) - def test_several_move_lines(self): + def test_several_moves(self): pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10), (self.product2, 10)] ) @@ -287,9 +291,7 @@ def test_several_move_lines(self): 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 - qty = move_a_p1.move_line_ids.product_uom_qty - move_a_p1.move_line_ids.qty_done = qty - move_a_p1.picking_id.action_done() + self.process_operations(move_a_p1) self.assertEqual(move_a_p1.state, 'done') self.assertEqual(move_a_p2.state, 'assigned') @@ -304,11 +306,8 @@ def test_several_move_lines(self): # Output self.assert_dest_output(move_line_middle) - qty = move_middle.move_line_ids.product_uom_qty - move_middle.move_line_ids.qty_done = qty - qty = move_a_p2.move_line_ids.product_uom_qty - move_a_p2.move_line_ids.qty_done = qty - pick_picking.action_done() + 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') @@ -316,14 +315,117 @@ def test_several_move_lines(self): self.assertEqual(move_b_p1.state, 'assigned') self.assertEqual(move_b_p2.state, 'assigned') - qty = move_b_p1.move_line_ids.product_uom_qty - move_b_p1.move_line_ids.qty_done = qty - qty = move_b_p2.move_line_ids.product_uom_qty - move_b_p2.move_line_ids.qty_done = qty - customer_picking.action_done() + 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_zone) + 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') From f9d0f4ba9827310f5e8a504f3a4f4bfa2f11e688 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 9 Jul 2019 08:35:54 +0200 Subject: [PATCH 04/21] fixup! Fix use case with different moves, keep only one picking --- stock_picking_zone/tests/test_picking_zone.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stock_picking_zone/tests/test_picking_zone.py b/stock_picking_zone/tests/test_picking_zone.py index 032bb8bf2cb3..670e523cf540 100644 --- a/stock_picking_zone/tests/test_picking_zone.py +++ b/stock_picking_zone/tests/test_picking_zone.py @@ -67,12 +67,14 @@ def setUpClass(cls): 'sequence_id': picking_type_sequence.id, }) - def _create_pick_ship(self, wh, products=[]): + 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, From dbbce0f3f14e4aa0e1fda252cb6f4816bd9ed7f7 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 9 Jul 2019 08:36:22 +0200 Subject: [PATCH 05/21] fixup! Add stock_picking_zone --- stock_picking_zone/demo/stock_picking_type_demo.xml | 1 - stock_picking_zone/readme/CONFIGURE.rst | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 stock_picking_zone/readme/CONFIGURE.rst diff --git a/stock_picking_zone/demo/stock_picking_type_demo.xml b/stock_picking_zone/demo/stock_picking_type_demo.xml index 91d51a83555d..5761b5c40c19 100644 --- a/stock_picking_zone/demo/stock_picking_type_demo.xml +++ b/stock_picking_zone/demo/stock_picking_type_demo.xml @@ -14,7 +14,6 @@ Highbay Handover internal - diff --git a/stock_picking_zone/readme/CONFIGURE.rst b/stock_picking_zone/readme/CONFIGURE.rst new file mode 100644 index 000000000000..fbb186440aa4 --- /dev/null +++ b/stock_picking_zone/readme/CONFIGURE.rst @@ -0,0 +1,10 @@ +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +Create an operation type and activate the "Is Zone" checkbox. +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 type's source location. From d18514289caad3767b8ac3791b302641b8f4a14d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 12 Jul 2019 08:47:21 +0200 Subject: [PATCH 06/21] fixup! Add stock_picking_zone --- stock_picking_zone/__manifest__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stock_picking_zone/__manifest__.py b/stock_picking_zone/__manifest__.py index 13d742919c2d..81f14e520668 100644 --- a/stock_picking_zone/__manifest__.py +++ b/stock_picking_zone/__manifest__.py @@ -10,10 +10,12 @@ 'depends': [ 'stock', ], - 'data': [ - 'views/stock_picking_type_views.xml', + 'demo': [ 'demo/stock_location_demo.xml', 'demo/stock_picking_type_demo.xml', ], + 'data': [ + 'views/stock_picking_type_views.xml', + ], 'installable': True, } From 08dd29e69ea7a283f0e7fde386e34ee8f7a9b651 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 7 Aug 2019 12:36:17 +0200 Subject: [PATCH 07/21] Fix zone constraints --- stock_picking_zone/models/stock_picking_type.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/stock_picking_zone/models/stock_picking_type.py b/stock_picking_zone/models/stock_picking_type.py index 796b82a76622..9842eac1ebe8 100644 --- a/stock_picking_zone/models/stock_picking_type.py +++ b/stock_picking_zone/models/stock_picking_type.py @@ -13,12 +13,14 @@ class StockPickingType(models.Model): @api.constrains('is_zone', 'default_location_src_id') def _check_zone_location_src_unique(self): - for zone in self: - src_location = zone.default_location_src_id + for picking_type in self: + if not picking_type.is_zone: + continue + src_location = picking_type.default_location_src_id domain = [ ('is_zone', '=', True), ('default_location_src_id', '=', src_location.id), - ('id', '!=', zone.id) + ('id', '!=', picking_type.id) ] other = self.search(domain) if other: From 63a5e328a3cc7c75963187a08a98e6b833072f41 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 28 Aug 2019 15:18:05 +0200 Subject: [PATCH 08/21] Add Alpha development status, add roadmap --- stock_picking_zone/README.rst | 102 ++++++++++++++++++++++++++ stock_picking_zone/__manifest__.py | 1 + stock_picking_zone/readme/ROADMAP.rst | 5 ++ 3 files changed, 108 insertions(+) create mode 100644 stock_picking_zone/README.rst create mode 100644 stock_picking_zone/readme/ROADMAP.rst diff --git a/stock_picking_zone/README.rst b/stock_picking_zone/README.rst new file mode 100644 index 000000000000..9c5ab0b19036 --- /dev/null +++ b/stock_picking_zone/README.rst @@ -0,0 +1,102 @@ +================== +Stock Picking Zone +================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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/12.0/stock_picking_zone + :alt: OCA/stock-logistics-warehouse +.. |badge4| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/153/12.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| + +Route explains the steps you want to produce whereas the “picking zone” defines +how operations are grouped according to their final source and destination +location. + +This allows for example: + +* To parallelize picking operations in two main zone of a warehouse, splitting + them in two different picking type +* To define pre-picking (wave) in some sub-zones, then roundtrip picking of the + sub-zone waves + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +In Inventory Settings, you must have: + + * Storage Locations + * Multi-Warehouses + * Multi-Step Routes + +Create an operation type and activate the "Is Zone" checkbox. +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 type's source location. + +Known issues / Roadmap +====================== + +The concept of "zone" is duplicated with the zones introduced by +"stock_location_zone" in +https://github.com/OCA/stock-logistics-warehouse/pull/653 +They will be merged in the same concept. Note that considering the +alpha version of this module, no data migration will be done. + +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 + +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_picking_zone/__manifest__.py b/stock_picking_zone/__manifest__.py index 81f14e520668..e86d9d487c88 100644 --- a/stock_picking_zone/__manifest__.py +++ b/stock_picking_zone/__manifest__.py @@ -18,4 +18,5 @@ 'views/stock_picking_type_views.xml', ], 'installable': True, + 'development_status': 'Alpha', } diff --git a/stock_picking_zone/readme/ROADMAP.rst b/stock_picking_zone/readme/ROADMAP.rst new file mode 100644 index 000000000000..ebc15a66e633 --- /dev/null +++ b/stock_picking_zone/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +The concept of "zone" is duplicated with the zones introduced by +"stock_location_zone" in +https://github.com/OCA/stock-logistics-warehouse/pull/653 +They will be merged in the same concept. Note that considering the +alpha version of this module, no data migration will be done. From 87ee5a701013147ef508d49dfabf9117775c47df Mon Sep 17 00:00:00 2001 From: Akim Juillerat Date: Wed, 11 Sep 2019 14:06:46 +0200 Subject: [PATCH 09/21] Rename stock_picking_zone to stock_picking_type_routing_operation Move is_zone flag to stock.location picking_type M2o Replace occurences of zone for routing operation --- .../stock_picking_type_routing_operation | 1 + .../setup.py | 0 .../odoo/addons/stock_picking_zone | 1 - .../README.rst | 0 .../__init__.py | 0 .../__manifest__.py | 2 +- .../demo/stock_location_demo.xml | 6 +- .../demo/stock_picking_type_demo.xml | 11 ++- .../models/__init__.py | 1 + .../models/stock_location.py | 55 +++++++++++++ .../models/stock_move.py | 79 ++++++++++--------- .../models/stock_picking_type.py | 45 +++++++++++ .../models/stock_quant.py | 2 +- .../readme/CONFIGURE.rst | 4 +- .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 10 +++ .../tests/__init__.py | 0 .../tests/test_picking_zone.py | 24 ++++-- .../views/stock_location.xml | 13 +++ .../models/stock_picking_type.py | 58 -------------- stock_picking_zone/readme/DESCRIPTION.rst | 10 --- stock_picking_zone/readme/ROADMAP.rst | 5 -- .../views/stock_picking_type_views.xml | 15 ---- 23 files changed, 195 insertions(+), 148 deletions(-) create mode 120000 setup/stock_picking_type_routing_operation/odoo/addons/stock_picking_type_routing_operation rename setup/{stock_picking_zone => stock_picking_type_routing_operation}/setup.py (100%) delete mode 120000 setup/stock_picking_zone/odoo/addons/stock_picking_zone rename {stock_picking_zone => stock_picking_type_routing_operation}/README.rst (100%) rename {stock_picking_zone => stock_picking_type_routing_operation}/__init__.py (100%) rename {stock_picking_zone => stock_picking_type_routing_operation}/__manifest__.py (92%) rename {stock_picking_zone => stock_picking_type_routing_operation}/demo/stock_location_demo.xml (77%) rename {stock_picking_zone => stock_picking_type_routing_operation}/demo/stock_picking_type_demo.xml (61%) rename {stock_picking_zone => stock_picking_type_routing_operation}/models/__init__.py (74%) create mode 100644 stock_picking_type_routing_operation/models/stock_location.py rename {stock_picking_zone => stock_picking_type_routing_operation}/models/stock_move.py (66%) create mode 100644 stock_picking_type_routing_operation/models/stock_picking_type.py rename {stock_picking_zone => stock_picking_type_routing_operation}/models/stock_quant.py (97%) rename {stock_picking_zone => stock_picking_type_routing_operation}/readme/CONFIGURE.rst (69%) rename {stock_picking_zone => stock_picking_type_routing_operation}/readme/CONTRIBUTORS.rst (69%) create mode 100644 stock_picking_type_routing_operation/readme/DESCRIPTION.rst rename {stock_picking_zone => stock_picking_type_routing_operation}/tests/__init__.py (100%) rename {stock_picking_zone => stock_picking_type_routing_operation}/tests/test_picking_zone.py (96%) create mode 100644 stock_picking_type_routing_operation/views/stock_location.xml delete mode 100644 stock_picking_zone/models/stock_picking_type.py delete mode 100644 stock_picking_zone/readme/DESCRIPTION.rst delete mode 100644 stock_picking_zone/readme/ROADMAP.rst delete mode 100644 stock_picking_zone/views/stock_picking_type_views.xml diff --git a/setup/stock_picking_type_routing_operation/odoo/addons/stock_picking_type_routing_operation b/setup/stock_picking_type_routing_operation/odoo/addons/stock_picking_type_routing_operation new file mode 120000 index 000000000000..48fab9b41631 --- /dev/null +++ b/setup/stock_picking_type_routing_operation/odoo/addons/stock_picking_type_routing_operation @@ -0,0 +1 @@ +../../../../stock_picking_type_routing_operation \ No newline at end of file diff --git a/setup/stock_picking_zone/setup.py b/setup/stock_picking_type_routing_operation/setup.py similarity index 100% rename from setup/stock_picking_zone/setup.py rename to setup/stock_picking_type_routing_operation/setup.py diff --git a/setup/stock_picking_zone/odoo/addons/stock_picking_zone b/setup/stock_picking_zone/odoo/addons/stock_picking_zone deleted file mode 120000 index a5f5df4d25bb..000000000000 --- a/setup/stock_picking_zone/odoo/addons/stock_picking_zone +++ /dev/null @@ -1 +0,0 @@ -../../../../stock_picking_zone \ No newline at end of file diff --git a/stock_picking_zone/README.rst b/stock_picking_type_routing_operation/README.rst similarity index 100% rename from stock_picking_zone/README.rst rename to stock_picking_type_routing_operation/README.rst diff --git a/stock_picking_zone/__init__.py b/stock_picking_type_routing_operation/__init__.py similarity index 100% rename from stock_picking_zone/__init__.py rename to stock_picking_type_routing_operation/__init__.py diff --git a/stock_picking_zone/__manifest__.py b/stock_picking_type_routing_operation/__manifest__.py similarity index 92% rename from stock_picking_zone/__manifest__.py rename to stock_picking_type_routing_operation/__manifest__.py index e86d9d487c88..8b4123dac55e 100644 --- a/stock_picking_zone/__manifest__.py +++ b/stock_picking_type_routing_operation/__manifest__.py @@ -15,7 +15,7 @@ 'demo/stock_picking_type_demo.xml', ], 'data': [ - 'views/stock_picking_type_views.xml', + 'views/stock_location.xml', ], 'installable': True, 'development_status': 'Alpha', diff --git a/stock_picking_zone/demo/stock_location_demo.xml b/stock_picking_type_routing_operation/demo/stock_location_demo.xml similarity index 77% rename from stock_picking_zone/demo/stock_location_demo.xml rename to stock_picking_type_routing_operation/demo/stock_location_demo.xml index d721e0a8f957..d29639c5d3f2 100644 --- a/stock_picking_zone/demo/stock_location_demo.xml +++ b/stock_picking_type_routing_operation/demo/stock_location_demo.xml @@ -3,8 +3,7 @@ Highbay - + Bay A @@ -21,8 +20,7 @@ Handover - + diff --git a/stock_picking_zone/demo/stock_picking_type_demo.xml b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml similarity index 61% rename from stock_picking_zone/demo/stock_picking_type_demo.xml rename to stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml index 5761b5c40c19..b884f438a43c 100644 --- a/stock_picking_zone/demo/stock_picking_type_demo.xml +++ b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml @@ -15,10 +15,13 @@ internal - - - - + + + + + + + diff --git a/stock_picking_zone/models/__init__.py b/stock_picking_type_routing_operation/models/__init__.py similarity index 74% rename from stock_picking_zone/models/__init__.py rename to stock_picking_type_routing_operation/models/__init__.py index b2c5936e1170..200ba08be0a0 100644 --- a/stock_picking_zone/models/__init__.py +++ b/stock_picking_type_routing_operation/models/__init__.py @@ -1,3 +1,4 @@ +from . import stock_location from . import stock_move from . import stock_picking_type from . import stock_quant diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py new file mode 100644 index 000000000000..33c8032217e8 --- /dev/null +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -0,0 +1,55 @@ +# Copyright 2019 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import api, models, fields, _ +from odoo.exceptions import ValidationError + + +class StockLocation(models.Model): + + _inherit = 'stock.location' + + routing_operation_picking_type_id = fields.Many2one( + 'stock.picking.type', + string='Routing operation', + help="Change destination of the move line according to the" + " default destination setup after reservation occurs", + ) + + @api.constrains('routing_operation_picking_type_id') + def _check_routing_operation_picking_type_id(self): + for location in self: + picking_type = location.routing_operation_picking_type_id + if picking_type.default_location_src_id != location: + raise ValidationError(_( + 'A picking type for routing operations cannot have a' + ' different default source location than the location it ' + 'is used on.' + )) + + @api.multi + def _find_picking_type_for_routing_operation(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' + ) + picking_types = self.env['stock.picking.type'].search([ + ('routing_operation_location_ids', '!=', False), + ('default_location_src_id', 'in', tree.ids) + ]) + # 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_src_id == location + ) + if match: + # we can only have one match as we have a unique + # constraint on is_zone + source location + return match + return self.env['stock.picking.type'] diff --git a/stock_picking_zone/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py similarity index 66% rename from stock_picking_zone/models/stock_move.py rename to stock_picking_type_routing_operation/models/stock_move.py index a2afed1eaf67..efd6dedf3d86 100644 --- a/stock_picking_zone/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -1,5 +1,5 @@ # 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 @@ -9,19 +9,17 @@ class StockMove(models.Model): def _action_assign(self): super()._action_assign() - if not self.env.context.get('exclude_apply_zone'): - moves = self._split_per_zone() - moves._apply_move_location_zone() + if not self.env.context.get('exclude_apply_routing_operation'): + moves = self._split_per_routing_operation() + moves._apply_move_location_routing_operation() - def _split_per_zone(self): + def _split_per_routing_operation(self): move_to_assign_ids = set() new_move_per_location = {} for move in self: if move.state not in ('assigned', 'partially_available'): continue - pick_type_model = self.env['stock.picking.type'] - # 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 @@ -31,39 +29,42 @@ def _split_per_zone(self): 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 zones where + # We'll split the move to have one move per different location where # we have to take products - zone_quantities = {} + routing_quantities = {} for source_location, qty in move_lines.items(): - zone = pick_type_model._find_zone_for_location(source_location) - zone_quantities.setdefault(zone, 0.0) - zone_quantities[zone] += qty - - if len(zone_quantities) == 1: - # The whole quantity can be taken from only one zone (a - # non-zone being equal to a zone here), nothing to split. + routing_picking_type = \ + source_location._find_picking_type_for_routing_operation() + 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_to_assign_ids.add(move.id) - zone_location = zone.default_location_src_id - for zone, qty in zone_quantities.items(): - # if zone is False-ish, we take in a location which is + for picking_type, qty in routing_quantities.items(): + # if picking type is empty, we don't need a new move # not a zone - if zone: - # split returns the same move if the qty is the same + if picking_type: + routing_location = picking_type.default_location_src_id + # if we have a picking type, split returns the same move if the qty is the same new_move_id = move._split(qty) - new_move_per_location.setdefault(zone_location.id, []) - new_move_per_location[zone_location.id].append(new_move_id) + 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 zones first + # 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_zone, will be called + # Prevent to call _apply_move_location_routing_operation, will be called # when all lines are processed. - exclude_apply_zone=True, - # Force reservation of quants in the zone they were + 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, @@ -78,33 +79,33 @@ def _split_per_zone(self): )) return self + new_moves - def _apply_move_location_zone(self): + def _apply_move_location_routing_operation(self): for move in self: if move.state not in ('assigned', 'partially_available'): continue - pick_type_model = self.env['stock.picking.type'] - # 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 zones, - # they have been split in _split_per_zone(), so we can take the - # first one + # 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 - zone = pick_type_model._find_zone_for_location(source) - if not zone: + picking_type = source._find_picking_type_for_routing_operation() + if not picking_type: continue - if (move.picking_type_id == zone and - move.location_dest_id == zone.default_location_dest_id): + if ( + move.picking_type_id == picking_type and + move.location_dest_id == picking_type.default_location_dest_id + ): # already done continue move._do_unreserve() move.write({ - 'location_dest_id': zone.default_location_dest_id.id, - 'picking_type_id': zone.id, + 'location_dest_id': picking_type.default_location_dest_id.id, + 'picking_type_id': picking_type.id, }) move._insert_middle_moves() move._assign_picking() diff --git a/stock_picking_type_routing_operation/models/stock_picking_type.py b/stock_picking_type_routing_operation/models/stock_picking_type.py new file mode 100644 index 000000000000..6a270beb8959 --- /dev/null +++ b/stock_picking_type_routing_operation/models/stock_picking_type.py @@ -0,0 +1,45 @@ +# 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' + + routing_operation_location_ids = fields.One2many( + 'stock.location', 'routing_operation_picking_type_id' + ) + + @api.constrains( + 'routing_operation_location_ids', 'default_location_src_id' + ) + def _check_routing_operation_location_src_unique(self): + for picking_type in self: + if not picking_type.routing_operation_location_ids: + continue + if len(picking_type.routing_operation_location_ids): + raise exceptions.ValidationError(_( + 'The same picking type cannot be used on different ' + 'locations having routing operations.' + )) + if ( + picking_type.routing_operation_location_ids + != picking_type.default_location_src_id + ): + raise exceptions.ValidationError(_( + 'A picking type for routing operations cannot have a' + ' different default source location than the location it ' + 'is used on.' + )) + src_location = picking_type.default_location_src_id + domain = [ + ('routing_operation_location_ids', '!=', False), + ('default_location_src_id', '=', src_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 source location.') % (other.display_name,) + ) diff --git a/stock_picking_zone/models/stock_quant.py b/stock_picking_type_routing_operation/models/stock_quant.py similarity index 97% rename from stock_picking_zone/models/stock_quant.py rename to stock_picking_type_routing_operation/models/stock_quant.py index f076476ea91a..2700a987471a 100644 --- a/stock_picking_zone/models/stock_quant.py +++ b/stock_picking_type_routing_operation/models/stock_quant.py @@ -1,5 +1,5 @@ # 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 diff --git a/stock_picking_zone/readme/CONFIGURE.rst b/stock_picking_type_routing_operation/readme/CONFIGURE.rst similarity index 69% rename from stock_picking_zone/readme/CONFIGURE.rst rename to stock_picking_type_routing_operation/readme/CONFIGURE.rst index fbb186440aa4..9e880ec0c5c6 100644 --- a/stock_picking_zone/readme/CONFIGURE.rst +++ b/stock_picking_type_routing_operation/readme/CONFIGURE.rst @@ -4,7 +4,7 @@ In Inventory Settings, you must have: * Multi-Warehouses * Multi-Step Routes -Create an operation type and activate the "Is Zone" checkbox. +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 type's source location. +is a child of the location. diff --git a/stock_picking_zone/readme/CONTRIBUTORS.rst b/stock_picking_type_routing_operation/readme/CONTRIBUTORS.rst similarity index 69% rename from stock_picking_zone/readme/CONTRIBUTORS.rst rename to stock_picking_type_routing_operation/readme/CONTRIBUTORS.rst index 63ccd231e8e3..7c15b32aa74c 100644 --- a/stock_picking_zone/readme/CONTRIBUTORS.rst +++ b/stock_picking_type_routing_operation/readme/CONTRIBUTORS.rst @@ -1,2 +1,3 @@ * Joël Grand-Guillaume * Guewen Baconnier +* Akim Juillerat diff --git a/stock_picking_type_routing_operation/readme/DESCRIPTION.rst b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst new file mode 100644 index 000000000000..99e1cf4dc2e6 --- /dev/null +++ b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst @@ -0,0 +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 +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 diff --git a/stock_picking_zone/tests/__init__.py b/stock_picking_type_routing_operation/tests/__init__.py similarity index 100% rename from stock_picking_zone/tests/__init__.py rename to stock_picking_type_routing_operation/tests/__init__.py diff --git a/stock_picking_zone/tests/test_picking_zone.py b/stock_picking_type_routing_operation/tests/test_picking_zone.py similarity index 96% rename from stock_picking_zone/tests/test_picking_zone.py rename to stock_picking_type_routing_operation/tests/test_picking_zone.py index 670e523cf540..8d5020b121f6 100644 --- a/stock_picking_zone/tests/test_picking_zone.py +++ b/stock_picking_type_routing_operation/tests/test_picking_zone.py @@ -3,7 +3,7 @@ from odoo.tests import common -class TestPickingZone(common.SavepointCase): +class TestPickingTypeRoutingOperation(common.SavepointCase): @classmethod def setUpClass(cls): @@ -56,16 +56,18 @@ def setUpClass(cls): 'padding': 5, 'company_id': cls.wh.company_id.id, }) - cls.pick_type_zone = cls.env['stock.picking.type'].create({ - 'name': 'Zone', + cls.pick_type_routing_op = cls.env['stock.picking.type'].create({ + 'name': 'Routing operation', 'code': 'internal', 'use_create_lots': False, 'use_existing_lots': True, 'default_location_src_id': cls.location_hb.id, 'default_location_dest_id': cls.location_handover.id, - 'is_zone': True, 'sequence_id': picking_type_sequence.id, }) + cls.location_hb.write({ + 'routing_operation_picking_type_id': cls.pick_type_routing_op.id + }) def _create_pick_ship(self, wh, products=None): """Create pick+ship pickings @@ -156,7 +158,7 @@ def process_operations(self, move): move.move_line_ids.qty_done = qty move.picking_id.action_done() - def test_change_location_to_zone(self): + def test_change_location_to_routing_operation(self): pick_picking, customer_picking = self._create_pick_ship( self.wh, [(self.product1, 10)] ) @@ -173,7 +175,9 @@ def test_change_location_to_zone(self): self.assert_src_highbay_1_2(ml) self.assert_dest_handover(ml) - self.assertEqual(ml.picking_id.picking_type_id, self.pick_type_zone) + 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) @@ -266,7 +270,9 @@ def test_several_moves(self): 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_zone) + self.assertEqual( + ml.picking_id.picking_type_id, self.pick_type_routing_op + ) self.assertEqual(ml.state, 'assigned') # this one stays in the PICK/ @@ -391,7 +397,9 @@ def test_several_move_lines(self): 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_zone) + 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' diff --git a/stock_picking_type_routing_operation/views/stock_location.xml b/stock_picking_type_routing_operation/views/stock_location.xml new file mode 100644 index 000000000000..9cf4c24b4a60 --- /dev/null +++ b/stock_picking_type_routing_operation/views/stock_location.xml @@ -0,0 +1,13 @@ + + + + stock.location.form.inherit + stock.location + + + + + + + + diff --git a/stock_picking_zone/models/stock_picking_type.py b/stock_picking_zone/models/stock_picking_type.py deleted file mode 100644 index 9842eac1ebe8..000000000000 --- a/stock_picking_zone/models/stock_picking_type.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2019 Camptocamp (https://www.camptocamp.com) - -from odoo import _, api, exceptions, fields, models - - -class StockPickingType(models.Model): - _inherit = 'stock.picking.type' - - is_zone = fields.Boolean( - help="Change destination of the move line according to the" - " default destination setup after reservation occurs", - ) - - @api.constrains('is_zone', 'default_location_src_id') - def _check_zone_location_src_unique(self): - for picking_type in self: - if not picking_type.is_zone: - continue - src_location = picking_type.default_location_src_id - domain = [ - ('is_zone', '=', True), - ('default_location_src_id', '=', src_location.id), - ('id', '!=', picking_type.id) - ] - other = self.search(domain) - if other: - raise exceptions.ValidationError( - _('Another zone picking type (%s) exists for' - ' the some source location.') % (other.display_name,) - ) - - @api.model - def _find_zone_for_location(self, location): - # First select all the parent locations and the matching - # zones. In a second step, the zone matching the closest location - # is searched in memory. This is to avoid doing an SQL query - # for each location in the tree. - tree = self.env['stock.location'].search( - [('id', 'parent_of', location.id)], - # the recordset will be ordered bottom location to top location - order='parent_path desc' - ) - zones = self.search([ - ('is_zone', '=', True), - ('default_location_src_id', 'in', tree.ids) - ]) - # the first location is the current move line's source location, - # then we climb up the tree of locations - for location in tree: - match = [ - zone for zone in zones - if zone.default_location_src_id == location - ] - if match: - # we can only have one match as we have a unique - # constraint on is_zone + source location - return match[0] - return self.browse() diff --git a/stock_picking_zone/readme/DESCRIPTION.rst b/stock_picking_zone/readme/DESCRIPTION.rst deleted file mode 100644 index da4e3a5e0d2b..000000000000 --- a/stock_picking_zone/readme/DESCRIPTION.rst +++ /dev/null @@ -1,10 +0,0 @@ -Route explains the steps you want to produce whereas the “picking zone” defines -how operations are grouped according to their final source and destination -location. - -This allows for example: - -* To parallelize picking operations in two main zone of a warehouse, splitting - them in two different picking type -* To define pre-picking (wave) in some sub-zones, then roundtrip picking of the - sub-zone waves diff --git a/stock_picking_zone/readme/ROADMAP.rst b/stock_picking_zone/readme/ROADMAP.rst deleted file mode 100644 index ebc15a66e633..000000000000 --- a/stock_picking_zone/readme/ROADMAP.rst +++ /dev/null @@ -1,5 +0,0 @@ -The concept of "zone" is duplicated with the zones introduced by -"stock_location_zone" in -https://github.com/OCA/stock-logistics-warehouse/pull/653 -They will be merged in the same concept. Note that considering the -alpha version of this module, no data migration will be done. diff --git a/stock_picking_zone/views/stock_picking_type_views.xml b/stock_picking_zone/views/stock_picking_type_views.xml deleted file mode 100644 index cd57aa8a2d7c..000000000000 --- a/stock_picking_zone/views/stock_picking_type_views.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - Operation Types - stock.picking.type - - - - - - - - - From 855c47fdd2df577b3927a1e8c2730d30ce2ae756 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 13 Sep 2019 10:58:20 +0200 Subject: [PATCH 10/21] Fix constraint condition --- .../models/stock_picking_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_picking_type_routing_operation/models/stock_picking_type.py b/stock_picking_type_routing_operation/models/stock_picking_type.py index 6a270beb8959..2a93dd33968b 100644 --- a/stock_picking_type_routing_operation/models/stock_picking_type.py +++ b/stock_picking_type_routing_operation/models/stock_picking_type.py @@ -17,7 +17,7 @@ def _check_routing_operation_location_src_unique(self): for picking_type in self: if not picking_type.routing_operation_location_ids: continue - if len(picking_type.routing_operation_location_ids): + if len(picking_type.routing_operation_location_ids) > 1: raise exceptions.ValidationError(_( 'The same picking type cannot be used on different ' 'locations having routing operations.' From fb47ff5cf8fb3ee1e91756b8e065952fb8c9d557 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Sep 2019 15:55:16 +0200 Subject: [PATCH 11/21] Fix location constraint --- stock_picking_type_routing_operation/models/stock_location.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py index 33c8032217e8..ecd406b44915 100644 --- a/stock_picking_type_routing_operation/models/stock_location.py +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -19,6 +19,8 @@ class StockLocation(models.Model): def _check_routing_operation_picking_type_id(self): for location in self: picking_type = location.routing_operation_picking_type_id + if not picking_type: + continue if picking_type.default_location_src_id != location: raise ValidationError(_( 'A picking type for routing operations cannot have a' From d1bf445b93fe93ee74bfd3755a62e54e23ff239f Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 20 Sep 2019 15:55:22 +0200 Subject: [PATCH 12/21] Fix lint (long lines) --- .../models/stock_location.py | 3 ++- .../models/stock_move.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py index ecd406b44915..02185296071d 100644 --- a/stock_picking_type_routing_operation/models/stock_location.py +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -32,7 +32,8 @@ def _check_routing_operation_picking_type_id(self): def _find_picking_type_for_routing_operation(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 + # 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( diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index efd6dedf3d86..2b100c06623d 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -40,7 +40,8 @@ def _split_per_routing_operation(self): 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. + # empty routing picking type being equal to one location here), + # nothing to split. continue move._do_unreserve() @@ -50,7 +51,8 @@ def _split_per_routing_operation(self): # not a zone if picking_type: routing_location = picking_type.default_location_src_id - # if we have a picking type, split returns the same move if the qty is the same + # if we have a picking type, split returns the same move if + # the qty is the same new_move_id = move._split(qty) new_move_per_location.setdefault(routing_location.id, []) new_move_per_location[routing_location.id].append( @@ -61,8 +63,8 @@ def _split_per_routing_operation(self): 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. + # 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 @@ -88,9 +90,9 @@ def _apply_move_location_routing_operation(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. - # 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 + # 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 picking_type = source._find_picking_type_for_routing_operation() if not picking_type: From e3cf81bd8bbf1075bce11bab6d8c914b5a569b44 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 30 Oct 2019 15:01:48 +0100 Subject: [PATCH 13/21] Prepare addition of routing operation for destinations Rename the existing field and methods to differentiate source and destination routing operations. --- .../demo/stock_picking_type_demo.xml | 2 +- .../models/stock_location.py | 14 ++++++------- .../models/stock_move.py | 4 ++-- .../models/stock_picking_type.py | 16 +++++++-------- .../readme/DESCRIPTION.rst | 20 +++++++++++++++++++ .../tests/__init__.py | 2 +- ..._zone.py => test_routing_operation_src.py} | 4 ++-- .../views/stock_location.xml | 2 +- 8 files changed, 42 insertions(+), 22 deletions(-) rename stock_picking_type_routing_operation/tests/{test_picking_zone.py => test_routing_operation_src.py} (99%) diff --git a/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml index b884f438a43c..a3750b18c421 100644 --- a/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml +++ b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml @@ -21,7 +21,7 @@ - + diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py index 02185296071d..7f51a642e111 100644 --- a/stock_picking_type_routing_operation/models/stock_location.py +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -8,17 +8,17 @@ class StockLocation(models.Model): _inherit = 'stock.location' - routing_operation_picking_type_id = fields.Many2one( + src_routing_picking_type_id = fields.Many2one( 'stock.picking.type', - string='Routing operation', + string='Routing Operation', help="Change destination of the move line according to the" " default destination setup after reservation occurs", ) - @api.constrains('routing_operation_picking_type_id') - def _check_routing_operation_picking_type_id(self): + @api.constrains('src_routing_picking_type_id') + def _check_src_routing_picking_type_id(self): for location in self: - picking_type = location.routing_operation_picking_type_id + picking_type = location.src_routing_picking_type_id if not picking_type: continue if picking_type.default_location_src_id != location: @@ -29,7 +29,7 @@ def _check_routing_operation_picking_type_id(self): )) @api.multi - def _find_picking_type_for_routing_operation(self): + def _find_picking_type_for_src_routing(self): self.ensure_one() # First select all the parent locations and the matching # picking types. In a second step, the picking type matching the @@ -42,7 +42,7 @@ def _find_picking_type_for_routing_operation(self): order='parent_path desc' ) picking_types = self.env['stock.picking.type'].search([ - ('routing_operation_location_ids', '!=', False), + ('src_routing_location_ids', '!=', False), ('default_location_src_id', 'in', tree.ids) ]) # the first location is the current move line's source location, diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index 2b100c06623d..c1d0b21482c3 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -34,7 +34,7 @@ def _split_per_routing_operation(self): routing_quantities = {} for source_location, qty in move_lines.items(): routing_picking_type = \ - source_location._find_picking_type_for_routing_operation() + source_location._find_picking_type_for_src_routing() routing_quantities.setdefault(routing_picking_type, 0.0) routing_quantities[routing_picking_type] += qty @@ -94,7 +94,7 @@ def _apply_move_location_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 - picking_type = source._find_picking_type_for_routing_operation() + picking_type = source._find_picking_type_for_src_routing() if not picking_type: continue if ( diff --git a/stock_picking_type_routing_operation/models/stock_picking_type.py b/stock_picking_type_routing_operation/models/stock_picking_type.py index 2a93dd33968b..e88d8398bf18 100644 --- a/stock_picking_type_routing_operation/models/stock_picking_type.py +++ b/stock_picking_type_routing_operation/models/stock_picking_type.py @@ -6,24 +6,24 @@ class StockPickingType(models.Model): _inherit = 'stock.picking.type' - routing_operation_location_ids = fields.One2many( - 'stock.location', 'routing_operation_picking_type_id' + src_routing_location_ids = fields.One2many( + 'stock.location', 'src_routing_picking_type_id' ) @api.constrains( - 'routing_operation_location_ids', 'default_location_src_id' + 'src_routing_location_ids', 'default_location_src_id' ) - def _check_routing_operation_location_src_unique(self): + def _check_src_routing_location_unique(self): for picking_type in self: - if not picking_type.routing_operation_location_ids: + if not picking_type.src_routing_location_ids: continue - if len(picking_type.routing_operation_location_ids) > 1: + if len(picking_type.src_routing_location_ids) > 1: raise exceptions.ValidationError(_( 'The same picking type cannot be used on different ' 'locations having routing operations.' )) if ( - picking_type.routing_operation_location_ids + picking_type.src_routing_location_ids != picking_type.default_location_src_id ): raise exceptions.ValidationError(_( @@ -33,7 +33,7 @@ def _check_routing_operation_location_src_unique(self): )) src_location = picking_type.default_location_src_id domain = [ - ('routing_operation_location_ids', '!=', False), + ('src_routing_location_ids', '!=', False), ('default_location_src_id', '=', src_location.id), ('id', '!=', picking_type.id) ] diff --git a/stock_picking_type_routing_operation/readme/DESCRIPTION.rst b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst index 99e1cf4dc2e6..0c6e683d30c2 100644 --- a/stock_picking_type_routing_operation/readme/DESCRIPTION.rst +++ b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst @@ -8,3 +8,23 @@ This allows for example: 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. + diff --git a/stock_picking_type_routing_operation/tests/__init__.py b/stock_picking_type_routing_operation/tests/__init__.py index 5d0c7ce96977..33ee1528ac5d 100644 --- a/stock_picking_type_routing_operation/tests/__init__.py +++ b/stock_picking_type_routing_operation/tests/__init__.py @@ -1 +1 @@ -from . import test_picking_zone +from . import test_routing_operation_src diff --git a/stock_picking_type_routing_operation/tests/test_picking_zone.py b/stock_picking_type_routing_operation/tests/test_routing_operation_src.py similarity index 99% rename from stock_picking_type_routing_operation/tests/test_picking_zone.py rename to stock_picking_type_routing_operation/tests/test_routing_operation_src.py index 8d5020b121f6..54c116f5f401 100644 --- a/stock_picking_type_routing_operation/tests/test_picking_zone.py +++ b/stock_picking_type_routing_operation/tests/test_routing_operation_src.py @@ -3,7 +3,7 @@ from odoo.tests import common -class TestPickingTypeRoutingOperation(common.SavepointCase): +class TestSourceRoutingOperation(common.SavepointCase): @classmethod def setUpClass(cls): @@ -66,7 +66,7 @@ def setUpClass(cls): 'sequence_id': picking_type_sequence.id, }) cls.location_hb.write({ - 'routing_operation_picking_type_id': cls.pick_type_routing_op.id + 'src_routing_picking_type_id': cls.pick_type_routing_op.id }) def _create_pick_ship(self, wh, products=None): diff --git a/stock_picking_type_routing_operation/views/stock_location.xml b/stock_picking_type_routing_operation/views/stock_location.xml index 9cf4c24b4a60..345022125241 100644 --- a/stock_picking_type_routing_operation/views/stock_location.xml +++ b/stock_picking_type_routing_operation/views/stock_location.xml @@ -6,7 +6,7 @@ - + From b0b3facd674a384ec5fd0f3f53d3e3393035a918 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 31 Oct 2019 14:47:02 +0100 Subject: [PATCH 14/21] Add destination routing operation --- .../models/stock_location.py | 59 ++++- .../models/stock_move.py | 189 ++++++++++++++- .../models/stock_picking_type.py | 53 +++-- .../readme/DESCRIPTION.rst | 8 + .../tests/__init__.py | 1 + .../tests/test_routing_operation_dest.py | 225 ++++++++++++++++++ 6 files changed, 499 insertions(+), 36 deletions(-) create mode 100644 stock_picking_type_routing_operation/tests/test_routing_operation_dest.py diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py index 7f51a642e111..7f98b525221e 100644 --- a/stock_picking_type_routing_operation/models/stock_location.py +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -5,14 +5,23 @@ class StockLocation(models.Model): - _inherit = 'stock.location' src_routing_picking_type_id = fields.Many2one( 'stock.picking.type', - string='Routing Operation', + string='Source Routing Operation', help="Change destination of the move line according to the" - " default destination setup after reservation occurs", + " default destination of the picking type after reservation" + " occurs, when the source of the move is in this location" + " (including sub-locations).", + ) + 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).", ) @api.constrains('src_routing_picking_type_id') @@ -23,13 +32,30 @@ def _check_src_routing_picking_type_id(self): continue if picking_type.default_location_src_id != location: raise ValidationError(_( - 'A picking type for routing operations cannot have a' - ' different default source location than the location it ' - 'is used on.' + '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.' )) @api.multi - def _find_picking_type_for_src_routing(self): + 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 @@ -41,18 +67,25 @@ def _find_picking_type_for_src_routing(self): # the recordset will be ordered bottom location to top location order='parent_path desc' ) - picking_types = self.env['stock.picking.type'].search([ - ('src_routing_location_ids', '!=', False), - ('default_location_src_id', 'in', tree.ids) - ]) + 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_src_id == location + 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 location + # constraint on is_zone + source (or dest) location return match return self.env['stock.picking.type'] diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index c1d0b21482c3..8d4ebc432a54 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -10,10 +10,12 @@ class StockMove(models.Model): def _action_assign(self): super()._action_assign() if not self.env.context.get('exclude_apply_routing_operation'): - moves = self._split_per_routing_operation() - moves._apply_move_location_routing_operation() + src_moves = self._split_per_routing_operation() + src_moves._apply_move_location_routing_operation() + dest_moves = self._split_per_dest_routing_operation() + dest_moves._apply_move_location_dest_routing_operation() - def _split_per_routing_operation(self): + def _split_per_dest_routing_operation(self): move_to_assign_ids = set() new_move_per_location = {} for move in self: @@ -26,15 +28,15 @@ def _split_per_routing_operation(self): # if needed. move_lines = {} for move_line in move.move_line_ids: - location = move_line.location_id + location = move_line.location_dest_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 + # We'll split the move to have one move per different location + # where we have to take products routing_quantities = {} - for source_location, qty in move_lines.items(): + for dest_location, qty in move_lines.items(): routing_picking_type = \ - source_location._find_picking_type_for_src_routing() + dest_location._find_picking_type_for_routing("dest") routing_quantities.setdefault(routing_picking_type, 0.0) routing_quantities[routing_picking_type] += qty @@ -81,7 +83,140 @@ def _split_per_routing_operation(self): )) return self + new_moves + def _split_per_routing_operation(self): + """Split moves per routing operations + + When a move has move lines with different routing operations or lines + with routing operations and lines without, this method split the move + in as many 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 intermediary 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_location, qty in move_lines.items(): + routing_picking_type = \ + source_location._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_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_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 ( + move.location_id == picking_type.default_location_src_id and + # a predecessor move is a "routing operation" + move.move_orig_ids.filtered( + lambda o: o.picking_type_id == picking_type + ) + ): + # already done + continue + + move._do_unreserve() + move.write({ + 'location_id': picking_type.default_location_src_id.id, + 'state': 'waiting', + # 'picking_type_id': picking_type.id, + }) + + move._insert_dest_middle_moves(picking_type) + def _apply_move_location_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 @@ -94,7 +229,7 @@ def _apply_move_location_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 - picking_type = source._find_picking_type_for_src_routing() + picking_type = source._find_picking_type_for_routing("src") if not picking_type: continue if ( @@ -114,6 +249,7 @@ def _apply_move_location_routing_operation(self): move._action_assign() def _insert_middle_moves(self): + """Create a chained move for the routing operation""" self.ensure_one() dest_moves = self.move_dest_ids dest_location = self.location_dest_id @@ -150,3 +286,38 @@ def _prepare_middle_move_values(self, destination): 'state': 'waiting', 'picking_type_id': self.picking_id.picking_type_id.id, } + + def _insert_dest_middle_moves(self, picking_type): + """Create a chained move for the routing operation""" + self.ensure_one() + source_moves = self.move_orig_ids + source_location = self.location_id + for source_move in source_moves: + previous_location = source_move.location_dest_id + if source_location == previous_location: + continue + # Insert a move between the previous move and the source of our + # move as their locations do not match. + middle_move_values = self._prepare_dest_middle_move_values( + picking_type, + previous_location + ) + middle_move = self.copy(middle_move_values) + source_move.write({ + 'move_dest_ids': [(3, self.id), (4, middle_move.id)], + }) + self.write({ + 'move_orig_ids': [(3, source_move.id), (4, middle_move.id)], + }) + middle_move._action_confirm(merge=False) + middle_move._assign_picking() + middle_move._action_assign() + + def _prepare_dest_middle_move_values(self, picking_type, source): + return { + 'picking_id': False, + 'location_id': source.id, + 'location_dest_id': self.location_id.id, + 'state': 'waiting', + 'picking_type_id': picking_type.id, + } diff --git a/stock_picking_type_routing_operation/models/stock_picking_type.py b/stock_picking_type_routing_operation/models/stock_picking_type.py index e88d8398bf18..be7b7692eaf8 100644 --- a/stock_picking_type_routing_operation/models/stock_picking_type.py +++ b/stock_picking_type_routing_operation/models/stock_picking_type.py @@ -9,37 +9,62 @@ class StockPickingType(models.Model): src_routing_location_ids = fields.One2many( 'stock.location', 'src_routing_picking_type_id' ) - - @api.constrains( - 'src_routing_location_ids', 'default_location_src_id' + dest_routing_location_ids = fields.One2many( + 'stock.location', 'dest_routing_picking_type_id' ) - def _check_src_routing_location_unique(self): + + 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.src_routing_location_ids: + if not picking_type[routing_location_fieldname]: continue - if len(picking_type.src_routing_location_ids) > 1: + 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.src_routing_location_ids - != picking_type.default_location_src_id + picking_type[routing_location_fieldname] + != picking_type[default_location_fieldname] ): raise exceptions.ValidationError(_( 'A picking type for routing operations cannot have a' - ' different default source location than the location it ' + ' different default %s location than the location it ' 'is used on.' - )) - src_location = picking_type.default_location_src_id + ) % (message_fragment,)) + default_location = picking_type[default_location_fieldname] domain = [ - ('src_routing_location_ids', '!=', False), - ('default_location_src_id', '=', src_location.id), + (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 source location.') % (other.display_name,) + ' 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_picking_type_routing_operation/readme/DESCRIPTION.rst b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst index 0c6e683d30c2..cdb9b16fdda2 100644 --- a/stock_picking_type_routing_operation/readme/DESCRIPTION.rst +++ b/stock_picking_type_routing_operation/readme/DESCRIPTION.rst @@ -28,3 +28,11 @@ 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_picking_type_routing_operation/tests/__init__.py b/stock_picking_type_routing_operation/tests/__init__.py index 33ee1528ac5d..2a582037d5bb 100644 --- a/stock_picking_type_routing_operation/tests/__init__.py +++ b/stock_picking_type_routing_operation/tests/__init__.py @@ -1 +1,2 @@ from . import test_routing_operation_src +from . import test_routing_operation_dest diff --git a/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py b/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py new file mode 100644 index 000000000000..4e828a3f7a9f --- /dev/null +++ b/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py @@ -0,0 +1,225 @@ +# 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.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', + }) + + picking_type_sequence = cls.env['ir.sequence'].create({ + 'name': 'WH/Handover', + 'prefix': 'WH/HO/', + 'padding': 5, + 'company_id': cls.wh.company_id.id, + }) + cls.pick_type_routing_op = cls.env['stock.picking.type'].create({ + 'name': 'Routing operation', + 'code': 'internal', + 'use_create_lots': False, + 'use_existing_lots': True, + 'default_location_src_id': cls.location_handover.id, + 'default_location_dest_id': cls.location_hb.id, + 'sequence_id': picking_type_sequence.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). + 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': self.location_hb_1_2.id, + 'partner_id': self.partner_delta.id, + 'picking_type_id': wh.int_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': internal_picking.id, + 'location_id': wh.wh_input_stock_loc_id.id, + 'location_dest_id': self.location_hb_1_2.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_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, 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): + in_picking, internal_picking = self._create_supplier_input_highbay( + self.wh, [(self.product1, 10)] + ) + move_a = in_picking.move_lines + move_b = internal_picking.move_lines + self.assertEqual(move_a.state, 'assigned') + self.process_operations(move_a) + + # ml = move_b.move_line_ids + # self.assertEqual(len(ml), 1) + # self.assert_src_input(ml) + # self.assert_dest_handover(ml) + + # self.assertEqual( + # ml.picking_id.picking_type_id, self.pick_type_routing_op + # ) + + self.assert_src_supplier(move_a) + self.assert_dest_input(move_a) + self.assert_src_handover(move_b) + # the move stays B stays on the same dest location + self.assert_dest_highbay_1_2(move_b) + + # we should have a move between move_a and move_b to make + # the bridge from input to handover + move_middle = move_a.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_input(move_middle) + self.assert_dest_handover(move_middle) + + self.assertEquals( + move_middle.picking_type_id, + self.pick_type_routing_op + ) + self.assertEquals( + move_middle.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_middle.state, 'assigned') + self.assertEqual(move_b.state, 'waiting') + + # we deliver middle to check that our last move line properly takes + # goods from the handover + self.process_operations(move_middle) + + self.assertEqual(move_a.state, 'done') + self.assertEqual(move_middle.state, 'done') + self.assertEqual(move_b.state, 'assigned') + + move_line_b = move_b.move_line_ids + self.assertEqual(len(move_line_b), 1) + self.assert_src_handover(move_line_b) + self.assert_dest_highbay_1_2(move_line_b) From 40a8c7db1ea363288c204d49c34a112e14d3e381 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 1 Nov 2019 10:45:06 +0100 Subject: [PATCH 15/21] Create the new move *after* the original one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason is that we have to keep moves that come from the same source in the same picking. If we have 2 products, one with a routing, the other without, we expect: +-----------------------------------------------+ | IN/xxxx | | Product1 Supplier → Input | | Product2 Supplier → Input | +-----------------------------------------------+ +-----------------------------------------------+ | INT/xxxx | | Product1 Input → Stock/Handover | | Product2 Input → Shelf1 | +-----------------------------------------------+ the new one with our routing picking type: +-----------------------------------------------+ | HO/xxxx | | Product1 Stock/Highbay/Handover → Highbay1-2 | +-----------------------------------------------+ And not: +-----------------------------------------------+ | IN/xxxx | | Product1 Supplier → Input | | Product2 Supplier → Input | +-----------------------------------------------+ +-----------------------------------------------+ | HO/xxxx | | Product1 Input → Stock/Handover | +-----------------------------------------------+ the new one with our routing picking type: +-----------------------------------------------+ | INT/xxxx | | Product1 Stock/Highbay/Handover → Highbay1-2 | | Product2 Input → Shelf1 | +-----------------------------------------------+ --- .../demo/stock_picking_type_demo.xml | 36 +- .../models/stock_move.py | 318 ++++++------- .../readme/USAGE.rst | 47 ++ .../tests/test_routing_operation_dest.py | 428 ++++++++++++++++-- .../views/stock_location.xml | 1 + 5 files changed, 629 insertions(+), 201 deletions(-) create mode 100644 stock_picking_type_routing_operation/readme/USAGE.rst diff --git a/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml index a3750b18c421..1153b7eaef61 100644 --- a/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml +++ b/stock_picking_type_routing_operation/demo/stock_picking_type_demo.xml @@ -1,27 +1,47 @@ - - Highbay Handover - stock.ho - HO/ + + Highbay → Handover + stock.hb.ho + HBHO/ 5 1 1 - - Highbay Handover + + Highbay → Handover internal - + + + + + Handover → Highbay + stock.ho.hb + HOHB/ + 5 + 1 + 1 + + + + Handover → Highbay + internal + + + + + - + + diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index 8d4ebc432a54..d5b4201b0eac 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -10,88 +10,27 @@ class StockMove(models.Model): def _action_assign(self): super()._action_assign() if not self.env.context.get('exclude_apply_routing_operation'): - src_moves = self._split_per_routing_operation() - src_moves._apply_move_location_routing_operation() - dest_moves = self._split_per_dest_routing_operation() - dest_moves._apply_move_location_dest_routing_operation() + self._apply_src_move_routing_operation() + self._apply_dest_move_routing_operation() - def _split_per_dest_routing_operation(self): - 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_dest_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 dest_location, qty in move_lines.items(): - routing_picking_type = \ - dest_location._find_picking_type_for_routing("dest") - routing_quantities.setdefault(routing_picking_type, 0.0) - routing_quantities[routing_picking_type] += qty + def _apply_src_move_routing_operation(self): + src_moves = self._split_per_src_routing_operation() + src_moves._apply_move_location_src_routing_operation() - 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 + def _apply_dest_move_routing_operation(self): + dest_moves = self._split_per_dest_routing_operation() + dest_moves._apply_move_location_dest_routing_operation() - move._do_unreserve() - move_to_assign_ids.add(move.id) - for picking_type, qty in routing_quantities.items(): - # if picking type is empty, we don't need a new move - # not a zone - if picking_type: - routing_location = picking_type.default_location_src_id - # if we have a picking type, split returns the same move if - # the qty is the same - 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 _split_per_routing_operation(self): - """Split moves per routing operations + 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, this method split the move - in as many 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 intermediary move (for - the routing operation). + 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 = {} @@ -165,50 +104,7 @@ def _split_per_routing_operation(self): )) 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 ( - move.location_id == picking_type.default_location_src_id and - # a predecessor move is a "routing operation" - move.move_orig_ids.filtered( - lambda o: o.picking_type_id == picking_type - ) - ): - # already done - continue - - move._do_unreserve() - move.write({ - 'location_id': picking_type.default_location_src_id.id, - 'state': 'waiting', - # 'picking_type_id': picking_type.id, - }) - - move._insert_dest_middle_moves(picking_type) - - def _apply_move_location_routing_operation(self): + def _apply_move_location_src_routing_operation(self): """Apply routing operations When a move has a routing operation configured on its location and the @@ -239,17 +135,20 @@ def _apply_move_location_routing_operation(self): # already done 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 move._do_unreserve() move.write({ 'location_dest_id': picking_type.default_location_dest_id.id, 'picking_type_id': picking_type.id, }) - move._insert_middle_moves() + move._insert_src_routing_moves() move._assign_picking() move._action_assign() - def _insert_middle_moves(self): - """Create a chained move for the routing operation""" + def _insert_src_routing_moves(self): + """Create a chained move for the source routing operation""" self.ensure_one() dest_moves = self.move_dest_ids dest_location = self.location_dest_id @@ -266,19 +165,21 @@ def _insert_middle_moves(self): continue # Insert move between the source and destination for the new # operation - middle_move_values = self._prepare_middle_move_values( + move_values = self._prepare_src_routing_move_values( final_location ) - middle_move = self.copy(middle_move_values) + move = self.copy(move_values) + + # modify the chain to include the new move dest_move.write({ - 'move_orig_ids': [(3, self.id), (4, middle_move.id)], + 'move_orig_ids': [(3, self.id), (4, move.id)], }) self.write({ - 'move_dest_ids': [(3, dest_move.id), (4, middle_move.id)], + 'move_dest_ids': [(3, dest_move.id), (4, move.id)], }) - middle_move._action_confirm(merge=False) + move._action_confirm(merge=False) - def _prepare_middle_move_values(self, destination): + def _prepare_src_routing_move_values(self, destination): return { 'picking_id': False, 'location_id': self.location_id.id, @@ -287,37 +188,144 @@ def _prepare_middle_move_values(self, destination): 'picking_type_id': self.picking_id.picking_type_id.id, } - def _insert_dest_middle_moves(self, picking_type): - """Create a chained move for the routing operation""" - self.ensure_one() - source_moves = self.move_orig_ids - source_location = self.location_id - for source_move in source_moves: - previous_location = source_move.location_dest_id - if source_location == previous_location: + 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 - # Insert a move between the previous move and the source of our - # move as their locations do not match. - middle_move_values = self._prepare_dest_middle_move_values( - picking_type, - previous_location - ) - middle_move = self.copy(middle_move_values) - source_move.write({ - 'move_dest_ids': [(3, self.id), (4, middle_move.id)], + + # 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: + location = move_line.location_dest_id + if location in routing_operations: + routing_picking_type = routing_operations[location] + else: + routing_picking_type = \ + location._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 move.picking_type_id == picking_type: + # This move has been created for the routing operation, + # 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, }) - self.write({ - 'move_orig_ids': [(3, source_move.id), (4, middle_move.id)], + move.move_line_ids.write({ + 'location_dest_id': picking_type.default_location_src_id.id, + }) + move._insert_dest_routing_move(picking_type, destination) + + def _insert_dest_routing_move(self, picking_type, original_destination): + """Create a chained move for the destination routing operation + + It adds a new move after the current move, and link the existing + destination moves to the new move. + """ + self.ensure_one() + routing_move_values = self._prepare_dest_routing_move_values( + picking_type, + original_destination + ) + routing_move = self.copy(routing_move_values) + dest_moves = self.move_dest_ids + # 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)], }) - middle_move._action_confirm(merge=False) - middle_move._assign_picking() - middle_move._action_assign() + routing_move._action_confirm(merge=False) + routing_move._assign_picking() - def _prepare_dest_middle_move_values(self, picking_type, source): + def _prepare_dest_routing_move_values( + self, picking_type, + original_destination + ): return { 'picking_id': False, - 'location_id': source.id, - 'location_dest_id': self.location_id.id, + 'location_id': self.location_dest_id.id, + 'location_dest_id': original_destination.id, 'state': 'waiting', 'picking_type_id': picking_type.id, } diff --git a/stock_picking_type_routing_operation/readme/USAGE.rst b/stock_picking_type_routing_operation/readme/USAGE.rst new file mode 100644 index 000000000000..9cdb262a5c49 --- /dev/null +++ b/stock_picking_type_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_picking_type_routing_operation/tests/test_routing_operation_dest.py b/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py index 4e828a3f7a9f..ef6f83c0e278 100644 --- a/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py +++ b/stock_picking_type_routing_operation/tests/test_routing_operation_dest.py @@ -40,7 +40,7 @@ def setUpClass(cls): cls.location_handover = cls.env['stock.location'].create({ 'name': 'Handover', - 'location_id': cls.wh.lot_stock_id.id, + 'location_id': cls.location_hb.id, }) cls.product1 = cls.env['product.product'].create({ @@ -72,7 +72,7 @@ def setUpClass(cls): def _create_supplier_input_highbay(self, wh, products=None): """Create pickings supplier->input, input-> highbay - Products must be a list of tuples (product, quantity). + Products must be a list of tuples (product, quantity, put_location). One stock move will be create for each tuple. """ if products is None: @@ -86,12 +86,12 @@ def _create_supplier_input_highbay(self, wh, products=None): internal_picking = self.env['stock.picking'].create({ 'location_id': wh.wh_input_stock_loc_id.id, - 'location_dest_id': self.location_hb_1_2.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 in products: + for product, qty, put_location in products: dest = self.env['stock.move'].create({ 'name': product.name, 'product_id': product.id, @@ -99,7 +99,7 @@ def _create_supplier_input_highbay(self, wh, products=None): 'product_uom': product.uom_id.id, 'picking_id': internal_picking.id, 'location_id': wh.wh_input_stock_loc_id.id, - 'location_dest_id': self.location_hb_1_2.id, + 'location_dest_id': put_location.id, 'state': 'waiting', 'procure_method': 'make_to_order', }) @@ -138,6 +138,9 @@ def assert_src_handover(self, record): 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) @@ -153,50 +156,63 @@ def assert_dest_highbay_1_2(self, record): def assert_src_supplier(self, record): self.assertEqual(record.location_id, self.supplier_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 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.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) - # ml = move_b.move_line_ids - # self.assertEqual(len(ml), 1) - # self.assert_src_input(ml) - # self.assert_dest_handover(ml) - - # self.assertEqual( - # ml.picking_id.picking_type_id, self.pick_type_routing_op - # ) + # 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_handover(move_b) + self.assert_src_input(move_b) # the move stays B stays on the same dest location - self.assert_dest_highbay_1_2(move_b) + self.assert_dest_handover(move_b) - # we should have a move between move_a and move_b to make - # the bridge from input to handover - move_middle = move_a.move_dest_ids + # 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_input(move_middle) - self.assert_dest_handover(move_middle) + self.assert_src_handover(routing_move) + self.assert_dest_highbay_1_2(routing_move) self.assertEquals( - move_middle.picking_type_id, + routing_move.picking_type_id, self.pick_type_routing_op ) self.assertEquals( - move_middle.picking_id.picking_type_id, + routing_move.picking_id.picking_type_id, self.pick_type_routing_op ) self.assertEquals( @@ -208,18 +224,354 @@ def test_change_location_to_routing_operation(self): self.wh.int_type_id ) self.assertEqual(move_a.state, 'done') - self.assertEqual(move_middle.state, 'assigned') - self.assertEqual(move_b.state, 'waiting') + self.assertEqual(move_b.state, 'assigned') + self.assertEqual(routing_move.state, 'waiting') - # we deliver middle to check that our last move line properly takes - # goods from the handover - self.process_operations(move_middle) + # 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_middle.state, 'done') - self.assertEqual(move_b.state, 'assigned') + self.assertEqual(move_b.state, 'done') + self.assertEqual(routing_move.state, 'assigned') - move_line_b = move_b.move_line_ids - self.assertEqual(len(move_line_b), 1) - self.assert_src_handover(move_line_b) - self.assert_dest_highbay_1_2(move_line_b) + 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 thex + # 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({ + 'location_dest_id': self.location_shelf_1.id, + 'product_uom_qty': 4.0, + }) + # 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_picking_type_routing_operation/views/stock_location.xml b/stock_picking_type_routing_operation/views/stock_location.xml index 345022125241..c7d0629f9260 100644 --- a/stock_picking_type_routing_operation/views/stock_location.xml +++ b/stock_picking_type_routing_operation/views/stock_location.xml @@ -7,6 +7,7 @@ + From b97c7e460e6f1138f569127b07f3dbc34ac442e6 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 1 Nov 2019 16:45:44 +0100 Subject: [PATCH 16/21] Destroy package level created by assign From @jbaudoux comment on review: "action_assign can create package level, so you need to destroy it when you unreserve" Co-Authored-By: Jacques-Etienne Baudoux --- stock_picking_type_routing_operation/models/stock_move.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index d5b4201b0eac..8afddd8e23c5 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -63,6 +63,7 @@ def _split_per_src_routing_operation(self): 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 From 07a512f3aba6fcd61a542ad3104543874c858f29 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 1 Nov 2019 16:51:12 +0100 Subject: [PATCH 17/21] Change summary --- stock_picking_type_routing_operation/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stock_picking_type_routing_operation/__manifest__.py b/stock_picking_type_routing_operation/__manifest__.py index 8b4123dac55e..948dde37277f 100644 --- a/stock_picking_type_routing_operation/__manifest__.py +++ b/stock_picking_type_routing_operation/__manifest__.py @@ -1,7 +1,7 @@ # Copyright 2019 Camptocamp (https://www.camptocamp.com) { 'name': "Stock Picking Zone", - 'summary': """Warehouse Operations By Zones""", + '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', From cc74339a31f6a2ea36d4941c1ea529363cdbd0c2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 19 Nov 2019 15:11:15 +0100 Subject: [PATCH 18/21] Unlink empty picking when last move changes type --- stock_picking_type_routing_operation/models/stock_move.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index 8afddd8e23c5..706292ce354d 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -145,7 +145,15 @@ def _apply_move_location_src_routing_operation(self): 'picking_type_id': picking_type.id, }) move._insert_src_routing_moves() + + 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_src_routing_moves(self): From 6f7d88cc3ae3c8765bff4fdc95013272009c7aec Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Nov 2019 14:02:10 +0100 Subject: [PATCH 19/21] Factorize src/dest methods and improvements Skip creation of a routing move when the destination is already the destination of the routing picking type --- .../models/stock_move.py | 210 ++++++++---------- .../tests/test_routing_operation_src.py | 2 +- 2 files changed, 93 insertions(+), 119 deletions(-) diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index 706292ce354d..11d1b79edcd8 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -5,11 +5,11 @@ class StockMove(models.Model): - _inherit = 'stock.move' + _inherit = "stock.move" def _action_assign(self): super()._action_assign() - if not self.env.context.get('exclude_apply_routing_operation'): + if not self.env.context.get("exclude_apply_routing_operation"): self._apply_src_move_routing_operation() self._apply_dest_move_routing_operation() @@ -35,7 +35,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'): + if move.state not in ("assigned", "partially_available"): continue # Group move lines per source location, some may need an additional @@ -45,14 +45,15 @@ def _split_per_src_routing_operation(self): 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')) + 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_location, qty in move_lines.items(): - routing_picking_type = \ - source_location._find_picking_type_for_routing("src") + 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 @@ -100,9 +101,9 @@ def _split_per_src_routing_operation(self): 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() - )) + new_moves = self.browse( + chain.from_iterable(new_move_per_location.values()) + ) return self + new_moves def _apply_move_location_src_routing_operation(self): @@ -115,7 +116,7 @@ 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"): continue # Group move lines per source location, some may need an additional @@ -126,25 +127,37 @@ 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 - picking_type = source._find_picking_type_for_routing("src") - if not picking_type: + 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 ( - move.picking_type_id == picking_type and - move.location_dest_id == picking_type.default_location_dest_id + + if self.env["stock.location"].search( + [ + ("id", "=", routing.default_location_dest_id.id), + ("id", "parent_of", move.location_dest_id.id), + ] ): - # already done + # 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 + # them, we have to unreserve and assign at the end to have the move + # lines go to the correct destination move._do_unreserve() - move.write({ - 'location_dest_id': picking_type.default_location_dest_id.id, - 'picking_type_id': picking_type.id, - }) - move._insert_src_routing_moves() + 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() @@ -156,45 +169,38 @@ def _apply_move_location_src_routing_operation(self): move._action_assign() - def _insert_src_routing_moves(self): + 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 - dest_location = self.location_dest_id - for dest_move in dest_moves: - final_location = dest_move.location_id - if dest_location == final_location: - # shortcircuit to avoid a query checking if it is a child - continue - child_locations = self.env['stock.location'].search([ - ('id', 'child_of', final_location.id) - ]) - if dest_location in child_locations: - # normal behavior, we don't need a move between A and B - continue - # Insert move between the source and destination for the new - # operation - move_values = self._prepare_src_routing_move_values( - final_location + # 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)]} ) - move = self.copy(move_values) - - # modify the chain to include the new move - dest_move.write({ - 'move_orig_ids': [(3, self.id), (4, move.id)], - }) - self.write({ - 'move_dest_ids': [(3, dest_move.id), (4, move.id)], - }) - move._action_confirm(merge=False) - - def _prepare_src_routing_move_values(self, destination): + 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': self.location_id.id, - 'location_dest_id': destination.id, - 'state': 'waiting', - 'picking_type_id': self.picking_id.picking_type_id.id, + "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): @@ -211,7 +217,7 @@ 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"): continue # Group move lines per destination location, some may need an @@ -221,15 +227,15 @@ def _split_per_dest_routing_operation(self): routing_move_lines = {} routing_operations = {} for move_line in move.move_line_ids: - location = move_line.location_dest_id - if location in routing_operations: - routing_picking_type = routing_operations[location] + dest = move_line.location_dest_id + if dest in routing_operations: + routing_picking_type = routing_operations[dest] else: - routing_picking_type = \ - location._find_picking_type_for_routing("dest") + routing_picking_type = dest._find_picking_type_for_routing( + "dest" + ) routing_move_lines.setdefault( - routing_picking_type, - self.env['stock.move.line'].browse() + routing_picking_type, self.env["stock.move.line"].browse() ) routing_move_lines[routing_picking_type] |= move_line @@ -247,14 +253,14 @@ def _split_per_dest_routing_operation(self): 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')) + 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') + 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_move.state = "assigned" new_moves += new_move return self + new_moves @@ -269,7 +275,7 @@ 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"): continue # Group move lines per source location, some may need an additional @@ -283,58 +289,26 @@ def _apply_move_location_dest_routing_operation(self): picking_type = destination._find_picking_type_for_routing("dest") if not picking_type: continue - if move.picking_type_id == picking_type: + if self.env["stock.location"].search( + [ + ("id", "=", picking_type.default_location_dest_id.id), + ("id", "parent_of", move.location_dest_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_dest_routing_move(picking_type, destination) - - def _insert_dest_routing_move(self, picking_type, original_destination): - """Create a chained move for the destination routing operation - - It adds a new move after the current move, and link the existing - destination moves to the new move. - """ - self.ensure_one() - routing_move_values = self._prepare_dest_routing_move_values( - picking_type, - original_destination - ) - routing_move = self.copy(routing_move_values) - dest_moves = self.move_dest_ids - # 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_dest_routing_move_values( - self, picking_type, - original_destination - ): - return { - 'picking_id': False, - 'location_id': self.location_dest_id.id, - 'location_dest_id': original_destination.id, - 'state': 'waiting', - 'picking_type_id': picking_type.id, - } + 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_picking_type_routing_operation/tests/test_routing_operation_src.py b/stock_picking_type_routing_operation/tests/test_routing_operation_src.py index 54c116f5f401..05140f772632 100644 --- a/stock_picking_type_routing_operation/tests/test_routing_operation_src.py +++ b/stock_picking_type_routing_operation/tests/test_routing_operation_src.py @@ -186,7 +186,7 @@ def test_change_location_to_routing_operation(self): self.assert_dest_customer(move_b) move_middle = move_a.move_dest_ids - # the middle move stays in the same source location than the original + # 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) From 18471cc9b0647039e6db5f0ce4cbaefcdd42f01c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 28 Nov 2019 15:44:36 +0100 Subject: [PATCH 20/21] Add tooltip --- .../models/stock_location.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/stock_picking_type_routing_operation/models/stock_location.py b/stock_picking_type_routing_operation/models/stock_location.py index 7f98b525221e..af3ebb228dbb 100644 --- a/stock_picking_type_routing_operation/models/stock_location.py +++ b/stock_picking_type_routing_operation/models/stock_location.py @@ -13,7 +13,8 @@ class StockLocation(models.Model): 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).", + " (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', @@ -21,7 +22,8 @@ class StockLocation(models.Model): 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).", + " (including sub-locations). A new chained move will be created " + " to reach the original source.", ) @api.constrains('src_routing_picking_type_id') From d7bb61e0fc2e78da977013bae97fc49bc19c30ad Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Fri, 6 Dec 2019 12:04:46 +0100 Subject: [PATCH 21/21] fixup! Factorize src/dest methods and improvements --- stock_picking_type_routing_operation/models/stock_move.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stock_picking_type_routing_operation/models/stock_move.py b/stock_picking_type_routing_operation/models/stock_move.py index 11d1b79edcd8..fc0b76c4b8d1 100644 --- a/stock_picking_type_routing_operation/models/stock_move.py +++ b/stock_picking_type_routing_operation/models/stock_move.py @@ -289,10 +289,11 @@ def _apply_move_location_dest_routing_operation(self): 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_dest_id.id), - ("id", "parent_of", move.location_dest_id.id), + ("id", "=", picking_type.default_location_src_id.id), + ("id", "parent_of", move.location_id.id), ] ): # This move has been created for the routing operation,