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