diff --git a/addons/barcodes/models/barcode_nomenclature.py b/addons/barcodes/models/barcode_nomenclature.py index bcf95aa376d..db6163baaf6 100644 --- a/addons/barcodes/models/barcode_nomenclature.py +++ b/addons/barcodes/models/barcode_nomenclature.py @@ -1,6 +1,7 @@ import re from odoo import models, fields, api +from odoo.tools import check_barcode_encoding, get_barcode_check_digit UPC_EAN_CONVERSIONS = [ @@ -21,55 +22,6 @@ class BarcodeNomenclature(models.Model): UPC_EAN_CONVERSIONS, string='UPC/EAN Conversion', required=True, default='always', help="UPC Codes can be converted to EAN by prefixing them with a zero. This setting determines if a UPC/EAN barcode should be automatically converted in one way or another when trying to match a rule with the other encoding.") - @api.model - def get_barcode_check_digit(self, numeric_barcode): - """ Computes and returns the barcode check digit. The used algorithm - follows the GTIN specifications and can be used by all compatible - barcode nomenclature, like as EAN-8, EAN-12 (UPC-A) or EAN-13. - - https://www.gs1.org/sites/default/files/docs/barcodes/GS1_General_Specifications.pdf - https://www.gs1.org/services/how-calculate-check-digit-manually - - :param numeric_barcode: the barcode to verify/recompute the check digit - :type numeric_barcode: str - :return: the number corresponding to the right check digit - :rtype: int - """ - # Multiply value of each position by - # N1 N2 N3 N4 N5 N6 N7 N8 N9 N10 N11 N12 N13 N14 N15 N16 N17 N18 - # x3 X1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 CHECKSUM - oddsum = evensum = total = 0 - code = numeric_barcode[-2::-1] # Remove the check digit and reverse the barcode. - # The CHECKSUM digit is removed because it will be recomputed and it must not interfer with - # the computation. Also, the barcode is inverted, so the barcode length doesn't matter. - # Otherwise, the digits' group (even or odd) could be different according to the barcode length. - for i, digit in enumerate(code): - if i % 2 == 0: - evensum += int(digit) - else: - oddsum += int(digit) - total = evensum * 3 + oddsum - return (10 - total % 10) % 10 - - @api.model - def check_encoding(self, barcode, encoding): - """ Checks if the given barcode is correctly encoded. - - :return: True if the barcode string is encoded with the provided encoding. - :rtype: bool - """ - if encoding == "any": - return True - barcode_sizes = { - 'ean8': 8, - 'ean13': 13, - 'gtin14': 14, - 'upca': 12, - 'sscc': 18, - } - barcode_size = barcode_sizes[encoding] - return len(barcode) == barcode_size and re.match(r"^\d+$", barcode) and self.get_barcode_check_digit(barcode) == int(barcode[-1]) - @api.model def sanitize_ean(self, ean): """ Returns a valid zero padded EAN-13 from an EAN prefix. @@ -77,7 +29,7 @@ class BarcodeNomenclature(models.Model): :type ean: str """ ean = ean[0:13].zfill(13) - return ean[0:-1] + str(self.get_barcode_check_digit(ean)) + return ean[0:-1] + str(get_barcode_check_digit(ean)) @api.model def sanitize_upc(self, upc): @@ -153,12 +105,12 @@ class BarcodeNomenclature(models.Model): for rule in self.rule_ids: cur_barcode = barcode - if rule.encoding == 'ean13' and self.check_encoding(barcode, 'upca') and self.upc_ean_conv in ['upc2ean', 'always']: + if rule.encoding == 'ean13' and check_barcode_encoding(barcode, 'upca') and self.upc_ean_conv in ['upc2ean', 'always']: cur_barcode = '0' + cur_barcode - elif rule.encoding == 'upca' and self.check_encoding(barcode, 'ean13') and barcode[0] == '0' and self.upc_ean_conv in ['ean2upc', 'always']: + elif rule.encoding == 'upca' and check_barcode_encoding(barcode, 'ean13') and barcode[0] == '0' and self.upc_ean_conv in ['ean2upc', 'always']: cur_barcode = cur_barcode[1:] - if not self.check_encoding(barcode, rule.encoding): + if not check_barcode_encoding(barcode, rule.encoding): continue match = self.match_pattern(cur_barcode, rule.pattern) diff --git a/addons/barcodes/tests/test_barcode_nomenclature.py b/addons/barcodes/tests/test_barcode_nomenclature.py index 1bdf20c050e..b1316971489 100644 --- a/addons/barcodes/tests/test_barcode_nomenclature.py +++ b/addons/barcodes/tests/test_barcode_nomenclature.py @@ -12,13 +12,6 @@ class TestBarcodeNomenclature(common.TransactionCase): 'name': 'Barcode Nomenclature Test', }) - def test_barcode_check_digit(self): - barcode_nomenclature = self.env['barcode.nomenclature'] - ean8 = "87111125" - self.assertEqual(barcode_nomenclature.get_barcode_check_digit("0" * 10 + ean8), int(ean8[-1])) - ean13 = "1234567891231" - self.assertEqual(barcode_nomenclature.get_barcode_check_digit("0" * 5 + ean13), int(ean13[-1])) - def test_barcode_nomenclature_parse_barcode_ean8_01(self): """ Parses some barcodes with a simple EAN-8 barcode rule and checks the result. """ diff --git a/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py b/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py index 99c42f80d1a..f3c66bfd334 100644 --- a/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py +++ b/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py @@ -4,6 +4,7 @@ import calendar from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +from odoo.tools import get_barcode_check_digit FNC1_CHAR = '\x1D' @@ -76,7 +77,7 @@ class BarcodeNomenclature(models.Model): rule.name)) elif rule.gs1_content_type == 'identifier': # Check digit and remove it of the value - if match.group(2)[-1] != str(self.get_barcode_check_digit("0" * (18 - len(match.group(2))) + match.group(2))): + if match.group(2)[-1] != str(get_barcode_check_digit("0" * (18 - len(match.group(2))) + match.group(2))): return None result['value'] = match.group(2) elif rule.gs1_content_type == 'date': diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 86d54e9c2a1..7d5189f5022 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -9,7 +9,7 @@ from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.osv import expression -from odoo.tools import float_is_zero +from odoo.tools import float_is_zero, check_barcode_encoding from odoo.tools.float_utils import float_round from odoo.tools.mail import html2plaintext, is_html_empty @@ -113,7 +113,7 @@ class Product(models.Model): self.valid_ean = False for product in self: if product.barcode: - product.valid_ean = self.env['barcode.nomenclature'].check_encoding(product.barcode.rjust(14, '0'), 'gtin14') + product.valid_ean = check_barcode_encoding(product.barcode.rjust(14, '0'), 'gtin14') @api.depends('stock_move_ids.product_qty', 'stock_move_ids.state') @api.depends_context( diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 995867c5787..8538a0320aa 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -8,7 +8,7 @@ from psycopg2 import Error from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError from odoo.osv import expression -from odoo.tools import groupby +from odoo.tools import check_barcode_encoding, groupby from odoo.tools.float_utils import float_compare, float_is_zero _logger = logging.getLogger(__name__) @@ -1101,7 +1101,7 @@ class QuantPackage(models.Model): self.valid_sscc = False for package in self: if package.name: - package.valid_sscc = self.env['barcode.nomenclature'].check_encoding(package.name, 'sscc') + package.valid_sscc = check_barcode_encoding(package.name, 'sscc') def _search_owner(self, operator, value): if value: diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py index 054a20ebf56..141c274dbda 100644 --- a/odoo/addons/base/models/ir_actions_report.py +++ b/odoo/addons/base/models/ir_actions_report.py @@ -6,7 +6,7 @@ from odoo import api, fields, models, tools, SUPERUSER_ID, _ from odoo.exceptions import UserError, AccessError from odoo.tools.safe_eval import safe_eval, time from odoo.tools.misc import find_in_path -from odoo.tools import config, is_html_empty, parse_version +from odoo.tools import check_barcode_encoding, config, is_html_empty, parse_version from odoo.http import request from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN @@ -550,6 +550,14 @@ class IrActionsReport(models.Model): if kwargs['quiet']: kwargs['barBorder'] = 0 + if barcode_type in ('EAN8', 'EAN13') and not check_barcode_encoding(value, barcode_type): + # If the barcode does not respect the encoding specifications, convert its type into Code128. + # Otherwise, the report-lab method may return a barcode different from its value. For instance, + # if the barcode type is EAN-8 and the value 11111111, the report-lab method will take the first + # seven digits and will compute the check digit, which gives: 11111115 -> the barcode does not + # match the expected value. + barcode_type = 'Code128' + try: barcode = createBarcodeDrawing(barcode_type, value=value, format='png', **kwargs) diff --git a/odoo/addons/base/tests/__init__.py b/odoo/addons/base/tests/__init__.py index 9ab47384d61..cbb693194df 100644 --- a/odoo/addons/base/tests/__init__.py +++ b/odoo/addons/base/tests/__init__.py @@ -3,6 +3,7 @@ from . import common from . import test_acl from . import test_api +from . import test_barcode from . import test_base from . import test_basecase from . import test_cache diff --git a/odoo/addons/base/tests/test_barcode.py b/odoo/addons/base/tests/test_barcode.py new file mode 100644 index 00000000000..64e9bd3693a --- /dev/null +++ b/odoo/addons/base/tests/test_barcode.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import TransactionCase +from odoo.tools import check_barcode_encoding, get_barcode_check_digit + + +class TestBarcode(TransactionCase): + def test_barcode_check_digit(self): + ean8 = "87111125" + self.assertEqual(get_barcode_check_digit("0" * 10 + ean8), int(ean8[-1])) + ean13 = "1234567891231" + self.assertEqual(get_barcode_check_digit("0" * 5 + ean13), int(ean13[-1])) + + def test_barcode_encoding(self): + self.assertTrue(check_barcode_encoding('20220006', 'ean8')) + self.assertTrue(check_barcode_encoding('93855341', 'ean8')) + self.assertTrue(check_barcode_encoding('2022071416014', 'ean13')) + self.assertTrue(check_barcode_encoding('9745213796142', 'ean13')) + + self.assertFalse(check_barcode_encoding('2022a006', 'ean8'), 'should contains digits only') + self.assertFalse(check_barcode_encoding('20220000', 'ean8'), 'incorrect check digit') + self.assertFalse(check_barcode_encoding('93855341', 'ean13'), 'ean13 is a 13-digits barcode') + self.assertFalse(check_barcode_encoding('9745213796142', 'ean8'), 'ean8 is a 8-digits barcode') + self.assertFalse(check_barcode_encoding('9745213796148', 'ean13'), 'incorrect check digit') + self.assertFalse(check_barcode_encoding('2022!71416014', 'ean13'), 'should contains digits only') + self.assertFalse(check_barcode_encoding('0022071416014', 'ean13'), 'when starting with one zero, it indicates that a 12-digit UPC-A code follows') diff --git a/odoo/tools/__init__.py b/odoo/tools/__init__.py index 5ecf4df075e..4bc5254382d 100644 --- a/odoo/tools/__init__.py +++ b/odoo/tools/__init__.py @@ -7,6 +7,7 @@ from . import cloc from . import pdf from . import pycompat from . import win32 +from .barcode import * from .config import config from .date_utils import * from .debugger import * diff --git a/odoo/tools/barcode.py b/odoo/tools/barcode.py new file mode 100644 index 00000000000..1fb27cf6408 --- /dev/null +++ b/odoo/tools/barcode.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import re + + +def get_barcode_check_digit(numeric_barcode): + """ Computes and returns the barcode check digit. The used algorithm + follows the GTIN specifications and can be used by all compatible + barcode nomenclature, like as EAN-8, EAN-12 (UPC-A) or EAN-13. + https://www.gs1.org/sites/default/files/docs/barcodes/GS1_General_Specifications.pdf + https://www.gs1.org/services/how-calculate-check-digit-manually + :param numeric_barcode: the barcode to verify/recompute the check digit + :type numeric_barcode: str + :return: the number corresponding to the right check digit + :rtype: int + """ + # Multiply value of each position by + # N1 N2 N3 N4 N5 N6 N7 N8 N9 N10 N11 N12 N13 N14 N15 N16 N17 N18 + # x3 X1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 x1 x3 CHECKSUM + oddsum = evensum = 0 + code = numeric_barcode[-2::-1] # Remove the check digit and reverse the barcode. + # The CHECKSUM digit is removed because it will be recomputed and it must not interfer with + # the computation. Also, the barcode is inverted, so the barcode length doesn't matter. + # Otherwise, the digits' group (even or odd) could be different according to the barcode length. + for i, digit in enumerate(code): + if i % 2 == 0: + evensum += int(digit) + else: + oddsum += int(digit) + total = evensum * 3 + oddsum + return (10 - total % 10) % 10 + + +def check_barcode_encoding(barcode, encoding): + """ Checks if the given barcode is correctly encoded. + :return: True if the barcode string is encoded with the provided encoding. + :rtype: bool + """ + encoding = encoding.lower() + if encoding == "any": + return True + barcode_sizes = { + 'ean8': 8, + 'ean13': 13, + 'gtin14': 14, + 'upca': 12, + 'sscc': 18, + } + barcode_size = barcode_sizes[encoding] + return (encoding != 'ean13' or barcode[0] != '0') \ + and len(barcode) == barcode_size \ + and re.match(r"^\d+$", barcode) \ + and get_barcode_check_digit(barcode) == int(barcode[-1])