From f8a5a7cdee8e9480c405d1d5802c870ef6493e2c Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 8 Jul 2019 17:06:42 +0200 Subject: [PATCH] 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')