diff --git a/barcodes/__init__.py b/barcodes/__init__.py new file mode 100644 index 00000000..42a4cc55 --- /dev/null +++ b/barcodes/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +import barcodes + +import models diff --git a/barcodes/__openerp__.py b/barcodes/__openerp__.py new file mode 100644 index 00000000..15c1cc79 --- /dev/null +++ b/barcodes/__openerp__.py @@ -0,0 +1,36 @@ +{ + 'name': 'Barcodes', + 'version': '2.0', + 'category': 'Extra Tools', + 'summary': 'Barcodes Scanning and Parsing', + 'description': """ +This module adds support for barcode scanning and parsing. + +Scanning +-------- +Use a USB scanner (that mimics keyboard inputs) in order to work with barcodes in Odoo. +The scanner must be configured to use no prefix and a carriage return or tab as suffix. +The delay between each character input must be less than or equal to 50 milliseconds. +Most barcode scanners will work out of the box. +However, make sure the scanner uses the same keyboard layout as the device it's plugged in. +Either by setting the device's keyboard layout to US QWERTY (default value for most readers) +or by changing the scanner's keyboard layout (check the manual). + +Parsing +------- +The barcodes are interpreted using the rules defined by a nomenclature. +It provides the following features: +- Patterns to identify barcodes containing a numerical value (e.g. weight, price) +- Definition of barcode aliases that allow to identify the same product with different barcodes +- Support for encodings EAN-13, EAN-8 and UPC-A +""", + 'depends': ['web'], + 'data': [ + 'data/barcodes_data.xml', + 'barcodes_view.xml', + 'security/ir.model.access.csv', + 'views/templates.xml', + ], + 'installable': True, + 'auto_install': False, +} diff --git a/barcodes/barcodes.py b/barcodes/barcodes.py new file mode 100644 index 00000000..91fd2a31 --- /dev/null +++ b/barcodes/barcodes.py @@ -0,0 +1,225 @@ +import logging +import re + +import openerp +from openerp import tools, models, fields, api +from openerp.osv import fields, osv +from openerp.tools.translate import _ +from openerp.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +UPC_EAN_CONVERSIONS = [ + ('none','Never'), + ('ean2upc','EAN-13 to UPC-A'), + ('upc2ean','UPC-A to EAN-13'), + ('always','Always'), +] + +class barcode_nomenclature(osv.osv): + _name = 'barcode.nomenclature' + _columns = { + 'name': fields.char('Nomenclature Name', size=32, required=True, help='An internal identification of the barcode nomenclature'), + 'rule_ids': fields.one2many('barcode.rule','barcode_nomenclature_id','Rules', help='The list of barcode rules'), + 'upc_ean_conv': fields.selection(UPC_EAN_CONVERSIONS, 'UPC/EAN Conversion', required=True, + 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.'), + } + + _defaults = { + 'upc_ean_conv': 'always', + } + + # returns the checksum of the ean13, or -1 if the ean has not the correct length, ean must be a string + def ean_checksum(self, ean): + code = list(ean) + if len(code) != 13: + return -1 + + oddsum = evensum = total = 0 + code = code[:-1] # Remove checksum + for i in range(len(code)): + if i % 2 == 0: + evensum += int(code[i]) + else: + oddsum += int(code[i]) + total = oddsum * 3 + evensum + return int((10 - total % 10) % 10) + + # returns the checksum of the ean8, or -1 if the ean has not the correct length, ean must be a string + def ean8_checksum(self,ean): + code = list(ean) + if len(code) != 8: + return -1 + + sum1 = ean[1] + ean[3] + ean[5] + sum2 = ean[0] + ean[2] + ean[4] + ean[6] + total = sum1 + 3 * sum2 + return int((10 - total % 10) % 10) + + # returns true if the barcode is a valid EAN barcode + def check_ean(self, ean): + return re.match("^\d+$", ean) and self.ean_checksum(ean) == int(ean[-1]) + + # returns true if the barcode string is encoded with the provided encoding. + def check_encoding(self, barcode, encoding): + if encoding == 'ean13': + return len(barcode) == 13 and re.match("^\d+$", barcode) and self.ean_checksum(barcode) == int(barcode[-1]) + elif encoding == 'ean8': + return len(barcode) == 8 and re.match("^\d+$", barcode) and self.ean8_checksum(barcode) == int(barcode[-1]) + elif encoding == 'upca': + return len(barcode) == 12 and re.match("^\d+$", barcode) and self.ean_checksum("0"+barcode) == int(barcode[-1]) + elif encoding == 'any': + return True + else: + return False + + + # Returns a valid zero padded ean13 from an ean prefix. the ean prefix must be a string. + def sanitize_ean(self, ean): + ean = ean[0:13] + ean = ean + (13-len(ean))*'0' + return ean[0:12] + str(self.ean_checksum(ean)) + + # Returns a valid zero padded UPC-A from a UPC-A prefix. the UPC-A prefix must be a string. + def sanitize_upc(self, upc): + return self.sanitize_ean('0'+upc)[1:] + + # Checks if barcode matches the pattern + # Additionnaly retrieves the optional numerical content in barcode + # Returns an object containing: + # - value: the numerical value encoded in the barcode (0 if no value encoded) + # - base_code: the barcode in which numerical content is replaced by 0's + # - match: boolean + def match_pattern(self, barcode, pattern): + match = { + "value": 0, + "base_code": barcode, + "match": False, + } + + barcode = barcode.replace("\\", "\\\\").replace("{", '\{').replace("}", "\}").replace(".", "\.") + numerical_content = re.search("[{][N]*[D]*[}]", pattern) # look for numerical content in pattern + + if numerical_content: # the pattern encodes a numerical content + num_start = numerical_content.start() # start index of numerical content + num_end = numerical_content.end() # end index of numerical content + value_string = barcode[num_start:num_end-2] # numerical content in barcode + + whole_part_match = re.search("[{][N]*[D}]", numerical_content.group()) # looks for whole part of numerical content + decimal_part_match = re.search("[{N][D]*[}]", numerical_content.group()) # looks for decimal part + whole_part = value_string[:whole_part_match.end()-2] # retrieve whole part of numerical content in barcode + decimal_part = "0." + value_string[decimal_part_match.start():decimal_part_match.end()-1] # retrieve decimal part + if whole_part == '': + whole_part = '0' + match['value'] = int(whole_part) + float(decimal_part) + + match['base_code'] = barcode[:num_start] + (num_end-num_start-2)*"0" + barcode[num_end-2:] # replace numerical content by 0's in barcode + match['base_code'] = match['base_code'].replace("\\\\", "\\").replace("\{", "{").replace("\}","}").replace("\.",".") + pattern = pattern[:num_start] + (num_end-num_start-2)*"0" + pattern[num_end:] # replace numerical content by 0's in pattern to match + + match['match'] = re.match(pattern, match['base_code'][:len(pattern)]) + + return match + + # Attempts to interpret an barcode (string encoding a barcode) + # It will return an object containing various information about the barcode. + # most importantly : + # - code : the barcode + # - type : the type of the barcode: + # - value : if the id encodes a numerical value, it will be put there + # - base_code : the barcode code with all the encoding parts set to zero; the one put on + # the product in the backend + def parse_barcode(self, barcode): + parsed_result = { + 'encoding': '', + 'type': 'error', + 'code': barcode, + 'base_code': barcode, + 'value': 0, + } + + rules = [] + for rule in self.rule_ids: + rules.append({'type': rule.type, 'encoding': rule.encoding, 'sequence': rule.sequence, 'pattern': rule.pattern, 'alias': rule.alias}) + + for rule in rules: + cur_barcode = barcode + if rule['encoding'] == 'ean13' and self.check_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']: + cur_barcode = cur_barcode[1:] + + if not self.check_encoding(barcode,rule['encoding']): + continue + + match = self.match_pattern(cur_barcode, rule['pattern']) + if match['match']: + if rule['type'] == 'alias': + barcode = rule['alias'] + parsed_result['code'] = barcode + else: + parsed_result['encoding'] = rule['encoding'] + parsed_result['type'] = rule['type'] + parsed_result['value'] = match['value'] + parsed_result['code'] = cur_barcode + if rule['encoding'] == "ean13": + parsed_result['base_code'] = self.sanitize_ean(match['base_code']) + elif rule['encoding'] == "upca": + parsed_result['base_code'] = self.sanitize_upc(match['base_code']) + else: + parsed_result['base_code'] = match['base_code'] + return parsed_result + + return parsed_result + +class barcode_rule(models.Model): + _name = 'barcode.rule' + _order = 'sequence asc' + + @api.model + def _encoding_selection_list(self): + return [ + ('any', _('Any')), + ('ean13', 'EAN-13'), + ('ean8', 'EAN-8'), + ('upca', 'UPC-A'), + ] + + @api.model + def _get_type_selection(self): + return [('alias', _('Alias')), ('product', _('Unit Product'))] + + _columns = { + 'name': fields.char('Rule Name', size=32, required=True, help='An internal identification for this barcode nomenclature rule'), + 'barcode_nomenclature_id': fields.many2one('barcode.nomenclature','Barcode Nomenclature'), + 'sequence': fields.integer('Sequence', help='Used to order rules such that rules with a smaller sequence match first'), + 'encoding': fields.selection('_encoding_selection_list','Encoding',required=True,help='This rule will apply only if the barcode is encoded with the specified encoding'), + 'type': fields.selection('_get_type_selection','Type', required=True), + 'pattern': fields.char('Barcode Pattern', size=32, help="The barcode matching pattern", required=True), + 'alias': fields.char('Alias',size=32,help='The matched pattern will alias to this barcode', required=True), + } + + _defaults = { + 'type': 'product', + 'pattern': '.*', + 'encoding': 'any', + 'alias': "0", + } + + @api.one + @api.constrains('pattern') + def _check_pattern(self): + p = self.pattern.replace("\\\\", "X").replace("\{", "X").replace("\}", "X") + findall = re.findall("[{]|[}]", p) # p does not contain escaped { or } + if len(findall) == 2: + if not re.search("[{][N]*[D]*[}]", p): + raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": braces can only contain N's followed by D's.")) + elif re.search("[{][}]", p): + raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": empty braces.")) + elif len(findall) != 0: + raise ValidationError(_("There is a syntax error in the barcode pattern ") + self.pattern + _(": a rule can only contain one pair of braces.")) + elif p == '*': + raise ValidationError(_(" '*' is not a valid Regex Barcode Pattern. Did you mean '.*' ?")) + + diff --git a/barcodes/barcodes_view.xml b/barcodes/barcodes_view.xml new file mode 100644 index 00000000..06ae009e --- /dev/null +++ b/barcodes/barcodes_view.xml @@ -0,0 +1,86 @@ + + + + + + Barcode Nomenclatures + barcode.nomenclature + +
+ + + + + +
+

+ Barcodes Nomenclatures define how barcodes are recognized and categorized. + When a barcode is scanned it is associated to the first rule with a matching + pattern. The pattern syntax is that of regular expression, and a barcode is matched + if the regular expression matches a prefix of the barcode. +

+ Patterns can also define how numerical values, such as weight or price, can be + encoded into the barcode. They are indicated by {NNN} where the N's + define where the number's digits are encoded. Floats are also supported with the + decimals indicated with D's, such as {NNNDD}. In these cases, + the barcode field on the associated records must show these digits as + zeroes. +

+
+ + + + + + + + + +
+
+
+
+ + + Barcode Nomenclatures + barcode.nomenclature + + + + + + + + + Barcode Nomenclatures + ir.actions.act_window + barcode.nomenclature + form + tree,form + +

+ Click to add a Barcode Nomenclature . +

+ A barcode nomenclature defines how the point of sale identify and interprets barcodes +

+
+
+ + + Barcode Rule + barcode.rule + +
+ + + + + + + + +
+
+
+
+
diff --git a/barcodes/data/barcodes_data.xml b/barcodes/data/barcodes_data.xml new file mode 100644 index 00000000..69ac657e --- /dev/null +++ b/barcodes/data/barcodes_data.xml @@ -0,0 +1,17 @@ + + + + + Default Nomenclature + + + + Product Barcodes + + 90 + product + any + .* + + + diff --git a/barcodes/doc/index.rst b/barcodes/doc/index.rst new file mode 100644 index 00000000..6c93b1f0 --- /dev/null +++ b/barcodes/doc/index.rst @@ -0,0 +1,131 @@ +============================== +Barcodes module documentation +============================== + +This module brings barcode encoding logic and client-side barcode scanning utilities. + + +Barcodes encoding +============================== + +The Barcodes module defines barcode nomenclatures whose rules identify specific type +of items e.g. products, locations. It contains the following features: + +- Barcode patterns to identify barcodes containing a numerical value (e.g. weight, price) +- Definitin of barcode aliases that allow to identify the same product with different barcodes +- Unlimited barcode patterns and definitions, +- Barcode EAN13 encoding supported. + +Barcode encodings +----------------- + +A barcode is an arbitrary long sequence of ASCII characters. An EAN-13 barcode is a 13 digit +barcode, whose 13th digit is the checksum. + +Simple barcodes and rules +------------------------- + +The default nomenclature assumes an EAN-13 encoding for product barcodes. It defines a rule +for Unit Products whose encoding is EAN-13, and whose pattern is '.', i.e. any barcode +matches this pattern. Scanning the barcode of a product, say '5410013101703', matches this rule. +The scanned item is thus identified as a Unit Product, and is retrieved from the product table. + +Note: the special character '.' in patterns is matched by any character. To explicitely specify +the '.' character in a pattern, escape it with '\'. The '\' character has to be escaped as well +('\\') to be explicitely specified. + +Let us now suppose that we identify other items with barcodes, say stock locations. We define a +new rule in the nomenclature with the corresponding type (in our example, the type is 'Location'), +and whose pattern is e.g. '414.', that is, any location barcode starts with '414'. Scanning a barcode +location, say '41401', matches this Location rule, and the corresponding location is retrieved from +the location table. + +Note: Rules have a sequence field which indicates the order the rules are evaluated (ASC). In our +previous examples, the Unit Product rule should have a larger sequence that then Location rule, +because we want the latter one to be evaluated first. + +Barcodes with numerical content +-------------------------------- + +Barcodes may encode numerical content, which is decoded by the barcodes module. To that purpose, +one have to define a new rule for barcodes with numerical content (e.g. barcodes for Weighted +Products). The numerical content in a pattern is specified between braces (special characters '{' and +'}'). The content of the braces must be a sequence of 'N's (representing the whole part of the numerical +content) followed by a sequence of 'D's (representing the decimal part of the numerical content). +For instance, let us define a new rule for Weighted Products whose pattern is '21.....{NNDDD}.'. Since +we assume EAN-13 encoding for product barcodes, the encoding of this rule should be EAN-13 as well. + +Let us now assume that we want to write a barcode for a given Weighted Product, say oranges. We first +have to define in product oranges a barcode that will match the Weighted Product rule. This barcode +must start with '21' and be a correct EAN-13 barcode (i.e. the 13th digit must be a correct checksum). +Moreover, all the numerical content must be set to a '0'. For instance, let us set the barcode to +'2100001000004'. + +We now want to write a barcode for 2.75kg of oranges. This barcode should be '2100001027506' (the +numerical content of this barcode is '02750', and the correct checksum is '6'). When scanned, this +barcode matches the Weighted Product rule (since is starts with '21'). The numerical content is extracted, +and replaced by a sequence of '0's. The correct checksum is then computed for the obtained barcode +('2100001000004') and the corresponding product (oranges) qgit is retrieved from product table. + +Note: the special characters '{' and '}' in patterns are used to identify numerical content. To +explicitely specify '{' or '}' in a pattern, they must be escaped. + + +Strict EAN-13 field of barcode nomenclatures +-------------------------------------------- + +Many barcode scanners strip the leading zero when scanning EAN-13 barcodes. Barcode nomenclatures +have a boolean field "Use strict EAN13". If False, when trying to match a scanned barcode with +a rule whose encoding is EAN-13, if the barcode is of length 12 and, by prepending it by a 0, +the last digit is the correct checksum, we automatically prepend the barcode by 0 and try to +find a match with this new barcode. If "Use strict EAN13" is set to True, we look for a pattern +matching the original, 12-digit long, barcode. + + + +Barcodes scanning +============================== + + +Barcode events +------------------------------ + +When the module barcodes is installed, it instanciate a singleton of the javascript class BarcodeEvents. +The purpose of this component is to listen to keypresses to detect barcodes, then dispatch those barcodes +on core.bus inside a 'barcode_event'. +All keypress events are buffered until there is no more keypress during 50ms or a carriage return / tab is +inputted (because most barcode scanners use this as a suffix). +If the buffered keys looks like a barcode (match the the regexp /.{3,}[\n\r\t]*), an event is triggered : +core.bus.trigger('barcode_scanned', barcode); +Otherwise, the keypresses are 'resent'. However, for security reasons, a keypress event programmatically +crafted doesn't trigger native browser behaviors. For this reason, BarcodeEvents doesn't intercept keypresses +whose target is an editable element (eg. input) or when ctrl/cmd/alt is pushed. +To catch keypresses targetting an editable element, it must have the attribute barcode_events="true". + + +Barcode handlers +------------------------------ + +To keep the web client consistent, components that want to listen to barcode events should include BarcodeHandlerMixin. +It requires method on_barcode_scanned(barcode) to be implemented and exposes methods start_listening and stop_listening +As long as it is the descendant of a View managed by a ViewManager is only listens while the view is attached. + + +Form view barcode handler +------------------------------ + +It is possible for a form view to listen to barcode events, handle them client side and/or server-side. +When the barcode is handled server-side, it works like an onchange. The relevant model must include the +BarcodeEventsMixin and redefine method on_barcode_scanned. This method receives the barcode scanned and +the `self` is a pseudo-record representing the content of the form view, just like in @api.onchange methods. +Barcodes prefixed with 'O-CMD' or 'O-BTN' are reserved for special features and are never passed to on_barcode_scanned. +The form view barcode handler can be extended to add client-side handling. Please refer to the (hopefully +well enough) documented file for more informations. + + +Button barcode handler +------------------------------ + +Add an attribute 'barcode_trigger' to a button to be able to trigger it by scanning a barcode. Example : +