diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index d8ab7a42284..8b3bb5401cf 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -367,8 +367,9 @@ class StockMove(models.Model): return True return False - def _should_bypass_reservation(self): - res = super(StockMove, self)._should_bypass_reservation() + def _should_bypass_reservation(self, forced_location=False): + res = super(StockMove, self)._should_bypass_reservation( + forced_location=forced_location) return bool(res and not self.production_id) def _key_assign_picking(self): diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py index fc944c6b955..7e7c3993a14 100644 --- a/addons/mrp_subcontracting/models/__init__.py +++ b/addons/mrp_subcontracting/models/__init__.py @@ -5,7 +5,6 @@ from . import product from . import res_company from . import res_partner from . import stock_move -from . import stock_move_line from . import stock_picking from . import stock_quant from . import stock_rule diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py index 59cb6108465..c7ed7d7112b 100644 --- a/addons/mrp_subcontracting/models/stock_move.py +++ b/addons/mrp_subcontracting/models/stock_move.py @@ -197,9 +197,9 @@ class StockMove(models.Model): return False return super()._should_bypass_set_qty_producing() - def _should_bypass_reservation(self): + def _should_bypass_reservation(self, forced_location=False): """ If the move is subcontracted then ignore the reservation. """ - should_bypass_reservation = super(StockMove, self)._should_bypass_reservation() + should_bypass_reservation = super()._should_bypass_reservation(forced_location=forced_location) if not should_bypass_reservation and self.is_subcontract: return True return should_bypass_reservation diff --git a/addons/mrp_subcontracting/models/stock_move_line.py b/addons/mrp_subcontracting/models/stock_move_line.py deleted file mode 100644 index 6dfb789b041..00000000000 --- a/addons/mrp_subcontracting/models/stock_move_line.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import models, fields - - -class StockMoveLine(models.Model): - _inherit = 'stock.move.line' - - def _should_bypass_reservation(self, location): - """ If the move line is subcontracted then ignore the reservation. """ - should_bypass_reservation = super(StockMoveLine, self)._should_bypass_reservation(location) - if not should_bypass_reservation and self.move_id.is_subcontract: - return True - return should_bypass_reservation diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 663d2b83acd..a2b87d0996f 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1320,13 +1320,16 @@ class StockMove(models.Model): self.env['stock.move.line'].create(self._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)) return taken_quantity - def _should_bypass_reservation(self): + def _should_bypass_reservation(self, forced_location=False): self.ensure_one() - return self.location_id.should_bypass_reservation() or self.product_id.type != 'product' + location = forced_location or self.location_id + return location.should_bypass_reservation() or self.product_id.type != 'product' # necessary hook to be able to override move reservation to a restrict lot, owner, pack, location... def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): self.ensure_one() + if location_id.should_bypass_reservation(): + return self.product_qty return self.env['stock.quant']._get_available_quantity(self.product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=strict, allow_negative=allow_negative) def _action_assign(self): @@ -1335,6 +1338,46 @@ class StockMove(models.Model): equal to its `product_qty`. If it is less, the stock move is considered partially available. """ + + def _get_available_move_lines(move): + move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids') + keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id'] + + def _keys_in_sorted(ml): + return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id) + + grouped_move_lines_in = {} + for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)): + qty_done = 0 + for ml in g: + qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + grouped_move_lines_in[k] = qty_done + move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\ + .filtered(lambda m: m.state in ['done'])\ + .mapped('move_line_ids') + # As we defer the write on the stock.move's state at the end of the loop, there + # could be moves to consider in what our siblings already took. + moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move + moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids)) + reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned']) + move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids') + keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id'] + + def _keys_out_sorted(ml): + return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id) + + grouped_move_lines_out = {} + for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): + qty_done = 0 + for ml in g: + qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) + grouped_move_lines_out[k] = qty_done + for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): + grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) + available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in} + # pop key if the quantity available amount to 0 + return dict((k, v) for k, v in available_move_lines.items() if v) + StockMove = self.env['stock.move'] assigned_moves_ids = OrderedSet() partially_available_moves_ids = OrderedSet() @@ -1349,10 +1392,26 @@ class StockMove(models.Model): missing_reserved_quantity = move.product_uom._compute_quantity(missing_reserved_uom_quantity, move.product_id.uom_id, rounding_method='HALF-UP') if move._should_bypass_reservation(): # create the move line(s) but do not impact quants - if move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): + if move.move_orig_ids: + available_move_lines = _get_available_move_lines(move) + for (location_id, lot_id, package_id, owner_id), quantity in available_move_lines.items(): + qty_added = min(missing_reserved_quantity, quantity) + move_line_vals = move._prepare_move_line_vals(qty_added) + move_line_vals.update({ + 'location_id': location_id.id, + 'lot_id': lot_id.id, + 'lot_name': lot_id.name, + 'owner_id': owner_id.id, + }) + move_line_vals_list.append(move_line_vals) + missing_reserved_quantity -= qty_added + if float_is_zero(missing_reserved_quantity, precision_rounding=move.product_id.uom_id.rounding): + break + + if missing_reserved_quantity and move.product_id.tracking == 'serial' and (move.picking_type_id.use_create_lots or move.picking_type_id.use_existing_lots): for i in range(0, int(missing_reserved_quantity)): move_line_vals_list.append(move._prepare_move_line_vals(quantity=1)) - else: + elif missing_reserved_quantity: to_update = move.move_line_ids.filtered(lambda ml: ml.product_uom_id == move.product_uom and ml.location_id == move.location_id and ml.location_dest_id == move.location_dest_id and @@ -1361,7 +1420,8 @@ class StockMove(models.Model): not ml.package_id and not ml.owner_id) if to_update: - to_update[0].product_uom_qty += missing_reserved_uom_quantity + to_update[0].product_uom_qty += move.product_id.uom_id._compute_quantity( + missing_reserved_quantity, move.product_uom, rounding_method='HALF-UP') else: move_line_vals_list.append(move._prepare_move_line_vals(quantity=missing_reserved_quantity)) assigned_moves_ids.add(move.id) @@ -1394,44 +1454,7 @@ class StockMove(models.Model): # `qty_done` is in `ml.product_uom_id` and, as we will later increase # the reserved quantity on the quants, convert it here in # `product_id.uom_id` (the UOM of the quants is the UOM of the product). - move_lines_in = move.move_orig_ids.filtered(lambda m: m.state == 'done').mapped('move_line_ids') - keys_in_groupby = ['location_dest_id', 'lot_id', 'result_package_id', 'owner_id'] - - def _keys_in_sorted(ml): - return (ml.location_dest_id.id, ml.lot_id.id, ml.result_package_id.id, ml.owner_id.id) - - grouped_move_lines_in = {} - for k, g in groupby(sorted(move_lines_in, key=_keys_in_sorted), key=itemgetter(*keys_in_groupby)): - qty_done = 0 - for ml in g: - qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) - grouped_move_lines_in[k] = qty_done - move_lines_out_done = (move.move_orig_ids.mapped('move_dest_ids') - move)\ - .filtered(lambda m: m.state in ['done'])\ - .mapped('move_line_ids') - # As we defer the write on the stock.move's state at the end of the loop, there - # could be moves to consider in what our siblings already took. - moves_out_siblings = move.move_orig_ids.mapped('move_dest_ids') - move - moves_out_siblings_to_consider = moves_out_siblings & (StockMove.browse(assigned_moves_ids) + StockMove.browse(partially_available_moves_ids)) - reserved_moves_out_siblings = moves_out_siblings.filtered(lambda m: m.state in ['partially_available', 'assigned']) - move_lines_out_reserved = (reserved_moves_out_siblings | moves_out_siblings_to_consider).mapped('move_line_ids') - keys_out_groupby = ['location_id', 'lot_id', 'package_id', 'owner_id'] - - def _keys_out_sorted(ml): - return (ml.location_id.id, ml.lot_id.id, ml.package_id.id, ml.owner_id.id) - - grouped_move_lines_out = {} - for k, g in groupby(sorted(move_lines_out_done, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): - qty_done = 0 - for ml in g: - qty_done += ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id) - grouped_move_lines_out[k] = qty_done - for k, g in groupby(sorted(move_lines_out_reserved, key=_keys_out_sorted), key=itemgetter(*keys_out_groupby)): - grouped_move_lines_out[k] = sum(self.env['stock.move.line'].concat(*list(g)).mapped('product_qty')) - available_move_lines = {key: grouped_move_lines_in[key] - grouped_move_lines_out.get(key, 0) for key in grouped_move_lines_in.keys()} - # pop key if the quantity available amount to 0 - available_move_lines = dict((k, v) for k, v in available_move_lines.items() if v) - + available_move_lines = _get_available_move_lines(move) if not available_move_lines: continue for move_line in move.move_line_ids.filtered(lambda m: m.product_qty): diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 1ecd487bc1b..a0b287fccc6 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -340,7 +340,7 @@ class StockMoveLine(models.Model): new_product_uom_qty = ml.product_qty # Unreserve the old charateristics of the move line. - if not ml._should_bypass_reservation(ml.location_id): + if not ml.move_id._should_bypass_reservation(ml.location_id): try: Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: @@ -351,7 +351,7 @@ class StockMoveLine(models.Model): raise # Reserve the maximum available of the new charateristics of the move line. - if not ml._should_bypass_reservation(updates.get('location_id', ml.location_id)): + if not ml.move_id._should_bypass_reservation(updates.get('location_id', ml.location_id)): reserved_qty = 0 try: q = Quant._update_reserved_quantity(ml.product_id, updates.get('location_id', ml.location_id), new_product_uom_qty, lot_id=updates.get('lot_id', ml.lot_id), @@ -395,7 +395,7 @@ class StockMoveLine(models.Model): result_package_id = updates.get('result_package_id', ml.result_package_id) owner_id = updates.get('owner_id', ml.owner_id) quantity = ml.move_id.product_uom._compute_quantity(qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') - if not ml._should_bypass_reservation(location_id): + if not ml.move_id._should_bypass_reservation(location_id): ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) if not float_is_zero(quantity, precision_digits=precision): available_qty, in_date = Quant._update_available_quantity(product_id, location_id, -quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) @@ -406,7 +406,7 @@ class StockMoveLine(models.Model): taken_from_untracked_qty = min(untracked_qty, abs(available_qty)) Quant._update_available_quantity(product_id, location_id, -taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) Quant._update_available_quantity(product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id) - if not ml._should_bypass_reservation(location_id): + if not ml.move_id._should_bypass_reservation(location_id): ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) Quant._update_available_quantity(product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id, in_date=in_date) @@ -451,7 +451,7 @@ class StockMoveLine(models.Model): precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') for ml in self: # Unlinking a move line should unreserve. - if ml.product_id.type == 'product' and not ml._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision): + if ml.product_id.type == 'product' and not ml.move_id._should_bypass_reservation(ml.location_id) and not float_is_zero(ml.product_qty, precision_digits=precision): try: self.env['stock.quant']._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: @@ -553,12 +553,12 @@ class StockMoveLine(models.Model): rounding = ml.product_uom_id.rounding # if this move line is force assigned, unreserve elsewhere if needed - if not ml._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0: + if not ml.move_id._should_bypass_reservation(ml.location_id) and float_compare(ml.qty_done, ml.product_uom_qty, precision_rounding=rounding) > 0: qty_done_product_uom = ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id, rounding_method='HALF-UP') extra_qty = qty_done_product_uom - ml.product_qty ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, ml_ids_to_ignore=ml_ids_to_ignore) # unreserve what's been reserved - if not ml._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty: + if not ml.move_id._should_bypass_reservation(ml.location_id) and ml.product_id.type == 'product' and ml.product_qty: try: Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: @@ -702,10 +702,6 @@ class StockMoveLine(models.Model): self.env['stock.move.line'].browse(to_unlink_candidate_ids).unlink() move_to_recompute_state._recompute_state() - def _should_bypass_reservation(self, location): - self.ensure_one() - return location.should_bypass_reservation() or self.product_id.type != 'product' - def _get_aggregated_product_quantities(self, **kwargs): """ Returns a dictionary of products (key = id+name+description+uom) and corresponding values of interest. diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index e1afcfb4e22..beb4343e0b0 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -76,6 +76,7 @@ class Warehouse(models.Model): out_type_id = fields.Many2one('stock.picking.type', 'Out Type', check_company=True) in_type_id = fields.Many2one('stock.picking.type', 'In Type', check_company=True) int_type_id = fields.Many2one('stock.picking.type', 'Internal Type', check_company=True) + return_type_id = fields.Many2one('stock.picking.type', 'Return Type', check_company=True) crossdock_route_id = fields.Many2one('stock.location.route', 'Crossdock Route', ondelete='restrict') reception_route_id = fields.Many2one('stock.location.route', 'Receipt Route', ondelete='restrict') delivery_route_id = fields.Many2one('stock.location.route', 'Delivery Route', ondelete='restrict') @@ -332,7 +333,7 @@ class Warehouse(models.Model): warehouse_data[picking_type] = PickingType.create(values).id if 'out_type_id' in warehouse_data: - PickingType.browse(warehouse_data['out_type_id']).write({'return_picking_type_id': warehouse_data.get('in_type_id', False)}) + PickingType.browse(warehouse_data['out_type_id']).write({'return_picking_type_id': warehouse_data.get('return_type_id', False)}) if 'in_type_id' in warehouse_data: PickingType.browse(warehouse_data['in_type_id']).write({'return_picking_type_id': warehouse_data.get('out_type_id', False)}) return warehouse_data @@ -915,6 +916,10 @@ class Warehouse(models.Model): 'int_type_id': { 'barcode': self.code.replace(" ", "").upper() + "-INTERNAL", }, + 'return_type_id': { + 'default_location_dest_id': output_loc.id, + 'barcode': self.code.replace(" ", "").upper() + "-RETURNS", + }, } def _get_picking_type_create_values(self, max_sequence): @@ -976,6 +981,16 @@ class Warehouse(models.Model): 'sequence': max_sequence + 2, 'sequence_code': 'INT', 'company_id': self.company_id.id, + }, 'return_type_id': { + 'name': _('Returns'), + 'code': 'incoming', + 'use_create_lots': False, + 'use_existing_lots': True, + 'default_location_src_id': False, + 'sequence': max_sequence + 6, + 'show_reserved': True, + 'sequence_code': 'IN', + 'company_id': self.company_id.id, }, }, max_sequence + 6 @@ -1009,6 +1024,11 @@ class Warehouse(models.Model): 'prefix': self.code + '/INT/', 'padding': 5, 'company_id': self.company_id.id, }, + 'return_type_id': { + 'name': self.name + ' ' + _('Sequence return'), + 'prefix': self.code + '/RET/', 'padding': 5, + 'company_id': self.company_id.id, + }, } def _format_rulename(self, from_loc, dest_loc, suffix): diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 64fe4f08691..1cecf9c7076 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -740,6 +740,75 @@ class TestPickShip(TestStockCommon): self.assertEqual(self.env['stock.quant']._get_available_quantity(self.productA, return_location), 5.0) self.assertEqual(len(self.env['stock.quant'].search([('product_id', '=', self.productA.id), ('quantity', '!=', 0)])), 2) + def test_return_lot(self): + """ With two distinct deliveries for the same product tracked by lot, ensure that the + return of the second picking suggest the lot from the picking returned. + """ + self.productA.tracking = 'lot' + lot1 = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': self.productA.id, + 'company_id': self.env.company.id, + }) + lot2 = self.env['stock.production.lot'].create({ + 'name': 'lot2', + 'product_id': self.productA.id, + 'company_id': self.env.company.id, + }) + lot3 = self.env['stock.production.lot'].create({ + 'name': 'lot3', + 'product_id': self.productA.id, + 'company_id': self.env.company.id, + }) + stock_location = self.env['stock.location'].browse(self.stock_location) + + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot1) + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot2) + self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 7.0, lot_id=lot3) + + picking_pick, picking_client = self.create_pick_ship() + picking_pick.action_confirm() + picking_pick.action_assign() + for move_line in picking_pick.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + picking_pick._action_done() + picking_client.action_confirm() + picking_client.action_assign() + for move_line in picking_client.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + picking_client._action_done() + + picking_pick, picking_client = self.create_pick_ship() + picking_pick.action_confirm() + picking_pick.action_assign() + for move_line in picking_pick.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + picking_pick._action_done() + picking_client.action_confirm() + picking_client.action_assign() + for move_line in picking_client.move_line_ids: + move_line.qty_done = move_line.product_uom_qty + picking_client._action_done() + + # Following FIFO strategy, First picking should have empty lot1 and took 3 of lot2. + # So the second picking contains 4 lot2 and 6 lot3 + self.assertEqual(picking_client.move_line_ids[0].lot_id, lot2) + self.assertEqual(picking_client.move_line_ids[0].qty_done, 4) + self.assertEqual(picking_client.move_line_ids[1].lot_id, lot3) + self.assertEqual(picking_client.move_line_ids[1].qty_done, 6) + + stock_return_picking_form = Form(self.env['stock.return.picking'].with_context( + active_ids=picking_client.ids, active_id=picking_client.ids[0], active_model='stock.picking')) + stock_return_picking = stock_return_picking_form.save() + stock_return_picking.product_return_moves.quantity = 10.0 + return_pick = self.env['stock.picking'].browse(stock_return_picking.create_returns()['res_id']) + + self.assertEqual(len(return_pick.move_line_ids), 2) + self.assertEqual(return_pick.move_line_ids[0].lot_id, lot2) + self.assertEqual(return_pick.move_line_ids[0].product_uom_qty, 4) + self.assertEqual(return_pick.move_line_ids[1].lot_id, lot3) + self.assertEqual(return_pick.move_line_ids[1].product_uom_qty, 6) + self.assertEqual(return_pick.picking_type_id, picking_client.location_id.warehouse_id.return_type_id) class TestSinglePicking(TestStockCommon): def test_backorder_1(self): diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py index 599d1bd9f32..becf1eb792c 100644 --- a/addons/stock/tests/test_packing.py +++ b/addons/stock/tests/test_packing.py @@ -218,7 +218,7 @@ class TestPacking(TestPackingCommon): }) picking = self.env['stock.picking'].create({ 'picking_type_id': self.warehouse.in_type_id.id, - 'location_id': self.stock_location.id, + 'location_id': self.customer_location.id, 'location_dest_id': self.stock_location.id, 'state': 'draft', })