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')