diff --git a/addons/product/models/product_product.py b/addons/product/models/product_product.py index ed567100856..b7782d0310a 100644 --- a/addons/product/models/product_product.py +++ b/addons/product/models/product_product.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import re +from operator import itemgetter from odoo import api, fields, models, tools, _ from odoo.exceptions import ValidationError @@ -168,9 +169,35 @@ class ProductProduct(models.Model): self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS product_product_combination_unique ON %s (product_tmpl_id, combination_indices) WHERE active is true" % self._table) - _sql_constraints = [ - ('barcode_uniq', 'unique(barcode)', "A barcode can only be assigned to one product !"), - ] + @api.constrains("barcode") + def _check_barcode_unique(self): + + # Collect `product.product.id` of objects that share a barcode with any of those in `self`, + # and group them by barcode. + candidate_duplicates = self.env['product.product'].sudo().read_group( + domain=[("barcode", "in", list(set(product.barcode for product in self if product.barcode)))], + fields=['ids:array_agg(id)', 'barcode'], + groupby=['barcode'] + ) + + # Keep only barcodes that match more than one `product.product` object. + duplicates = [candidate for candidate in candidate_duplicates if candidate['barcode_count'] > 1] + if duplicates: + # Prepare a mapping of "{ `product.barcode`: [`product.display_name`, ... ] }" to show the user. + duplicates_joined = {dup['barcode']: ', '.join( + sorted([product.display_name for product in + self.env['product.product'].browse(dup['ids'])]) + ) for dup in sorted(duplicates, key=itemgetter('barcode'))} + + # Transform the mapping into a prettified string and raise. + duplicates_as_str = "\n".join( + f"Barcode \"{barcode}\" already assigned to product(s): {products}" + for barcode, products in duplicates_joined.items() + ) + + raise ValidationError( + "Barcode(s) already assigned:\n\n{duplicates}".format(duplicates=duplicates_as_str) + ) @api.constrains('barcode') def _check_barcode_uniqueness(self): diff --git a/addons/product/tests/__init__.py b/addons/product/tests/__init__.py index d125ddbec69..26f5dbef310 100644 --- a/addons/product/tests/__init__.py +++ b/addons/product/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_pricelist from . import test_product_pricelist from . import test_seller from . import test_product_attribute_value_config +from . import test_barcode diff --git a/addons/product/tests/test_barcode.py b/addons/product/tests/test_barcode.py new file mode 100644 index 00000000000..4b53f757fa2 --- /dev/null +++ b/addons/product/tests/test_barcode.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions +from odoo.tests.common import TransactionCase + + +class TestProductBarcode(TransactionCase): + + def setUp(self): + super().setUp() + self.env['product.product'].create({'name': 'BC1', 'barcode': '1'}) + self.env['product.product'].create({'name': 'BC2', 'barcode': '2'}) + + def test_blank_barcodes_allowed(self): + """Makes sure duplicated blank barcodes are allowed.""" + for i in range(2): + self.env['product.product'].create({'name': f'BC_{i}'}) + + def test_false_barcodes_allowed(self): + """Makes sure duplicated False barcodes are allowed.""" + for i in range(2): + self.env['product.product'].create({'name': f'BC_{i}', 'barcode': False}) + + def test_duplicated_barcode(self): + """Tests for simple duplication.""" + with self.assertRaises(exceptions.ValidationError): + self.env['product.product'].create({'name': 'BC3', 'barcode': '1'}) + + def test_duplicated_barcode_in_batch_edit(self): + """Tests for duplication in batch edits.""" + batch = [ + {'name': 'BC3', 'barcode': '3'}, + {'name': 'BC4', 'barcode': '4'}, + ] + self.env['product.product'].create(batch) + batch.append({'name': 'BC5', 'barcode': '1'}) + with self.assertRaises(exceptions.ValidationError): + self.env['product.product'].create(batch) + + def test_test_duplicated_barcode_error_msg_content(self): + """Validates the error message shown when duplicated barcodes are found.""" + batch = [ + {'name': 'BC3', 'barcode': '3'}, + {'name': 'BC4', 'barcode': '3'}, + {'name': 'BC5', 'barcode': '4'}, + {'name': 'BC6', 'barcode': '4'}, + {'name': 'BC7', 'barcode': '1'}, + ] + try: + self.env['product.product'].create(batch) + except exceptions.ValidationError as exc: + assert 'Barcode "3" already assigned to product(s): BC3, BC4' in exc.args[0] + assert 'Barcode "4" already assigned to product(s): BC5, BC6' in exc.args[0] + assert 'Barcode "1" already assigned to product(s): BC1' in exc.args[0]