diff --git a/addons/product_expiry/models/stock_move_line.py b/addons/product_expiry/models/stock_move_line.py index 765c5052f83..b299030fae6 100644 --- a/addons/product_expiry/models/stock_move_line.py +++ b/addons/product_expiry/models/stock_move_line.py @@ -33,4 +33,4 @@ class StockMoveLine(models.Model): def _assign_production_lot(self, lot): super()._assign_production_lot(lot) - self.lot_id._update_date_values(self.expiration_date) + self.lot_id._update_date_values(self[0].expiration_date) diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index 3eb991c8b31..74b2dc21781 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from collections import Counter +from collections import Counter, defaultdict from odoo import _, api, fields, tools, models from odoo.exceptions import UserError, ValidationError @@ -596,20 +596,28 @@ class StockMoveLine(models.Model): def _create_and_assign_production_lot(self): """ Creates and assign new production lots for move lines.""" - lot_vals = [{ - 'company_id': ml.move_id.company_id.id, - 'name': ml.lot_name, - 'product_id': ml.product_id.id, - } for ml in self] + lot_vals = [] + # It is possible to have multiple time the same lot to create & assign, + # so we handle the case with 2 dictionaries. + key_to_index = {} # key to index of the lot + key_to_mls = defaultdict(lambda: self.env['stock.move.line']) # key to all mls + for ml in self: + key = (ml.company_id.id, ml.product_id.id, ml.lot_name) + key_to_mls[key] |= ml + if ml.tracking != 'lot' or key not in key_to_index: + key_to_index[key] = len(lot_vals) + lot_vals.append({ + 'company_id': ml.company_id.id, + 'name': ml.lot_name, + 'product_id': ml.product_id.id + }) + lots = self.env['stock.production.lot'].create(lot_vals) - for ml, lot in zip(self, lots): - ml._assign_production_lot(lot) + for key, mls in key_to_mls.items(): + mls._assign_production_lot(lots[key_to_index[key]].with_prefetch(lots._ids)) # With prefetch to reconstruct the ones broke by accessing by index def _assign_production_lot(self, lot): - self.ensure_one() - self.write({ - 'lot_id': lot.id - }) + self.write({'lot_id': lot.id}) def _reservation_is_updatable(self, quantity, reserved_quant): self.ensure_one() diff --git a/addons/stock/tests/test_generate_serial_numbers.py b/addons/stock/tests/test_generate_serial_numbers.py index 1d45ac79afd..07cb40cfaa7 100644 --- a/addons/stock/tests/test_generate_serial_numbers.py +++ b/addons/stock/tests/test_generate_serial_numbers.py @@ -295,6 +295,7 @@ class StockGenerate(TransactionCase): self.assertEqual(move_line.qty_done, 1) # The location dest must be now the one from the putaway. self.assertEqual(move_line.location_dest_id.id, shelf_location.id) + def test_set_multiple_lot_name_01(self): """ Sets five SN in one time in stock move view form, then checks move has five new move lines with the right `lot_name`. diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index 3544616ce4b..0949b153c19 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -9,7 +9,6 @@ from odoo.exceptions import UserError from odoo.tests import Form from odoo.tools import float_is_zero, float_compare -from odoo.tests.common import Form class TestPickShip(TestStockCommon): def create_pick_ship(self): diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py index e65515f06ae..b4af1fabb7a 100644 --- a/addons/stock/tests/test_stock_flow.py +++ b/addons/stock/tests/test_stock_flow.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from odoo.addons.stock.tests.common import TestStockCommon +from odoo.exceptions import ValidationError from odoo.tests import Form from odoo.tools import mute_logger, float_round from odoo import fields @@ -16,12 +17,12 @@ class TestStockFlow(TestStockCommon): 'name': 'My Company (Chicago)-demo', 'email': 'chicago@yourcompany.com', 'company_id': False, - }) + }) cls.company = cls.env['res.company'].create({ 'currency_id': cls.env.ref('base.USD').id, 'partner_id': cls.partner_company2.id, 'name': 'My Company (Chicago)-demo', - }) + }) @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') def test_00_picking_create_and_transfer_quantity(self): @@ -106,7 +107,7 @@ class TestStockFlow(TestStockCommon): 'location_dest_id': self.stock_location, 'move_id': move_c.id, 'lot_id': lot2_productC.id, - }) + }) self.StockPackObj.create({ 'product_id': self.productD.id, 'qty_done': 2, @@ -114,7 +115,7 @@ class TestStockFlow(TestStockCommon): 'location_id': self.supplier_location, 'location_dest_id': self.stock_location, 'move_id': move_d.id - }) + }) # Check incoming shipment total quantity of pack operation total_qty = sum(self.StockPackObj.search([('move_id', 'in', picking_in.move_lines.ids)]).mapped('qty_done')) @@ -1904,3 +1905,118 @@ class TestStockFlow(TestStockCommon): picking = picking_form.save() self.assertEqual(picking.state, 'draft') + + def test_validate_multiple_pickings_with_same_lot_names(self): + """ Checks only one lot is created when the same lot name is used in + different pickings and those pickings are validated together. + """ + # Creates two tracked products (one by lots and one by SN). + product_lot = self.env['product.product'].create({ + 'name': 'Tracked by lot', + 'type': 'product', + 'tracking': 'lot', + }) + product_serial = self.env['product.product'].create({ + 'name': 'Tracked by SN', + 'type': 'product', + 'tracking': 'serial', + }) + # Creates two receipts using some lot names in common. + picking_type = self.env['stock.picking.type'].browse(self.picking_type_in) + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = picking_type + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_lot + move.product_uom_qty = 8 + receipt_1 = picking_form.save() + receipt_1.action_confirm() + + move_form = Form(receipt_1.move_lines, view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.lot_name = 'lot-001' + line.qty_done = 3 + with move_form.move_line_ids.new() as line: + line.lot_name = 'lot-002' + line.qty_done = 3 + with move_form.move_line_ids.new() as line: + line.lot_name = 'lot-003' + line.qty_done = 2 + move = move_form.save() + + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = picking_type + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_lot + move.product_uom_qty = 8 + receipt_2 = picking_form.save() + receipt_2.action_confirm() + + move_form = Form(receipt_2.move_lines, view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.lot_name = 'lot-003' + line.qty_done = 2 + with move_form.move_line_ids.new() as line: + line.lot_name = 'lot-004' + line.qty_done = 4 + with move_form.move_line_ids.new() as line: + line.lot_name = 'lot-001' + line.qty_done = 1 + with move_form.move_line_ids.new() as line: + line.lot_name = 'lot-005' + line.qty_done = 1 + move = move_form.save() + + # Validates the two receipts and checks the move lines' lot. + (receipt_1 | receipt_2).button_validate() + lots = self.env['stock.production.lot'].search([('product_id', '=', product_lot.id)]) + self.assertEqual(len(lots), 5) + lot1, lot2, lot3, lot4, lot5 = lots + self.assertEqual(lot1.name, 'lot-001') + self.assertEqual(lot2.name, 'lot-002') + self.assertEqual(lot3.name, 'lot-003') + self.assertEqual(lot4.name, 'lot-004') + self.assertEqual(lot5.name, 'lot-005') + self.assertEqual(receipt_1.move_line_ids[0].lot_id.id, lot1.id) + self.assertEqual(receipt_1.move_line_ids[1].lot_id.id, lot2.id) + self.assertEqual(receipt_1.move_line_ids[2].lot_id.id, lot3.id) + self.assertEqual(receipt_2.move_line_ids[0].lot_id.id, lot3.id) + self.assertEqual(receipt_2.move_line_ids[1].lot_id.id, lot4.id) + self.assertEqual(receipt_2.move_line_ids[2].lot_id.id, lot1.id) + self.assertEqual(receipt_2.move_line_ids[3].lot_id.id, lot5.id) + + # Checks also it still raise an error when it tries to create multiple time + # the same serial numbers (same scenario but with SN instead of lots). + picking_type = self.env['stock.picking.type'].browse(self.picking_type_in) + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = picking_type + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_serial + move.product_uom_qty = 2 + receipt_1 = picking_form.save() + receipt_1.action_confirm() + + move_form = Form(receipt_1.move_lines, view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.lot_name = 'sn-001' + with move_form.move_line_ids.new() as line: + line.lot_name = 'sn-002' + move = move_form.save() + + picking_form = Form(self.env['stock.picking']) + picking_form.picking_type_id = picking_type + with picking_form.move_ids_without_package.new() as move: + move.product_id = product_serial + move.product_uom_qty = 2 + receipt_2 = picking_form.save() + receipt_2.action_confirm() + + move_form = Form(receipt_2.move_lines, view="stock.view_stock_move_operations") + with move_form.move_line_ids.edit(0) as line: + line.lot_name = 'sn-002' + with move_form.move_line_ids.new() as line: + line.lot_name = 'sn-001' + move = move_form.save() + + # Validates the two receipts => It should raise an error as there is duplicate SN. + with self.assertRaises(ValidationError): + (receipt_1 | receipt_2).button_validate()