Skip to content

Commit

Permalink
Add stock_picking_zone
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
guewen committed Jul 5, 2019
1 parent d69bc62 commit 1d5fa2e
Show file tree
Hide file tree
Showing 13 changed files with 366 additions and 0 deletions.
1 change: 1 addition & 0 deletions setup/stock_picking_zone/odoo/addons/stock_picking_zone
6 changes: 6 additions & 0 deletions setup/stock_picking_zone/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
1 change: 1 addition & 0 deletions stock_picking_zone/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
17 changes: 17 additions & 0 deletions stock_picking_zone/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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',
],
'installable': True,
}
2 changes: 2 additions & 0 deletions stock_picking_zone/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import stock_move
from . import stock_picking_type
74 changes: 74 additions & 0 deletions stock_picking_zone/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -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,
}
56 changes: 56 additions & 0 deletions stock_picking_zone/models/stock_picking_type.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions stock_picking_zone/models/stock_picking_zone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2019 Camptocamp (https://www.camptocamp.com)

from odoo import fields, models


class StockPickingZone(models.Model):
_name = 'stock.picking.zone'
_description = 'Stock Picking Zone'

name = fields.Char(required=True)
active = fields.Boolean(
default=True,
help="By unchecking the active field, you may hide"
" a zone without deleting it."
)
# When we assign a move, from the source location, search
# the first location (going up through the locations) which
# has a zone. Change the destination and picking type of the move line
# to the zone's
location_id = fields.Many2one(
comodel_name='stock.location',
string='Source Location',
index=True,
required=True,
)
location_dest_id = fields.Many2one(
comodel_name='stock.location',
string='Destination Location',
index=True,
required=True,
)
picking_type_id = fields.Many2one(
comodel_name='stock.picking.type',
required=True,
)
2 changes: 2 additions & 0 deletions stock_picking_zone/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Joël Grand-Guillaume <[email protected]>
* Guewen Baconnier <[email protected]>
10 changes: 10 additions & 0 deletions stock_picking_zone/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions stock_picking_zone/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import test_picking_zone
146 changes: 146 additions & 0 deletions stock_picking_zone/tests/test_picking_zone.py
Original file line number Diff line number Diff line change
@@ -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')
15 changes: 15 additions & 0 deletions stock_picking_zone/views/stock_picking_type_views.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>

<record model="ir.ui.view" id="view_picking_type_form">
<field name="name">Operation Types</field>
<field name="model">stock.picking.type</field>
<field name="inherit_id" ref="stock.view_picking_type_form"/>
<field name="arch" type="xml">
<field name="default_location_dest_id" position="after">
<field name="is_zone"/>
</field>
</field>
</record>

</odoo>

0 comments on commit 1d5fa2e

Please sign in to comment.