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