From 320025ee630acadd4a5cbd32f425cddee4f81c1e Mon Sep 17 00:00:00 2001 From: Adrien Widart Date: Wed, 13 Jul 2022 16:00:21 +0000 Subject: [PATCH] [FIX] base: convert barcode type if incorrect encoding To reproduce the issue: (Need stock_barcode) 1. Create a product: - Barcode: 1234567890123 2. Print the label 3. Try to scan the barcode and check the value read. Error: The value is 1234567890128, the last digit is incorrect (8 instead of 3) When printing a barcode, we use the library 'report-lab' to generate a barcode image from a value and a barcode type. In case of EAN-13, if the value contains a non-digit character, it will raise an error. We then catch the error and retry to generate the barcode according to the barcode type Code128: https://github.com/odoo/odoo/blob/87698d90f02bfe93c6e643f4876a5ccd74788eff/odoo/addons/base/models/ir_actions_report.py#L569-L575 However, if the value contains only digits, the method will use the 12 first digits: https://github.com/mattjmorrison/ReportLab/blob/dade0f303cb6fcdbe535c4cc92e6102c2417b699/src/reportlab/graphics/barcode/eanbc.py#L187-L188 and will then add the last one, the check digit, which is computed by the library. This explains why, in the above use case, the barcode value returned by the scanner is not the same than the expected one. Note: Similar behavior with type EAN-8 OPW-2902150 closes odoo/odoo#97052 X-original-commit: a2c7470f8fd46b2520f7b8f0750c63216273ce96 Related: odoo/enterprise#30160 Signed-off-by: Steve Van Essche Signed-off-by: Adrien Widart --- .../barcodes/models/barcode_nomenclature.py | 58 ++----------------- .../tests/test_barcode_nomenclature.py | 7 --- .../models/barcode_nomenclature.py | 3 +- addons/stock/models/product.py | 4 +- addons/stock/models/stock_quant.py | 4 +- odoo/addons/base/models/ir_actions_report.py | 10 +++- odoo/addons/base/tests/__init__.py | 1 + odoo/addons/base/tests/test_barcode.py | 27 +++++++++ odoo/tools/__init__.py | 1 + odoo/tools/barcode.py | 54 +++++++++++++++++ 10 files changed, 103 insertions(+), 66 deletions(-) create mode 100644 odoo/addons/base/tests/test_barcode.py create mode 100644 odoo/tools/barcode.py 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])