Skip to content

Commit

Permalink
Support split of moves for sourcing from different zones
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
guewen committed Jul 8, 2019
1 parent a7dc125 commit f8a5a7c
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 24 deletions.
1 change: 1 addition & 0 deletions stock_picking_zone/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from . import stock_move
from . import stock_picking_type
from . import stock_quant
91 changes: 83 additions & 8 deletions stock_picking_zone/models/stock_move.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright 2019 Camptocamp (https://www.camptocamp.com)

from itertools import chain
from odoo import models


Expand All @@ -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,
Expand Down Expand Up @@ -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)],
})
Expand Down
59 changes: 59 additions & 0 deletions stock_picking_zone/models/stock_quant.py
Original file line number Diff line number Diff line change
@@ -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
134 changes: 118 additions & 16 deletions stock_picking_zone/tests/test_picking_zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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')
Expand All @@ -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)]
)
Expand Down Expand Up @@ -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')
Expand All @@ -304,26 +306,126 @@ 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')
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.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')

0 comments on commit f8a5a7c

Please sign in to comment.