From 1c0dec6d5813fa81a0fec8668ee13d8036cde5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Voet=20=28ryv=29?= Date: Mon, 20 Apr 2020 09:42:42 +0000 Subject: [PATCH] [ADD] barcodes_gs1_nomenclature In order to integrate and use GS1 nomenclature into `stock_barcode`, adds a new module with GS1 nomenclature and rules, and overrides parsing methods. task-1968113 closes odoo/odoo#65858 Signed-off-by: Arnold Moyaux Co-authored-by: ryv-odoo --- .../barcodes/static/src/js/barcode_parser.js | 59 ++-- addons/barcodes/views/barcodes_view.xml | 2 +- addons/barcodes_gs1_nomenclature/__init__.py | 2 + .../barcodes_gs1_nomenclature/__manifest__.py | 16 + .../data/barcodes_gs1_rules.xml | 208 +++++++++++++ .../models/__init__.py | 2 + .../models/barcode_nomenclature.py | 126 ++++++++ .../models/barcode_rule.py | 66 ++++ .../static/src/js/barcode_parser.js | 155 ++++++++++ .../src/js/tests/barcode_parser_tests.js | 282 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_barcodes_gs1_nomenclature.py | 130 ++++++++ .../views/barcodes_gs1_templates.xml | 14 + .../views/barcodes_view.xml | 60 ++++ .../static/src/js/barcode_reader.js | 21 +- 15 files changed, 1115 insertions(+), 29 deletions(-) create mode 100644 addons/barcodes_gs1_nomenclature/__init__.py create mode 100644 addons/barcodes_gs1_nomenclature/__manifest__.py create mode 100644 addons/barcodes_gs1_nomenclature/data/barcodes_gs1_rules.xml create mode 100644 addons/barcodes_gs1_nomenclature/models/__init__.py create mode 100644 addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py create mode 100644 addons/barcodes_gs1_nomenclature/models/barcode_rule.py create mode 100644 addons/barcodes_gs1_nomenclature/static/src/js/barcode_parser.js create mode 100644 addons/barcodes_gs1_nomenclature/static/src/js/tests/barcode_parser_tests.js create mode 100644 addons/barcodes_gs1_nomenclature/tests/__init__.py create mode 100644 addons/barcodes_gs1_nomenclature/tests/test_barcodes_gs1_nomenclature.py create mode 100644 addons/barcodes_gs1_nomenclature/views/barcodes_gs1_templates.xml create mode 100644 addons/barcodes_gs1_nomenclature/views/barcodes_view.xml diff --git a/addons/barcodes/static/src/js/barcode_parser.js b/addons/barcodes/static/src/js/barcode_parser.js index dcf6472d43a..89c9addc4bb 100644 --- a/addons/barcodes/static/src/js/barcode_parser.js +++ b/addons/barcodes/static/src/js/barcode_parser.js @@ -17,7 +17,6 @@ var BarcodeParser = Class.extend({ // necessary to parse the barcodes. The BarcodeParser is operational // only when those data have been loaded load: function(){ - var self = this; if (!this.nomenclature_id) { return; } @@ -25,23 +24,21 @@ var BarcodeParser = Class.extend({ return rpc.query({ model: 'barcode.nomenclature', method: 'read', - args: [[id], ['name','rule_ids','upc_ean_conv']], - }) - .then(function (nomenclatures){ - self.nomenclature = nomenclatures[0]; - + args: [[id], this._barcodeNomenclatureFields()], + }).then(nomenclatures => { + this.nomenclature = nomenclatures[0]; var args = [ - [['barcode_nomenclature_id', '=', self.nomenclature.id]], - ['name', 'sequence', 'type', 'encoding', 'pattern', 'alias'], + [['barcode_nomenclature_id', '=', this.nomenclature.id]], + this._barcodeRuleFields(), ]; return rpc.query({ model: 'barcode.rule', method: 'search_read', args: args, }); - }).then(function(rules){ + }).then(rules => { rules = rules.sort(function(a, b){ return a.sequence - b.sequence; }); - self.nomenclature.rules = rules; + this.nomenclature.rules = rules; }); }, @@ -187,15 +184,16 @@ var BarcodeParser = Class.extend({ return match; }, - // attempts to interpret a barcode (string encoding a barcode Code-128) - // it will return an object containing various information about the barcode. - // most importantly : - // - code : the barcode - // - type : the type of the barcode (e.g. alias, unit product, weighted product...) - // - // - value : if the barcode encodes a numerical value, it will be put there - // - base_code : the barcode with all the encoding parts set to zero; the one put on - // the product in the backend + /** + * Attempts to interpret a barcode (string encoding a barcode Code-128) + * + * @param {string} barcode + * @returns {Object} the returned object containing informations about the barcode: + * - code: the barcode + * - type: the type of the barcode (e.g. alias, unit product, weighted product...) + * - value: if the barcode encodes a numerical value, it will be put there + * - base_code: the barcode with all the encoding parts set to zero; the one put on the product in the backend + */ parse_barcode: function(barcode){ var parsed_result = { encoding: '', @@ -253,6 +251,29 @@ var BarcodeParser = Class.extend({ } return parsed_result; }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _barcodeNomenclatureFields: function () { + return [ + 'name', + 'rule_ids', + 'upc_ean_conv', + ]; + }, + + _barcodeRuleFields: function () { + return [ + 'name', + 'sequence', + 'type', + 'encoding', + 'pattern', + 'alias', + ]; + }, }); return BarcodeParser; diff --git a/addons/barcodes/views/barcodes_view.xml b/addons/barcodes/views/barcodes_view.xml index e005a38fcba..000df9fd379 100644 --- a/addons/barcodes/views/barcodes_view.xml +++ b/addons/barcodes/views/barcodes_view.xml @@ -7,7 +7,7 @@
- + diff --git a/addons/barcodes_gs1_nomenclature/__init__.py b/addons/barcodes_gs1_nomenclature/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/addons/barcodes_gs1_nomenclature/__manifest__.py b/addons/barcodes_gs1_nomenclature/__manifest__.py new file mode 100644 index 00000000000..8ea27725ec7 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/__manifest__.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Barcode - GS1 Nomenclature', + 'version': '1.0', + 'category': 'Hidden', + 'summary': 'Parse barcodes according to the GS1-128 specifications', + 'depends': ['barcodes', 'uom'], + 'data': [ + 'data/barcodes_gs1_rules.xml', + 'views/barcodes_gs1_templates.xml', + 'views/barcodes_view.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/addons/barcodes_gs1_nomenclature/data/barcodes_gs1_rules.xml b/addons/barcodes_gs1_nomenclature/data/barcodes_gs1_rules.xml new file mode 100644 index 00000000000..50ce7d392bb --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/data/barcodes_gs1_rules.xml @@ -0,0 +1,208 @@ + + + + + Default GS1 Nomenclature + true + + + + + Serial Shipping Container Code + + 100 + gs1-128 + (00)(\d{18}) + package + identifier + + + + Global Trade Item Number (GTIN) + + 101 + gs1-128 + (01)(\d{14}) + product + identifier + + + + GTIN of contained trade items + + 101 + gs1-128 + (02)(\d{14}) + product + identifier + + + + Ship to / Deliver to Global Location Number (GLN) + + 102 + gs1-128 + (410)(\d{13}) + location_dest + identifier + + + + Ship for / Deliver for - Forward to Global Location Number (GLN) + + 103 + gs1-128 + (413)(\d{13}) + location_dest + identifier + + + + Identification of a physical location - Global Location Number (GLN) + + 104 + gs1-128 + (414)(\d{13}) + location + identifier + + + + + Batch or lot number + + 105 + gs1-128 + (10)([!"%-/0-9:-?A-Z_a-z]{0,20}) + lot + alpha + + + + Serial number + + 106 + gs1-128 + (21)([!"%-/0-9:-?A-Z_a-z]{0,20}) + lot + alpha + + + + + Packaging date (YYMMDD) + + 107 + gs1-128 + (13)(\d{6}) + packaging_date + date + + + + Best before date (YYMMDD) + + 108 + gs1-128 + (15)(\d{6}) + use_date + date + + + + Expiration date (YYMMDD) + + 109 + gs1-128 + (16)(\d{6}) + expiration_date + date + + + + + Variable count of items (variable measure trade item) + + 110 + gs1-128 + (30)(\d{0,8}) + + qty_done + measure + False + + + + + Count of trade items or trade item pieces contained in a logistic unit + + 111 + gs1-128 + (37)(\d{0,8}) + + qty_done + measure + False + + + + Net weight, kilograms (variable measure trade item) + + 112 + gs1-128 + (310[0-5])(\d{6}) + + qty_done + measure + True + + + + Length or first dimension, metres (variable measure trade item) + + 113 + gs1-128 + (311[0-5])(\d{6}) + + qty_done + measure + True + + + + Net volume, litres (variable measure trade item) + + 114 + gs1-128 + (315[0-5])(\d{6}) + + qty_done + measure + True + + + + Length or first dimension, inches (variable measure trade item) + + 115 + gs1-128 + (321[0-5])(\d{6}) + + qty_done + measure + True + + + + Net weight (or volume), ounces (variable measure trade item) + + 116 + gs1-128 + (357[0-5])(\d{6}) + + qty_done + measure + True + + + diff --git a/addons/barcodes_gs1_nomenclature/models/__init__.py b/addons/barcodes_gs1_nomenclature/models/__init__.py new file mode 100644 index 00000000000..01218f2351b --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/models/__init__.py @@ -0,0 +1,2 @@ +from . import barcode_nomenclature +from . import barcode_rule diff --git a/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py b/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py new file mode 100644 index 00000000000..ed971900332 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/models/barcode_nomenclature.py @@ -0,0 +1,126 @@ +import re +import datetime +import calendar + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +FNC1_CHAR = '\x1D' + + +class BarcodeNomenclature(models.Model): + _inherit = 'barcode.nomenclature' + + is_gs1_nomenclature = fields.Boolean( + string="Is GS1 Nomenclature", + help="This Nomenclature use the GS1 specification, only GS1-128 encoding rules is accepted is this kind of nomenclature.") + gs1_separator_fnc1 = fields.Char( + string="FNC1 Seperator", trim=False, + help="Alternative regex delimiter for the FNC1 (by default, if not set, it is ASCII 29 char). The seperator must not match the begin/end of any related rules pattern.") + + @api.constrains('gs1_separator_fnc1') + def _check_pattern(self): + for nom in self: + if nom.is_gs1_nomenclature and nom.gs1_separator_fnc1: + try: + re.compile("(?:%s)?" % nom.gs1_separator_fnc1) + except re.error as error: + raise ValidationError(_("The FNC1 Seperator Alternative is not a valid Regex: ") + str(error)) + + @api.model + def gs1_date_to_date(self, gs1_date): + """ Converts a GS1 date into a datetime.date. + + :param gs1_date: A year formated as yymmdd + :type gs1_date: str + :return: converted date + :rtype: datetime.date + """ + # See 7.12 Determination of century in dates: + # https://www.gs1.org/sites/default/files/docs/barcodes/GS1_General_Specifications.pdf + now = datetime.date.today() + current_century = now.year // 100 + substract_year = int(gs1_date[0:2]) - (now.year % 100) + century = (51 <= substract_year <= 99 and current_century - 1) or\ + (-99 <= substract_year <= -50 and current_century + 1) or\ + current_century + year = century * 100 + int(gs1_date[0:2]) + + if gs1_date[-2:] == '00': # Day is not mandatory, when not set -> last day of the month + date = datetime.datetime.strptime(str(year) + gs1_date[2:4], '%Y%m') + date = date.replace(day=calendar.monthrange(year, int(gs1_date[2:4]))[1]) + else: + date = datetime.datetime.strptime(str(year) + gs1_date[2:], '%Y%m%d') + return date.date() + + def parse_gs1_rule_pattern(self, match, rule): + result = { + 'rule': rule, + 'ai': match.group(1), + 'string_value': match.group(2), + } + if rule.gs1_content_type == 'measure': + try: + decimal_position = 0 # Decimal position begins at the end, 0 means no decimal. + if rule.gs1_decimal_usage: + decimal_position = int(match.group(1)[-1]) + if decimal_position > 0: + result['value'] = float(match.group(2)[:-decimal_position] + "." + match.group(2)[-decimal_position:]) + else: + result['value'] = int(match.group(2)) + except Exception: + raise ValidationError(_( + "There is something wrong with the barcode rule \"%s\" pattern.\n" + "If this rule uses decimal, check it can't get sometime else than a digit as last char for the Application Identifier.\n" + "Check also the possible matched values can only be digits, otherwise the value can't be casted as a measure.", + 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))): + return None + result['value'] = match.group(2) + elif rule.gs1_content_type == 'date': + if len(match.group(2)) != 6: + return None + result['value'] = self.gs1_date_to_date(match.group(2)) + else: # when gs1_content_type == 'alpha': + result['value'] = match.group(2) + return result + + def gs1_decompose_extanded(self, barcode): + """Try to decompose the gs1 extanded barcode into several unit of information using gs1 rules. + + Return a ordered list of dict + """ + self.ensure_one() + separator_group = FNC1_CHAR + "?" + if self.gs1_separator_fnc1: + separator_group = "(?:%s)?" % self.gs1_separator_fnc1 + results = [] + gs1_rules = self.rule_ids.filtered(lambda r: r.encoding == 'gs1-128') + + def find_next_rule(remaining_barcode): + for rule in gs1_rules: + match = re.search("^" + rule.pattern + separator_group, remaining_barcode) + # If match and contains 2 groups at minimun, the first one need to be the AI and the second the value + # We can't use regex nammed group because in JS, it is not the same regex syntax (and not compatible in all browser) + if match and len(match.groups()) >= 2: + res = self.parse_gs1_rule_pattern(match, rule) + if res: + return res, remaining_barcode[match.end():] + return None + + while len(barcode) > 0: + res_bar = find_next_rule(barcode) + # Cannot continue -> Fail to decompose gs1 and return + if not res_bar or res_bar[1] == barcode: + return None + barcode = res_bar[1] + results.append(res_bar[0]) + + return results + + def parse_barcode(self, barcode): + if self.is_gs1_nomenclature: + return self.gs1_decompose_extanded(barcode) + return super().parse_barcode(barcode) diff --git a/addons/barcodes_gs1_nomenclature/models/barcode_rule.py b/addons/barcodes_gs1_nomenclature/models/barcode_rule.py new file mode 100644 index 00000000000..d1875e4abd4 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/models/barcode_rule.py @@ -0,0 +1,66 @@ +import re + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class BarcodeRule(models.Model): + _inherit = 'barcode.rule' + + def _default_encoding(self): + return 'gs1-128' if self.env.context.get('is_gs1') else 'any' + + encoding = fields.Selection( + selection_add=[('gs1-128', 'GS1-128')], default=_default_encoding, + ondelete={'gs1-128': 'set default'}) + type = fields.Selection( + selection_add=[ + ('qty_done', 'Quantity'), + ('location', 'Location'), + ('location_dest', 'Destination location'), + ('lot', 'Lot number'), + ('package', 'Package'), + ('use_date', 'Best before Date'), + ('expiration_date', 'Expiration Date'), + ('packaging_date', 'Packaging Date'), + ], ondelete={ + 'qty_done': 'set default', + 'location': 'set default', + 'location_dest': 'set default', + 'lot': 'set default', + 'package': 'set default', + 'use_date': 'set default', + 'expiration_date': 'set default', + 'packaging_date': 'set default', + }) + is_gs1_nomenclature = fields.Boolean(related="barcode_nomenclature_id.is_gs1_nomenclature") + gs1_content_type = fields.Selection([ + ('date', 'Date'), + ('measure', 'Measure'), + ('identifier', 'Numeric Identifier'), + ('alpha', 'Alpha-Numeric Name'), + ], string="GS1 Content Type", help="The GS1 content type defines to what this rule data will be handle:\ + * Date: the barcode will be converted into a Odoo datetime;\ + * Measure: the barcode's value is related to a specific UoM;\ + * Numeric Identifier: fixed length barcode following a specific encoding;\ + * Alpha-Numeric Name: variable length barcode.") + gs1_decimal_usage = fields.Boolean('Decimal', help="If True, use the last digit of AI to dertermine where the first decimal is") + associated_uom_id = fields.Many2one('uom.uom') + + @api.constrains('pattern') + def _check_pattern(self): + gs1_rules = self.filtered(lambda rule: rule.encoding == 'gs1-128') + for rule in gs1_rules: + try: + re.compile(rule.pattern) + except re.error as error: + raise ValidationError(_("The rule pattern \"%s\" is not a valid Regex: ", rule.name) + str(error)) + groups = re.findall(r'\([^)]*\)', rule.pattern) + if len(groups) != 2: + raise ValidationError(_( + "The rule pattern \"%s\" is not valid, it needs two groups:" + "\n\t- A first one for the Application Identifier (usually 2 to 4 digits);" + "\n\t- A second one to catch the value.", + rule.name)) + + super(BarcodeRule, (self - gs1_rules))._check_pattern() diff --git a/addons/barcodes_gs1_nomenclature/static/src/js/barcode_parser.js b/addons/barcodes_gs1_nomenclature/static/src/js/barcode_parser.js new file mode 100644 index 00000000000..1de5b782326 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/static/src/js/barcode_parser.js @@ -0,0 +1,155 @@ +odoo.define('barcodes_gs1_nomenclature/static/src/js/barcode_parser.js', function (require) { +"use strict"; + +const BarcodeParser = require('barcodes.BarcodeParser'); +const FNC1_CHAR = String.fromCharCode(29); +const {_lt} = require('web.core'); + +BarcodeParser.include({ + /** + * Convert YYMMDD GS1 date into a Date object + * + * @param {string} gs1Date YYMMDD string date, length must be 6 + * @returns {Date} + */ + gs1_date_to_date: function(gs1Date) { + // See 7.12 Determination of century in dates: + // https://www.gs1.org/sites/default/files/docs/barcodes/GS1_General_Specifications.pdfDetermination of century + const now = new Date(); + const substractYear = parseInt(gs1Date.slice(0, 2)) - (now.getFullYear() % 100); + let century = Math.floor(now.getFullYear() / 100); + if (51 <= substractYear && substractYear <= 99) { + century--; + } else if (-99 <= substractYear && substractYear <= -50) { + century++; + } + const year = century * 100 + parseInt(gs1Date.slice(0, 2)); + const date = new Date(year, parseInt(gs1Date.slice(2, 4) - 1)); + + if (gs1Date.slice(-2) === '00'){ + // Day is not mandatory, when not set -> last day of the month + date.setDate(new Date(year, parseInt(gs1Date.slice(2, 4)), 0).getDate()); + } else { + date.setDate(parseInt(gs1Date.slice(-2))); + } + return date; + }, + + /** + * Perform interpretation of the barcode value depending ot the rule.gs1_content_type + * + * @param {Array} match Result of a regex match with atmost 2 groups (ia and value) + * @param {Object} rule Matched Barcode Rule + * @returns {Object|null} + */ + parse_gs1_rule_pattern: function(match, rule) { + const result = { + rule: Object.assign({}, rule), + ai: match[1], + string_value: match[2] + }; + if (rule.gs1_content_type === 'measure'){ + let decimalPosition = 0; // Decimal position begin at the end, 0 means no decimal + if (rule.gs1_decimal_usage){ + decimalPosition = parseInt(match[1][match[1].length - 1]); + } + if (decimalPosition > 0) { + const integral = match[2].slice(0, match[2].length - decimalPosition); + const decimal = match[2].slice(match[2].length - decimalPosition); + result.value = parseFloat( integral + "." + decimal); + } else { + result.value = parseInt(match[2]); + } + } else if (rule.gs1_content_type === 'identifier'){ + if (parseInt(match[2][match[2].length - 1]) !== this.get_barcode_check_digit("0".repeat(18 - match[2].length) + match[2])){ + throw new Error(_lt("Invalid barcode: the check digit is incorrect")); + // return {error: _lt("Invalid barcode: the check digit is incorrect")}; + } + result.value = match[2]; + } else if (rule.gs1_content_type === 'date'){ + if (match[2].length !== 6){ + throw new Error(_lt("Invalid barcode: can't be formated as date")); + // return {error: _lt("Invalid barcode: can't be formated as date")}; + } + result.value = this.gs1_date_to_date(match[2]); + } else { + result.value = match[2]; + } + return result; + }, + + /** + * Try to decompose the gs1 extanded barcode into several unit of information using gs1 rules. + * + * @param {string} barcode + * @returns {Array} Array of object + */ + gs1_decompose_extanded: function(barcode) { + const results = []; + const rules = this.nomenclature.rules.filter(rule => rule.encoding === 'gs1-128'); + let separatorReg = FNC1_CHAR + "?"; + if (this.nomenclature.gs1_separator_fnc1 && this.nomenclature.gs1_separator_fnc1.trim()){ + separatorReg = `(?:${this.nomenclature.gs1_separator_fnc1})?`; + } + + while (barcode.length > 0) { + const barcodeLength = barcode.length; + for (const rule of rules) { + const match = barcode.match("^" + rule.pattern + separatorReg); + if (match && match.length >= 3) { + const res = this.parse_gs1_rule_pattern(match, rule); + if (res) { + barcode = barcode.slice(match.index + match[0].length); + results.push(res); + if (barcode.length === 0) { + return results; // Barcode completly parsed, no need to keep looping. + } + } else { + throw new Error(_lt("This barcode can't be parsed by any barcode rules.")); + } + } + } + if (barcodeLength === barcode.length) { + throw new Error(_lt("This barcode can't be partially or fully parsed.")); + } + } + + return results; + }, + + /** + * @override + * @returns {Object|Array|null} If nomenclature is GS1, returns an array or null + */ + parse_barcode: function(barcode){ + if (this.nomenclature && this.nomenclature.is_gs1_nomenclature) { + return this.gs1_decompose_extanded(barcode); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _barcodeNomenclatureFields: function () { + const fieldNames = this._super(...arguments); + fieldNames.push('is_gs1_nomenclature', 'gs1_separator_fnc1'); + return fieldNames; + }, + + /** + * @override + */ + _barcodeRuleFields: function () { + const fieldNames = this._super(...arguments); + fieldNames.push('gs1_content_type', 'gs1_decimal_usage', 'associated_uom_id'); + return fieldNames; + }, +}); + +return BarcodeParser; +}); diff --git a/addons/barcodes_gs1_nomenclature/static/src/js/tests/barcode_parser_tests.js b/addons/barcodes_gs1_nomenclature/static/src/js/tests/barcode_parser_tests.js new file mode 100644 index 00000000000..00743587df6 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/static/src/js/tests/barcode_parser_tests.js @@ -0,0 +1,282 @@ +odoo.define('barcodes_gs1_nomenclature/static/src/js/tests/barcode_parser_tests.js', function (require) { +"use strict"; + +const BarcodeParser = require('barcodes.BarcodeParser'); + + +QUnit.module('Barcodes', {}, function () { +QUnit.module('Barcode GS1 Parser', { + beforeEach: function () { + this.data = { + 'barcode.nomenclature': { + fields: { + name: {type: 'char', string: 'Barcode Nomenclature'}, + rule_ids: {type: 'one2many', relation: 'barcode.rule'}, + upc_ean_conv: {type: 'selection', string: 'UPC/EAN Conversion'}, + is_gs1_nomenclature: {type: 'boolean', string: 'Is GS1 Nomenclature'}, + gs1_separator_fnc1: {type: 'char', string: 'FNC1 Seperator Alternative'} + }, + records: [ + {id: 1, name: "normal", upc_ean_conv: "always", is_gs1_nomenclature: false, gs1_separator_fnc1: ''}, + {id: 2, name: "GS1", upc_ean_conv: "always", is_gs1_nomenclature: true, gs1_separator_fnc1: ''}, + ], + }, + 'barcode.rule': { + fields: { + name: {type: 'char', string: 'Barcode Nomenclature'}, + barcode_nomenclature_id: {type: 'many2one', relation: 'barcode.nomenclature'}, + sequence: {type: 'integer', string: 'Sequence'}, + encoding: {type: 'selection', string: 'Encoding'}, + type: {type: 'selection', string: 'Type'}, + pattern: {type: 'Char', string: 'Pattern'}, + alias: {type: 'Char', string: 'Alias'}, + }, + records: [ + {id: 1, name: "Product Barcodes", barcode_nomenclature_id: 1, sequence: 90, type: 'product', encoding: 'any', pattern: ".*"}, + { + id: 2, + name: "Global Trade Item Number (GTIN)", + barcode_nomenclature_id: 2, + sequence: 100, + encoding: "gs1-128", + pattern: "(01)(\\d{14})", + type: "product", + gs1_content_type: "identifier", + gs1_decimal_usage: false + }, { + id: 3, + name: "GTIN of contained trade items", + barcode_nomenclature_id: 2, + sequence: 101, + encoding: "gs1-128", + pattern: "(02)(\\d{14})", + type: "product", + gs1_content_type: "identifier", + gs1_decimal_usage: false + }, { + id: 4, + name: "Batch or lot number", + barcode_nomenclature_id: 2, + sequence: 102, + encoding: "gs1-128", + pattern: "(10)([!\"%/0-9:?A-Za-z]{0,20})", + type: "product", + gs1_content_type: "alpha", + gs1_decimal_usage: false + }, { + id: 5, + name: "Serial number", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(21)([!\"%/0-9:?A-Za-z]{0,20})", + type: "product", + gs1_content_type: "alpha", + gs1_decimal_usage: false + }, { + id: 6, + name: "Packaging date (YYMMDD)", + barcode_nomenclature_id: 2, + sequence: 103, + encoding: "gs1-128", + pattern: "(13)(\\d{6})", + type: "packaging_date", + gs1_content_type: "date", + gs1_decimal_usage: false + }, { + id: 7, + name: "Best before date (YYMMDD)", + barcode_nomenclature_id: 2, + sequence: 104, + encoding: "gs1-128", + pattern: "(15)(\\d{6})", + type: "use_date", + gs1_content_type: "date", + gs1_decimal_usage: false + }, { + id: 8, + name: "Expiration date (YYMMDD)", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(16)(\\d{6})", + type: "expiration_date", + gs1_content_type: "date", + gs1_decimal_usage: false + }, { + id: 9, + name: "Variable count of items (variabl", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(30)(\\d{0,8})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: false + }, { + id: 10, + name: "Count of trade items or trade it", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(37)(\\d{0,8})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: false + }, { + id: 11, + name: "Net weight, kilograms (variable", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(310[0-5])(\\d{6})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: true + }, { + id: 12, + name: "Length or first dimension, metre", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(311[0-5])(\\d{6})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: true + }, { + id: 13, + name: "Net volume, litres (variable mea", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(315[0-5])(\\d{6})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: true + }, { + id: 14, + name: "Length or first dimension, inche", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(321[0-5])(\\d{6})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: true + }, { + id: 15, + name: "Net weight (or volume), ounces (", + barcode_nomenclature_id: 2, + sequence: 105, + encoding: "gs1-128", + pattern: "(357[0-5])(\\d{6})", + type: "product", + gs1_content_type: "measure", + gs1_decimal_usage: true + } + ], + } + }; + } +}, function () { + QUnit.test('Test gs1 date barcode', async function (assert) { + assert.expect(9); + const barcodeNomenclature = new BarcodeParser({'nomenclature_id': false}); + await barcodeNomenclature.loaded; + + // 20/10/2015 -> 151020 + let dateGS1 = "151020"; + let date = barcodeNomenclature.gs1_date_to_date(dateGS1); + assert.equal(date.getDate(), 20); + assert.equal(date.getMonth() + 1, 10); + assert.equal(date.getFullYear(), 2015); + + // XX/03/2052 -> 520300 -> (if day no set take last day of the month -> 31) + dateGS1 = "520300"; + date = barcodeNomenclature.gs1_date_to_date(dateGS1); + assert.equal(date.getDate(), 31); + assert.equal(date.getMonth() + 1, 3); + assert.equal(date.getFullYear(), 2052); + + // XX/02/2020 -> 520200 -> (if day no set take last day of the month -> 29) + dateGS1 = "200200"; + date = barcodeNomenclature.gs1_date_to_date(dateGS1); + assert.equal(date.getDate(), 29); + assert.equal(date.getMonth() + 1, 2); + assert.equal(date.getFullYear(), 2020); + }); + + QUnit.test('Test gs1 decompose extanded', async function (assert) { + assert.expect(19); + const barcodeNomenclature = new BarcodeParser({'nomenclature_id': false}); + await barcodeNomenclature.loaded; + + barcodeNomenclature.nomenclature = this.data['barcode.nomenclature'].records[0]; + barcodeNomenclature.nomenclature.rules = this.data['barcode.rule'].records; + + // (01)94019097685457(10)33650100138(3102)002004(15)131018 + let code128 = "01940190976854571033650100138\x1D310200200415131018"; + let res = barcodeNomenclature.gs1_decompose_extanded(code128); + assert.equal(res.length, 4); + assert.equal(res[0].ai, "01"); + + assert.equal(res[1].ai, "10"); + + assert.equal(res[2].ai, "3102"); + assert.equal(res[2].value, 20.04); + + assert.equal(res[3].ai, "15"); + assert.equal(typeof res[3].value.getFullYear, 'function'); + assert.equal(res[3].value.getFullYear(), 2013); + assert.equal(res[3].value.getDate(), 18); + assert.equal(res[3].value.getMonth() + 1, 10); + + // (01)94019097685457(13)170119(30)17 + code128 = "0194019097685457131701193017"; + res = barcodeNomenclature.gs1_decompose_extanded(code128); + assert.equal(res.length, 3); + assert.equal(res[0].ai, "01"); + + assert.equal(res[1].ai, "13"); + assert.equal(typeof res[1].value.getFullYear, 'function'); + assert.equal(res[1].value.getFullYear(), 2017); + assert.equal(res[1].value.getDate(), 19); + assert.equal(res[1].value.getMonth() + 1, 1); + + assert.equal(res[2].ai, "30"); + assert.equal(res[2].value, 17); + + }); + + QUnit.test('Test Alternative GS1 Separator (fnc1)', async function (assert) { + assert.expect(6); + let barcodeNomenclature = new BarcodeParser({'nomenclature_id': 2}); + await barcodeNomenclature.loaded; + + barcodeNomenclature.nomenclature = this.data['barcode.nomenclature'].records[0]; + barcodeNomenclature.nomenclature.gs1_separator_fnc1 = "#"; + barcodeNomenclature.nomenclature.rules = this.data['barcode.rule'].records; + + // (21)12345(15)090101(16)100101 + let code128 = "2112345\x1D1509010116100101"; + let res; + try { + res = barcodeNomenclature.gs1_decompose_extanded(code128); + } catch (error) { + assert.ok( + error instanceof Error, + "Default separator shouldn't work" + ); + } + + code128 = "2112345#1509010116100101"; + res = barcodeNomenclature.gs1_decompose_extanded(code128); + assert.equal(res.length, 3); + assert.equal(res[0].ai, "21"); + assert.equal(res[0].value, "12345"); + assert.equal(res[1].ai, "15"); + assert.equal(res[2].ai, "16"); + }); +}); +}); +}); diff --git a/addons/barcodes_gs1_nomenclature/tests/__init__.py b/addons/barcodes_gs1_nomenclature/tests/__init__.py new file mode 100644 index 00000000000..cdab5c79a73 --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/tests/__init__.py @@ -0,0 +1 @@ +from . import test_barcodes_gs1_nomenclature diff --git a/addons/barcodes_gs1_nomenclature/tests/test_barcodes_gs1_nomenclature.py b/addons/barcodes_gs1_nomenclature/tests/test_barcodes_gs1_nomenclature.py new file mode 100644 index 00000000000..93c74fc571a --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/tests/test_barcodes_gs1_nomenclature.py @@ -0,0 +1,130 @@ +from odoo.addons.barcodes.tests.test_barcode_nomenclature import TestBarcodeNomenclature +from odoo.exceptions import ValidationError + + +class TestBarcodeGS1Nomenclature(TestBarcodeNomenclature): + def test_gs1_date_to_date(self): + barcode_nomenclature = self.env['barcode.nomenclature'] + # 20/10/2015 -> 151020 + date_gs1 = "151020" + date = barcode_nomenclature.gs1_date_to_date(date_gs1) + self.assertEqual(date.day, 20) + self.assertEqual(date.month, 10) + self.assertEqual(date.year, 2015) + + # XX/03/2052 -> 520300 -> (if day no set take last day of the month -> 31) + date_gs1 = "520300" + date = barcode_nomenclature.gs1_date_to_date(date_gs1) + self.assertEqual(date.day, 31) + self.assertEqual(date.month, 3) + self.assertEqual(date.year, 2052) + + # XX/02/2020 -> 520200 -> (if day no set take last day of the month -> 29) + date_gs1 = "200200" + date = barcode_nomenclature.gs1_date_to_date(date_gs1) + self.assertEqual(date.day, 29) + self.assertEqual(date.month, 2) + self.assertEqual(date.year, 2020) + + def test_gs1_extanded_barcode_1(self): + barcode_nomenclature = self.env['barcode.nomenclature'].browse(self.ref('barcodes_gs1_nomenclature.default_gs1_nomenclature')) + # (01)94019097685457(10)33650100138(3102)002004(15)131018 + code128 = "01940190976854571033650100138\x1D310200200415131018" + res = barcode_nomenclature.gs1_decompose_extanded(code128) + self.assertEqual(len(res), 4) + self.assertEqual(res[0]["ai"], "01") + + self.assertEqual(res[1]["ai"], "10") + + self.assertEqual(res[2]["ai"], "3102") + self.assertEqual(res[2]["value"], 20.04) + + self.assertEqual(res[3]["ai"], "15") + self.assertEqual(res[3]["value"].year, 2013) + self.assertEqual(res[3]["value"].day, 18) + self.assertEqual(res[3]["value"].month, 10) + + # (01)94019097685457(13)170119(30)17 + code128 = "0194019097685457131701193017" + res = barcode_nomenclature.gs1_decompose_extanded(code128) + self.assertEqual(len(res), 3) + self.assertEqual(res[0]["ai"], "01") + + self.assertEqual(res[1]["ai"], "13") + self.assertEqual(res[1]["value"].year, 2017) + self.assertEqual(res[1]["value"].day, 19) + self.assertEqual(res[1]["value"].month, 1) + + self.assertEqual(res[2]["ai"], "30") + self.assertEqual(res[2]["value"], 17) + + def test_gs1_extanded_barcode_2_decimal(self): + """ Parses multiples barcode with (or without) a decimal value and + checks for each of them the value is correctly parsed. + """ + # Configures a barcode GS1 nomenclature... + barcode_nomenclature = self.env['barcode.nomenclature'].create({ + 'name': "GS1 Nomenclature - Test", + 'is_gs1_nomenclature': True, + }) + default_barcode_rule_vals = { + 'default_encoding': 'gs1-128', + 'default_barcode_nomenclature_id': barcode_nomenclature.id, + 'default_type': 'qty_done', + 'default_gs1_content_type': 'measure', + } + # Creates a rule who don't take any decimal. + barcode_rule = self.env['barcode.rule'].with_context(default_barcode_rule_vals).create({ + 'name': "GS1 Rule Test - No Decimal", + 'pattern': r'(300)(\d{5,8})', + 'gs1_decimal_usage': False, + }) + # Creates a rule to take the four last digit as decimals. + barcode_rule_decimal = self.env['barcode.rule'].with_context(default_barcode_rule_vals).create({ + 'name': "GS1 Rule Test - Four Decimals", + 'pattern': r'(304)(\d{5,8})', + 'gs1_decimal_usage': True, + }) + + # Checks barcodes without decimals. + res = barcode_nomenclature.gs1_decompose_extanded('30000000') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '00000') + self.assertEqual(res[0]['value'], 0) + + res = barcode_nomenclature.gs1_decompose_extanded('30018789') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '18789') + self.assertEqual(res[0]['value'], 18789) + + res = barcode_nomenclature.gs1_decompose_extanded('3001515000') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '1515000') + self.assertEqual(res[0]['value'], 1515000) + + # Checks barcodes with decimals. + res = barcode_nomenclature.gs1_decompose_extanded('30400000') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '00000') + self.assertEqual(res[0]['value'], 0.0) + + res = barcode_nomenclature.gs1_decompose_extanded('30418789') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '18789') + self.assertEqual(res[0]['value'], 1.8789) + + res = barcode_nomenclature.gs1_decompose_extanded('3041515000') + self.assertEqual(len(res), 1) + self.assertEqual(res[0]['string_value'], '1515000') + self.assertEqual(res[0]['value'], 151.5) + + # Checks wrong configs will raise an exception. + barcode_rule_decimal.pattern = r'()(\d{0,4})' + # Barcode rule uses decimals but AI doesn't precise what is the decimal position. + with self.assertRaises(ValidationError): + res = barcode_nomenclature.gs1_decompose_extanded('1234') + + # The pattern is too permissive and can catch something which can't be casted as measurement + barcode_rule.pattern = r'(300)(.*)' + with self.assertRaises(ValidationError): + res = barcode_nomenclature.gs1_decompose_extanded('300bilou4000') diff --git a/addons/barcodes_gs1_nomenclature/views/barcodes_gs1_templates.xml b/addons/barcodes_gs1_nomenclature/views/barcodes_gs1_templates.xml new file mode 100644 index 00000000000..47c6567e12d --- /dev/null +++ b/addons/barcodes_gs1_nomenclature/views/barcodes_gs1_templates.xml @@ -0,0 +1,14 @@ + + + + +