From 02347e46203583abb70ef36241e2f03eb0b9b956 Mon Sep 17 00:00:00 2001 From: "Adrien Widart (awt)" Date: Wed, 6 Sep 2023 09:42:12 +0000 Subject: [PATCH] [FIX] stock: check SN uniqueness by location To reproduce the issue: 1. In Settings, enable: - Storage Locations - Package 2. Create a product P - Type: Storable - Tracked by SN 3. Process a receipt with 1 x P, serial S 4. Return P in a package 5. Process a new receipt with 1 x P, still S as SN Error: An error is displayed "The serial number has already been assigned [...]". This is incorrect, the user should be able to receive that SN. After step 3, there exists a quant Q1: -1 x P at Supplier Location with S. Then, after step 4, a new quant Q2 is created: 1 x P at Supplier Location with S and the package. Because the field `package_id` is not the same on Q1 and Q2, both quants are not merged. As a result, step 5, we will update Q1 and have -2 x P at Supplier Location with S. This will trigger the constraint, hence the error message. OPW-3390615 closes odoo/odoo#134902 X-original-commit: 0459e42ac452996831b0443bce088b84735975f0 Signed-off-by: William Henrotin (whe) Signed-off-by: Adrien Widart (awt) --- addons/stock/models/stock_quant.py | 19 ++++++-- addons/stock/tests/test_quant.py | 71 +++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 5867d87e215..c26ec135ecd 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -560,10 +560,21 @@ class StockQuant(models.Model): @api.constrains('quantity') def check_quantity(self): - for quant in self: - if quant.location_id.usage != 'inventory' and quant.lot_id and quant.product_id.tracking == 'serial' \ - and float_compare(abs(quant.quantity), 1, precision_rounding=quant.product_uom_id.rounding) > 0: - raise ValidationError(_('The serial number has already been assigned: \n Product: %s, Serial Number: %s') % (quant.product_id.display_name, quant.lot_id.name)) + sn_quants = self.filtered(lambda q: q.product_id.tracking == 'serial' and q.location_id.usage != 'inventory' and q.lot_id) + if not sn_quants: + return + domain = expression.OR([ + [('product_id', '=', q.product_id.id), ('location_id', '=', q.location_id.id), ('lot_id', '=', q.lot_id.id)] + for q in sn_quants + ]) + groups = self._read_group( + domain, + ['product_id', 'location_id', 'lot_id'], + ['quantity:sum'], + ) + for product, _location, lot, qty in groups: + if float_compare(abs(qty), 1, precision_rounding=product.uom_id.rounding) > 0: + raise ValidationError(_('The serial number has already been assigned: \n Product: %s, Serial Number: %s') % (product.display_name, lot.name)) @api.constrains('location_id') def check_location_id(self): diff --git a/addons/stock/tests/test_quant.py b/addons/stock/tests/test_quant.py index bb6e8fe9f8a..91ff7f393e6 100644 --- a/addons/stock/tests/test_quant.py +++ b/addons/stock/tests/test_quant.py @@ -7,7 +7,7 @@ from unittest.mock import patch from odoo import fields from odoo.addons.mail.tests.common import mail_new_test_user from odoo.exceptions import ValidationError -from odoo.tests.common import TransactionCase +from odoo.tests.common import Form, TransactionCase from odoo.exceptions import AccessError, UserError @@ -819,6 +819,75 @@ class StockQuant(TransactionCase): else: self.assertEqual(q.display_name, '%s' % (q.location_id.display_name)) + def test_serial_constraint_with_package_and_return(self): + """ + Receive product with serial S + Return it in a package + Confirm a new receipt with S + """ + stock_location = self.env['stock.warehouse'].search([], limit=1).lot_stock_id + supplier_location = self.env.ref('stock.stock_location_suppliers') + picking_type_in = self.env.ref('stock.picking_type_in') + + receipt01 = self.env['stock.picking'].create({ + 'picking_type_id': picking_type_in.id, + 'location_id': supplier_location.id, + 'location_dest_id': stock_location.id, + 'move_ids': [(0, 0, { + 'name': self.product_serial.name, + 'product_id': self.product_serial.id, + 'location_id': supplier_location.id, + 'location_dest_id': stock_location.id, + 'product_uom_qty': 1, + 'product_uom': self.product_serial.uom_id.id, + })], + }) + receipt01.action_confirm() + receipt01.move_line_ids.write({ + 'lot_name': 'Michel', + 'qty_done': 1.0 + }) + receipt01.button_validate() + + quant = self.env['stock.quant'].search([('product_id', '=', self.product_serial.id), ('location_id', '=', stock_location.id)]) + + wizard_form = Form(self.env['stock.return.picking'].with_context(active_ids=receipt01.ids, active_id=receipt01.ids[0], active_model='stock.picking')) + wizard = wizard_form.save() + wizard.product_return_moves.quantity = 1.0 + stock_return_picking_action = wizard.create_returns() + + return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id']) + return_pick.move_ids.move_line_ids.qty_done = 1.0 + return_pick.action_put_in_pack() + return_pick._action_done() + + self.assertEqual(return_pick.move_line_ids.lot_id, quant.lot_id) + self.assertTrue(return_pick.move_line_ids.result_package_id, quant.lot_id) + + receipt02 = self.env['stock.picking'].create({ + 'picking_type_id': picking_type_in.id, + 'location_id': supplier_location.id, + 'location_dest_id': stock_location.id, + 'move_ids': [(0, 0, { + 'name': self.product_serial.name, + 'product_id': self.product_serial.id, + 'location_id': supplier_location.id, + 'location_dest_id': stock_location.id, + 'product_uom_qty': 1, + 'product_uom': self.product_serial.uom_id.id, + })], + }) + receipt02.action_confirm() + receipt02.move_line_ids.write({ + 'lot_name': 'Michel', + 'qty_done': 1.0 + }) + receipt02.button_validate() + + quant = self.env['stock.quant'].search([('product_id', '=', self.product_serial.id), ('location_id', '=', stock_location.id)]) + self.assertEqual(len(quant), 1) + self.assertEqual(quant.lot_id.name, 'Michel') + class StockQuantRemovalStrategy(TransactionCase): def setUp(self): super().setUp()