From 2bdde6a278748d0ae76149d294d5444782e57737 Mon Sep 17 00:00:00 2001 From: Graeme Gellatly Date: Fri, 14 Feb 2025 17:36:12 +1300 Subject: [PATCH] [IMP] printer_zpl2: Add GS1-128 barcode support and code improvements Add support for GS1-128 barcodes with configurable Application Identifiers: - Support for common AIs (SSCC, GTIN, dates, weights, etc.) - Automatic data formatting per GS1 specifications - Unit of measure conversion for weight-based AIs (kg/lbs) - Configurable field paths and sequence ordering - Proper AI value formatting and validation --- printer_zpl2/README.rst | 45 +++++ printer_zpl2/models/__init__.py | 1 + printer_zpl2/models/printing_label_zpl2.py | 143 +-------------- .../models/printing_label_zpl2_component.py | 170 +++++++++++++++++ .../models/printing_label_zpl2_gs1_ai.py | 169 +++++++++++++++++ printer_zpl2/models/zpl2.py | 18 ++ printer_zpl2/readme/CONFIGURE.rst | 21 +++ printer_zpl2/readme/CONTRIBUTORS.rst | 1 + printer_zpl2/readme/DESCRIPTION.rst | 6 + printer_zpl2/readme/KNOWN_ISSUES.rst | 20 ++ printer_zpl2/readme/USAGE.rst | 17 ++ printer_zpl2/security/ir.model.access.csv | 1 + printer_zpl2/static/description/index.html | 81 ++++++++ printer_zpl2/tests/__init__.py | 1 + printer_zpl2/tests/test_gs1_ai.py | 173 ++++++++++++++++++ printer_zpl2/views/printing_label_zpl2.xml | 27 +++ 16 files changed, 758 insertions(+), 136 deletions(-) create mode 100644 printer_zpl2/models/printing_label_zpl2_gs1_ai.py create mode 100644 printer_zpl2/readme/KNOWN_ISSUES.rst create mode 100644 printer_zpl2/tests/test_gs1_ai.py diff --git a/printer_zpl2/README.rst b/printer_zpl2/README.rst index da806af1b6e..f9e46c96919 100644 --- a/printer_zpl2/README.rst +++ b/printer_zpl2/README.rst @@ -31,6 +31,12 @@ Printer ZPL II This module extends the **Report to printer** (``base_report_to_printer``) module to add a ZPL II label printing feature. +Key features include: +* Design labels with components like text, barcodes, boxes, lines and images +* Support for GS1-128 barcodes with configurable Application Identifiers (AIs) +* Automatic data formatting according to GS1 specifications +* Unit of measure conversion for weight-based AIs + This module is meant to be used as a base for module development, and does not provide a GUI on its own. See below for more details. @@ -54,6 +60,27 @@ To configure this module, you need to: #. Import ZPL2 code #. Use the Test Mode tab during the creation +For GS1-128 barcodes, you can configure: + +* Supported Application Identifiers (AIs): + * (00) SSCC + * (01) GTIN + * (10) Batch/Lot Number + * (11) Production Date (YYMMDD) + * (13) Packaging Date (YYMMDD) + * (15) Best Before Date (YYMMDD) + * (17) Expiration Date (YYMMDD) + * (21) Serial Number + * (30) Count + * (310n) Net Weight (kg) + * (320n) Net Weight (lbs) + +* For each AI: + * Field path to get data from (e.g., "product_id.weight") + * For weight fields, UoM field path for automatic conversion + * Sequence order in the final barcode + * For weight AIs, number of decimal places (0-5) + It's also possible to add a label printing wizard on any model by creating a new *ir.actions.act_window* record. For example, to add the printing wizard on the *product.product* model :: @@ -76,6 +103,23 @@ Example : Print the label of a product :: self.env['printing.printer'].browse(printer_id), self.env['product.product'].browse(product_id)) +For GS1-128 barcodes: + +1. Create a new label component +2. Set the component type to "GS1-128" +3. Add Application Identifiers with their configurations: + * Select the AI type (e.g., GTIN, Weight, Date) + * Set the field path to get the data from + * For weight fields, optionally set the UoM field path + * Set the sequence to control AI order + * For weight fields, set the decimal places + +The module will automatically: +* Format the data according to GS1 specifications +* Convert units of measure for weights +* Combine multiple AIs with proper separators +* Generate the final GS1-128 barcode + You can also use the generic label printing wizard, if added on some models. .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas @@ -124,6 +168,7 @@ Contributors * Miquel Raïch * Lois Rilo * Tran Quoc Duong +* Graeme Gellatly Maintainers ~~~~~~~~~~~ diff --git a/printer_zpl2/models/__init__.py b/printer_zpl2/models/__init__.py index 69d41d1b023..29e9a1a254f 100644 --- a/printer_zpl2/models/__init__.py +++ b/printer_zpl2/models/__init__.py @@ -3,3 +3,4 @@ from . import printing_label_zpl2 from . import printing_label_zpl2_component +from . import printing_label_zpl2_gs1_ai diff --git a/printer_zpl2/models/printing_label_zpl2.py b/printer_zpl2/models/printing_label_zpl2.py index 44152f23a40..664002bec4b 100644 --- a/printer_zpl2/models/printing_label_zpl2.py +++ b/printer_zpl2/models/printing_label_zpl2.py @@ -8,7 +8,7 @@ from collections import defaultdict import requests -from PIL import Image, ImageOps +from PIL import Image from odoo import _, api, exceptions, fields, models from odoo.exceptions import ValidationError @@ -121,11 +121,6 @@ def check_recursion(self): if id2 not in done: todo.add(id2) - def _get_component_data(self, record, component, eval_args): - if component.data_autofill: - return component.autofill_data(record, eval_args) - return safe_eval(str(component.data), eval_args) or "" - def _get_to_data_to_print( self, record, @@ -161,7 +156,7 @@ def _get_to_data_to_print( ), } ) - data = self._get_component_data(record, component, eval_args) + data = component._get_data(record, eval_args) if isinstance(data, str) and data == "component_not_show": continue @@ -207,135 +202,11 @@ def _generate_zpl2_components_data( ) for (component, data, offset_x, offset_y) in to_print: - component_offset_x = component.origin_x + offset_x - component_offset_y = component.origin_y + offset_y - if component.component_type == "text": - barcode_arguments = { - field_name: component[field_name] - for field_name in [ - zpl2.ARG_FONT, - zpl2.ARG_ORIENTATION, - zpl2.ARG_HEIGHT, - zpl2.ARG_WIDTH, - zpl2.ARG_REVERSE_PRINT, - zpl2.ARG_IN_BLOCK, - zpl2.ARG_BLOCK_WIDTH, - zpl2.ARG_BLOCK_LINES, - zpl2.ARG_BLOCK_SPACES, - zpl2.ARG_BLOCK_JUSTIFY, - zpl2.ARG_BLOCK_LEFT_MARGIN, - ] - } - label_data.font_data( - component_offset_x, component_offset_y, barcode_arguments, data - ) - elif component.component_type == "zpl2_raw": - label_data._write_command(data) - elif component.component_type == "rectangle": - label_data.graphic_box( - component_offset_x, - component_offset_y, - { - zpl2.ARG_WIDTH: component.width, - zpl2.ARG_HEIGHT: component.height, - zpl2.ARG_THICKNESS: component.thickness, - zpl2.ARG_COLOR: component.color, - zpl2.ARG_ROUNDING: component.rounding, - }, - ) - elif component.component_type == "diagonal": - label_data.graphic_diagonal_line( - component_offset_x, - component_offset_y, - { - zpl2.ARG_WIDTH: component.width, - zpl2.ARG_HEIGHT: component.height, - zpl2.ARG_THICKNESS: component.thickness, - zpl2.ARG_COLOR: component.color, - zpl2.ARG_DIAGONAL_ORIENTATION: component.diagonal_orientation, - }, - ) - elif component.component_type == "graphic": - # During the on_change don't take the bin_size - image = ( - component.with_context(bin_size_graphic_image=False).graphic_image - or data - ) - try: - pil_image = Image.open(io.BytesIO(base64.b64decode(image))).convert( - "RGB" - ) - except Exception: - continue - if component.width and component.height: - pil_image = pil_image.resize((component.width, component.height)) - - # Invert the colors - if component.reverse_print: - pil_image = ImageOps.invert(pil_image) - - # Rotation (PIL rotates counter clockwise) - if component.orientation == zpl2.ORIENTATION_ROTATED: - pil_image = pil_image.transpose(Image.ROTATE_270) - elif component.orientation == zpl2.ORIENTATION_INVERTED: - pil_image = pil_image.transpose(Image.ROTATE_180) - elif component.orientation == zpl2.ORIENTATION_BOTTOM_UP: - pil_image = pil_image.transpose(Image.ROTATE_90) - - label_data.graphic_field( - component_offset_x, component_offset_y, pil_image - ) - elif component.component_type == "circle": - label_data.graphic_circle( - component_offset_x, - component_offset_y, - { - zpl2.ARG_DIAMETER: component.width, - zpl2.ARG_THICKNESS: component.thickness, - zpl2.ARG_COLOR: component.color, - }, - ) - elif component.component_type == "sublabel": - component_offset_x += component.sublabel_id.origin_x - component_offset_y += component.sublabel_id.origin_y - component.sublabel_id._generate_zpl2_components_data( - label_data, - data if isinstance(data, models.BaseModel) else record, - label_offset_x=component_offset_x, - label_offset_y=component_offset_y, - ) - else: - if component.component_type == zpl2.BARCODE_QR_CODE: - # Adding Control Arguments to QRCode data Label - data = "{}A,{}".format(component.error_correction, data) - - barcode_arguments = { - field_name: component[field_name] - for field_name in [ - zpl2.ARG_ORIENTATION, - zpl2.ARG_CHECK_DIGITS, - zpl2.ARG_HEIGHT, - zpl2.ARG_INTERPRETATION_LINE, - zpl2.ARG_INTERPRETATION_LINE_ABOVE, - zpl2.ARG_SECURITY_LEVEL, - zpl2.ARG_COLUMNS_COUNT, - zpl2.ARG_ROWS_COUNT, - zpl2.ARG_TRUNCATE, - zpl2.ARG_MODULE_WIDTH, - zpl2.ARG_BAR_WIDTH_RATIO, - zpl2.ARG_MODEL, - zpl2.ARG_MAGNIFICATION_FACTOR, - zpl2.ARG_ERROR_CORRECTION, - zpl2.ARG_MASK_VALUE, - ] - } - label_data.barcode_data( - component.origin_x + offset_x, - component.origin_y + offset_y, - component.component_type, - barcode_arguments, - data, - ) + getattr( + component, + "_process_type_%s" % component.component_type, + component._process_type_barcode, + )(label_data, data, offset_x, offset_y, record) def _generate_zpl2_data(self, record, page_count=1, **extra): self.ensure_one() diff --git a/printer_zpl2/models/printing_label_zpl2_component.py b/printer_zpl2/models/printing_label_zpl2_component.py index eccd2ef6a66..5957794117c 100644 --- a/printer_zpl2/models/printing_label_zpl2_component.py +++ b/printer_zpl2/models/printing_label_zpl2_component.py @@ -1,9 +1,14 @@ # Copyright (C) 2016 SYLEAM () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 +import io import logging +from PIL import Image, ImageOps + from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval from . import zpl2 @@ -64,6 +69,7 @@ class PrintingLabelZpl2Component(models.Model): (str(zpl2.BARCODE_CODE_128), "Code 128"), (str(zpl2.BARCODE_EAN_13), "EAN-13"), (str(zpl2.BARCODE_QR_CODE), "QR Code"), + (str(zpl2.BARCODE_GS1_128), "GS1-128"), ("sublabel", "Sublabel"), ("zpl2_raw", "ZPL2"), ], @@ -72,6 +78,13 @@ class PrintingLabelZpl2Component(models.Model): default="text", help="Type of content, simple text or barcode.", ) + + gs1_ai_ids = fields.One2many( + "printing.label.zpl2.gs1.ai", + "component_id", + string="GS1 Application Identifiers", + ) + font = fields.Selection( selection=[ (str(zpl2.FONT_DEFAULT), "Default"), @@ -267,6 +280,22 @@ def _onchange_component_type(self): else: component.data_autofill = False + def _get_data(self, record, eval_args): + if self.data_autofill: + return self.autofill_data(record, eval_args) + data = safe_eval(str(self.data), eval_args) or "" + if hasattr(self, "_postprocess_data_%s" % self.component_type): + data = getattr(self, "_postprocess_data_%s" % self.component_type)( + data, record, eval_args + ) + return data + + def _postprocess_data_gs1_128(self, data, record, eval_args): + return self._generate_gs1_128_data(record) + + def _postprocess_data_qr_code(self, data, record, eval_args): + return "{}A,{}".format(self.error_correction, data) + @api.model def autofill_data(self, record, eval_args): data = {} @@ -276,6 +305,147 @@ def autofill_data(self, record, eval_args): data[field] = getattr(record, field) return data + def _generate_gs1_128_data(self, record): + data = [] + for ai_config in self.gs1_ai_ids: + ai, value = ai_config._format_gs1_value(record) + if value: + data.append(f"({ai}){value}") + return ">8".join(data) + + def _process_type_text(self, label_data, data, offset_x, offset_y, record): + component_offset_x = self.origin_x + offset_x + component_offset_y = self.origin_y + offset_y + format_arguments = { + field_name: self[field_name] + for field_name in [ + zpl2.ARG_FONT, + zpl2.ARG_ORIENTATION, + zpl2.ARG_HEIGHT, + zpl2.ARG_WIDTH, + zpl2.ARG_REVERSE_PRINT, + zpl2.ARG_IN_BLOCK, + zpl2.ARG_BLOCK_WIDTH, + zpl2.ARG_BLOCK_LINES, + zpl2.ARG_BLOCK_SPACES, + zpl2.ARG_BLOCK_JUSTIFY, + zpl2.ARG_BLOCK_LEFT_MARGIN, + ] + } + label_data.font_data( + component_offset_x, component_offset_y, format_arguments, data + ) + + def _process_type_zpl2_raw(self, label_data, data, *args): + label_data._write_command(data) + + def _process_type_rectangle(self, label_data, data, offset_x, offset_y, record): + label_data.graphic_box( + self.origin_x + offset_x, + self.origin_y + offset_y, + { + zpl2.ARG_WIDTH: self.width, + zpl2.ARG_HEIGHT: self.height, + zpl2.ARG_THICKNESS: self.thickness, + zpl2.ARG_COLOR: self.color, + zpl2.ARG_ROUNDING: self.rounding, + }, + ) + + def _process_type_diagonal(self, label_data, data, offset_x, offset_y, record): + label_data.graphic_diagonal_line( + self.origin_x + offset_x, + self.origin_y + offset_y, + { + zpl2.ARG_WIDTH: self.width, + zpl2.ARG_HEIGHT: self.height, + zpl2.ARG_THICKNESS: self.thickness, + zpl2.ARG_COLOR: self.color, + zpl2.ARG_DIAGONAL_ORIENTATION: self.diagonal_orientation, + }, + ) + + def _process_type_graphic(self, label_data, data, offset_x, offset_y, record): + image = self.with_context(bin_size_graphic_image=False).graphic_image or data + try: + pil_image = Image.open(io.BytesIO(base64.b64decode(image))).convert("RGB") + except Exception as e: + _logger.warning( + "Failed to process graphic component %s for record %s: %s", + self.name, + record.display_name, + str(e), + ) + return + if self.width and self.height: + pil_image = pil_image.resize((self.width, self.height)) + + # Invert the colors + if self.reverse_print: + pil_image = ImageOps.invert(pil_image) + + # Rotation (PIL rotates counter clockwise) + if self.orientation == zpl2.ORIENTATION_ROTATED: + pil_image = pil_image.transpose(Image.ROTATE_270) + elif self.orientation == zpl2.ORIENTATION_INVERTED: + pil_image = pil_image.transpose(Image.ROTATE_180) + elif self.orientation == zpl2.ORIENTATION_BOTTOM_UP: + pil_image = pil_image.transpose(Image.ROTATE_90) + + label_data.graphic_field( + self.origin_x + offset_x, self.origin_y + offset_y, pil_image + ) + + def _process_type_circle(self, label_data, data, offset_x, offset_y, record): + label_data.graphic_circle( + self.origin_x + offset_x, + self.origin_y + offset_y, + { + zpl2.ARG_DIAMETER: self.width, + zpl2.ARG_THICKNESS: self.thickness, + zpl2.ARG_COLOR: self.color, + }, + ) + + def _process_type_sublabel(self, label_data, data, offset_x, offset_y, record): + component_offset_x = self.origin_x + offset_x + self.sublabel_id.origin_x + component_offset_y = self.origin_y + offset_y + self.sublabel_id.origin_y + self.sublabel_id._generate_zpl2_components_data( + label_data, + data if isinstance(data, models.BaseModel) else record, + label_offset_x=component_offset_x, + label_offset_y=component_offset_y, + ) + + def _process_type_barcode(self, label_data, data, offset_x, offset_y, record): + barcode_arguments = { + field_name: self[field_name] + for field_name in [ + zpl2.ARG_ORIENTATION, + zpl2.ARG_CHECK_DIGITS, + zpl2.ARG_HEIGHT, + zpl2.ARG_INTERPRETATION_LINE, + zpl2.ARG_INTERPRETATION_LINE_ABOVE, + zpl2.ARG_SECURITY_LEVEL, + zpl2.ARG_COLUMNS_COUNT, + zpl2.ARG_ROWS_COUNT, + zpl2.ARG_TRUNCATE, + zpl2.ARG_MODULE_WIDTH, + zpl2.ARG_BAR_WIDTH_RATIO, + zpl2.ARG_MODEL, + zpl2.ARG_MAGNIFICATION_FACTOR, + zpl2.ARG_ERROR_CORRECTION, + zpl2.ARG_MASK_VALUE, + ] + } + label_data.barcode_data( + self.origin_x + offset_x, + self.origin_y + offset_y, + self.component_type, + barcode_arguments, + data, + ) + def action_plus_origin_x(self): self.ensure_one() self.origin_x += 10 diff --git a/printer_zpl2/models/printing_label_zpl2_gs1_ai.py b/printer_zpl2/models/printing_label_zpl2_gs1_ai.py new file mode 100644 index 00000000000..a7e7b7c2b91 --- /dev/null +++ b/printer_zpl2/models/printing_label_zpl2_gs1_ai.py @@ -0,0 +1,169 @@ +from datetime import datetime + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PrintingLabelZpl2Gs1AI(models.Model): + _name = "printing.label.zpl2.gs1.ai" + _description = "GS1-128 Application Identifier Configuration" + _order = "sequence" + + component_id = fields.Many2one( + "printing.label.zpl2.component", + string="Label Component", + required=True, + ondelete="cascade", + ) + sequence = fields.Integer(default=10) + ai = fields.Selection( + [ + ("00", "SSCC"), + ("01", "GTIN"), + ("10", "Batch/Lot Number"), + ("11", "Production Date (YYMMDD)"), + ("13", "Packaging Date (YYMMDD)"), + ("15", "Best Before Date (YYMMDD)"), + ("17", "Expiration Date (YYMMDD)"), + ("21", "Serial Number"), + ("30", "Count"), + ("310n", "Net Weight (kg)"), # n = decimal places + ("320n", "Net Weight (lbs)"), # n = decimal places + ], + string="Application Identifier", + required=True, + ) + field_name = fields.Char( + string="Field Path", + required=True, + help="Field name or dot-notation path (e.g. product_id.weight)", + ) + uom_field_name = fields.Char( + string="UoM Field Path", + help="Field path to the UoM field (e.g. product_id.uom_id)", + ) + decimal_places = fields.Integer( + help="For weight AIs (310n, 320n), specify decimal places (0-5)", + ) + + @api.constrains("field_name", "uom_field_name") + def _check_field_paths(self): + for record in self: + if not record.field_name: + continue + + model = record.component_id.model_id.model + Model = self.env[model] + + # Check field_name path + self._check_field_path(Model, record.field_name) + + # Check uom_field_name path if weight AI + if record.ai.startswith(("310", "320")) and record.uom_field_name: + self._check_field_path(Model, record.uom_field_name) + + def _check_field_path(self, Model, field_path): + field_path = field_path.split(".") + current = Model + + try: + for field in field_path[:-1]: + field_def = current._fields[field] + if not field_def.type == "many2one": + raise ValidationError( + _( + "Field %(field)s in path %(path)s is not a relational field", + field=field, + path=".".join(field_path), + ) + ) + current = self.env[field_def.comodel_name] + + if field_path[-1] not in current._fields: + raise ValidationError( + _( + "Field %(field)s does not exist on model %(model)s", + field=field_path[-1], + model=current._name, + ) + ) + except KeyError: + raise ValidationError( + _("Invalid field path: %(path)s", path=".".join(field_path)) + ) from None + + def _format_gs1_value(self, record): + """Format field value according to GS1 AI rules""" + # Handle dot notation for related fields + field_path = self.field_name.split(".") + value = record + for field_name in field_path: + value = value[field_name] if value else False + + if not value: + value = "" + try: + ai, value = getattr(self, f"_format_gs1_ai{self.ai}")(value, record) + except AttributeError: + ai = self.ai + + return ai, str(value) + + def _format_gs1_ai00(self, value, record): + """Format value for GS1-128 AI 00""" + return self.ai, f"{int(value):018d}" + + def _format_gs1_ai01(self, value, record): + """Format value for GS1-128 AI 01""" + return self.ai, f"{int(value):014d}" + + def _format_gs1_ai10(self, value, record): + """Format value for GS1-128 AI 10""" + return self.ai, value[:20] + + def _format_gs1_ai11(self, value, record): + """Format value for GS1-128 AI 11""" + if isinstance(value, str): + value = datetime.strptime(value, "%Y-%m-%d") + return self.ai, value.strftime("%y%m%d") + + _format_gs1_ai13 = _format_gs1_ai11 + _format_gs1_ai15 = _format_gs1_ai11 + _format_gs1_ai17 = _format_gs1_ai11 + + def _format_gs1_ai21(self, value, record): + return self.ai, value + + def _format_gs1_ai30(self, value, record): + return self.ai, value + + def _format_gs1_ai310n(self, value, record): + target_uom = self.env.ref("uom.product_uom_kgm") + return self._format_gs1_weight(value, target_uom, record) + + def _format_gs1_ai320n(self, value, record): + target_uom = self.env.ref("uom.product_uom_lb") + return self._format_gs1_weight(value, target_uom, record) + + def _format_gs1_weight(self, value, target_uom, record): + source_uom = None + if self.uom_field_name: + source_uom = record + for field_name in self.uom_field_name.split("."): + source_uom = source_uom[field_name] if source_uom else False + if source_uom: + value = self._convert_weight(value, source_uom, target_uom) + value = int(round(value, self.decimal_places) * 10**self.decimal_places) + return self.ai.replace("n", str(self.decimal_places)), f"{value:06d}" + + def _convert_weight(self, value, from_uom, to_uom): + try: + return from_uom._compute_quantity(value, to_uom) + except Exception: + raise ValidationError( + _( + "Failed to convert weight from %(from_uom)s to %(to_uom)s", + from_uom=from_uom.name, + to_uom=to_uom.name, + ) + ) from None diff --git a/printer_zpl2/models/zpl2.py b/printer_zpl2/models/zpl2.py index d9600b62339..2a6e89b0736 100644 --- a/printer_zpl2/models/zpl2.py +++ b/printer_zpl2/models/zpl2.py @@ -104,6 +104,7 @@ BARCODE_CODE_128 = "code_128" BARCODE_EAN_13 = "ean-13" BARCODE_QR_CODE = "qr_code" +BARCODE_GS1_128 = "gs1_128" class Zpl2(object): @@ -322,6 +323,22 @@ def _qrcode(**kwargs): ] return "Q" + self._generate_arguments(arguments, kwargs) + def _gs1_128(**kwargs): + arguments = [ + ARG_ORIENTATION, + ARG_HEIGHT, + ARG_INTERPRETATION_LINE, + ARG_INTERPRETATION_LINE_ABOVE, + ARG_CHECK_DIGITS, + ARG_MODE, + ] + # GS1-128 uses the same command as Code 128 + # mode 'D' limits characters to GS1 dataset and avoids need for + # setting character sets in the data string. It also allows + # the interpretation line to contain parentheses for AI's + kwargs[ARG_MODE] = "D" + return "C" + self._generate_arguments(arguments, kwargs) + barcodeTypes = { BARCODE_CODE_11: _code11, BARCODE_INTERLEAVED_2_OF_5: _interleaved2of5, @@ -333,6 +350,7 @@ def _qrcode(**kwargs): BARCODE_CODE_128: _code128, BARCODE_EAN_13: _ean13, BARCODE_QR_CODE: _qrcode, + BARCODE_GS1_128: _gs1_128, } return "^B" + barcodeTypes[barcodeType](**barcode_format) diff --git a/printer_zpl2/readme/CONFIGURE.rst b/printer_zpl2/readme/CONFIGURE.rst index 2a3036e1690..34bb3ee9020 100644 --- a/printer_zpl2/readme/CONFIGURE.rst +++ b/printer_zpl2/readme/CONFIGURE.rst @@ -5,6 +5,27 @@ To configure this module, you need to: #. Import ZPL2 code #. Use the Test Mode tab during the creation +For GS1-128 barcodes, you can configure: + +* Supported Application Identifiers (AIs): + * (00) SSCC + * (01) GTIN + * (10) Batch/Lot Number + * (11) Production Date (YYMMDD) + * (13) Packaging Date (YYMMDD) + * (15) Best Before Date (YYMMDD) + * (17) Expiration Date (YYMMDD) + * (21) Serial Number + * (30) Count + * (310n) Net Weight (kg) + * (320n) Net Weight (lbs) + +* For each AI: + * Field path to get data from (e.g., "product_id.weight") + * For weight fields, UoM field path for automatic conversion + * Sequence order in the final barcode + * For weight AIs, number of decimal places (0-5) + It's also possible to add a label printing wizard on any model by creating a new *ir.actions.act_window* record. For example, to add the printing wizard on the *product.product* model :: diff --git a/printer_zpl2/readme/CONTRIBUTORS.rst b/printer_zpl2/readme/CONTRIBUTORS.rst index b6a2bf923b6..a4739e89e59 100644 --- a/printer_zpl2/readme/CONTRIBUTORS.rst +++ b/printer_zpl2/readme/CONTRIBUTORS.rst @@ -5,3 +5,4 @@ * Miquel Raïch * Lois Rilo * Tran Quoc Duong +* Graeme Gellatly diff --git a/printer_zpl2/readme/DESCRIPTION.rst b/printer_zpl2/readme/DESCRIPTION.rst index 3dbd00407e3..6f1c59d8e3f 100644 --- a/printer_zpl2/readme/DESCRIPTION.rst +++ b/printer_zpl2/readme/DESCRIPTION.rst @@ -1,5 +1,11 @@ This module extends the **Report to printer** (``base_report_to_printer``) module to add a ZPL II label printing feature. +Key features include: +* Design labels with components like text, barcodes, boxes, lines and images +* Support for GS1-128 barcodes with configurable Application Identifiers (AIs) +* Automatic data formatting according to GS1 specifications +* Unit of measure conversion for weight-based AIs + This module is meant to be used as a base for module development, and does not provide a GUI on its own. See below for more details. diff --git a/printer_zpl2/readme/KNOWN_ISSUES.rst b/printer_zpl2/readme/KNOWN_ISSUES.rst new file mode 100644 index 00000000000..3427959ff71 --- /dev/null +++ b/printer_zpl2/readme/KNOWN_ISSUES.rst @@ -0,0 +1,20 @@ +Current limitations and planned improvements: + +* GS1-128 Application Identifier Support: + * Limited set of AIs currently supported + +* Unit of Measure Conversions: + * Currently only supports weight conversions (kg/lbs) + * Planned support for: + * Length measurements (m, ft, etc.) + * Volume measurements (L, m³, etc.) + * Count/quantity conversions + +* GS1-128 Import: + * No support for importing existing GS1-128 barcode configurations + * Future versions will add ability to: + * Parse existing GS1-128 barcodes + * Extract and configure AIs automatically + * Import AI configurations from other labels + +Please check the issue tracker or submit new issues for additional feature requests. diff --git a/printer_zpl2/readme/USAGE.rst b/printer_zpl2/readme/USAGE.rst index cfbe9a9cbcf..861d6b375de 100644 --- a/printer_zpl2/readme/USAGE.rst +++ b/printer_zpl2/readme/USAGE.rst @@ -6,6 +6,23 @@ Example : Print the label of a product :: self.env['printing.printer'].browse(printer_id), self.env['product.product'].browse(product_id)) +For GS1-128 barcodes: + +1. Create a new label component +2. Set the component type to "GS1-128" +3. Add Application Identifiers with their configurations: + * Select the AI type (e.g., GTIN, Weight, Date) + * Set the field path to get the data from + * For weight fields, optionally set the UoM field path + * Set the sequence to control AI order + * For weight fields, set the decimal places + +The module will automatically: +* Format the data according to GS1 specifications +* Convert units of measure for weights +* Combine multiple AIs with proper separators +* Generate the final GS1-128 barcode + You can also use the generic label printing wizard, if added on some models. .. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas diff --git a/printer_zpl2/security/ir.model.access.csv b/printer_zpl2/security/ir.model.access.csv index 0694fb5b45e..4963928b005 100644 --- a/printer_zpl2/security/ir.model.access.csv +++ b/printer_zpl2/security/ir.model.access.csv @@ -6,3 +6,4 @@ printing_label_zpl2_component_manager,Printing Label ZPL2 Component Manager,mode access_wizard_print_record_label_user,Print Record Label user,model_wizard_print_record_label,base_report_to_printer.printing_group_user,1,1,1,1 access_wizard_print_record_label_line_user,Print Record Label Line user,model_wizard_print_record_label_line,base_report_to_printer.printing_group_user,1,1,1,1 access_wizard_import_zpl2_user,Import ZPL2 user,model_wizard_import_zpl2,base_report_to_printer.printing_group_user,1,1,1,1 +access_printing_label_zpl2_gs1_ai_user,printing.label.zpl2.gs1.ai user,model_printing_label_zpl2_gs1_ai,base_report_to_printer.printing_group_user,1,1,1,1 diff --git a/printer_zpl2/static/description/index.html b/printer_zpl2/static/description/index.html index 852d536ec1e..07252223a6d 100644 --- a/printer_zpl2/static/description/index.html +++ b/printer_zpl2/static/description/index.html @@ -372,6 +372,11 @@

Printer ZPL II

Beta License: AGPL-3 OCA/report-print-send Translate me on Weblate Try me on Runboat

This module extends the Report to printer (base_report_to_printer) module to add a ZPL II label printing feature.

+

Key features include: +* Design labels with components like text, barcodes, boxes, lines and images +* Support for GS1-128 barcodes with configurable Application Identifiers (AIs) +* Automatic data formatting according to GS1 specifications +* Unit of measure conversion for weight-based AIs

This module is meant to be used as a base for module development, and does not provide a GUI on its own. See below for more details.

Table of contents

@@ -406,6 +411,65 @@

Configuration

  • Import ZPL2 code
  • Use the Test Mode tab during the creation
  • +

    For GS1-128 barcodes, you can configure:

    +
      +
    • +
      Supported Application Identifiers (AIs):
      +
        +
        1. +
        2. SSCC
        3. +
        +
      • +
        1. +
        2. GTIN
        3. +
        +
      • +
        1. +
        2. Batch/Lot Number
        3. +
        +
      • +
        1. +
        2. Production Date (YYMMDD)
        3. +
        +
      • +
        1. +
        2. Packaging Date (YYMMDD)
        3. +
        +
      • +
        1. +
        2. Best Before Date (YYMMDD)
        3. +
        +
      • +
        1. +
        2. Expiration Date (YYMMDD)
        3. +
        +
      • +
        1. +
        2. Serial Number
        3. +
        +
      • +
        1. +
        2. Count
        3. +
        +
      • +
      • (310n) Net Weight (kg)
      • +
      • (320n) Net Weight (lbs)
      • +
      +
      +
      +
    • +
    • +
      For each AI:
      +
        +
      • Field path to get data from (e.g., “product_id.weight”)
      • +
      • For weight fields, UoM field path for automatic conversion
      • +
      • Sequence order in the final barcode
      • +
      • For weight AIs, number of decimal places (0-5)
      • +
      +
      +
      +
    • +

    It’s also possible to add a label printing wizard on any model by creating a new ir.actions.act_window record. For example, to add the printing wizard on the product.product model

    @@ -427,6 +491,22 @@ 

    Usage

    self.env['printing.printer'].browse(printer_id), self.env['product.product'].browse(product_id))
    +

    For GS1-128 barcodes:

    +
      +
    1. Create a new label component
    2. +
    3. Set the component type to “GS1-128”
    4. +
    5. Add Application Identifiers with their configurations: +* Select the AI type (e.g., GTIN, Weight, Date) +* Set the field path to get the data from +* For weight fields, optionally set the UoM field path +* Set the sequence to control AI order +* For weight fields, set the decimal places
    6. +
    +

    The module will automatically: +* Format the data according to GS1 specifications +* Convert units of measure for weights +* Combine multiple AIs with proper separators +* Generate the final GS1-128 barcode

    You can also use the generic label printing wizard, if added on some models.

    Try me on Runbot @@ -473,6 +553,7 @@

    Contributors

  • Miquel Raïch <miquel.raich@forgeflow.com>
  • Lois Rilo <lois.rilo@forgeflow.com>
  • Tran Quoc Duong <duontq@trobz.com>
  • +
  • Graeme Gellatly <graeme@moahub.nz>
  • diff --git a/printer_zpl2/tests/__init__.py b/printer_zpl2/tests/__init__.py index 78b9bd8ca4e..9c829a815a6 100644 --- a/printer_zpl2/tests/__init__.py +++ b/printer_zpl2/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_generate_action from . import test_test_mode from . import test_wizard_import_zpl2 +from . import test_gs1_ai diff --git a/printer_zpl2/tests/test_gs1_ai.py b/printer_zpl2/tests/test_gs1_ai.py new file mode 100644 index 00000000000..6740e286d1d --- /dev/null +++ b/printer_zpl2/tests/test_gs1_ai.py @@ -0,0 +1,173 @@ +# Copyright 2024 Your Company +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestPrintingLabelZpl2Gs1AI(TransactionCase): + def setUp(self): + super().setUp() + self.server = self.env["printing.server"].create({}) + self.printer = self.env["printing.printer"].create( + { + "name": "Test Printer", + "server_id": self.server.id, + "system_name": "Test", + "default": True, + "status": "unknown", + "status_message": "Ready", + "model": "res.users", + "location": "Office", + "uri": "URI", + } + ) + + # Create a test product + self.product = self.env["product.product"].create( + { + "name": "Test Product", + "type": "product", + "default_code": "TEST001", + "barcode": "12345678", + "weight": 1.23, + } + ) + + # Create a label + self.label = self.env["printing.label.zpl2"].create( + { + "name": "GS1-128 Test Label", + "model_id": self.env.ref("product.model_product_product").id, + } + ) + + # Create a GS1-128 component + self.component = self.env["printing.label.zpl2.component"].create( + { + "name": "GS1-128 Component", + "label_id": self.label.id, + "component_type": "gs1_128", + } + ) + + def test_gs1_ai_validation(self): + """Test validation of GS1 AI configuration""" + # Test valid field path + ai = self.env["printing.label.zpl2.gs1.ai"].create( + { + "component_id": self.component.id, + "ai": "01", + "field_name": "barcode", + } + ) + self.assertTrue(ai) + + # Test invalid field path + with self.assertRaises(ValidationError): + self.env["printing.label.zpl2.gs1.ai"].create( + { + "component_id": self.component.id, + "ai": "01", + "field_name": "nonexistent_field", + } + ) + + def test_gs1_ai_formatting(self): + """Test formatting of different GS1 AIs""" + Gs1AI = self.env["printing.label.zpl2.gs1.ai"] + + # Test GTIN (AI 01) + ai = Gs1AI.create( + { + "component_id": self.component.id, + "ai": "01", + "field_name": "barcode", + } + ) + ai_code, value = ai._format_gs1_value(self.product) + self.assertEqual(ai_code, "01") + self.assertEqual(value, "00000012345678") + + # Test Weight (AI 310n) + ai = Gs1AI.create( + { + "component_id": self.component.id, + "ai": "310n", + "field_name": "weight", + "decimal_places": 3, + } + ) + ai_code, value = ai._format_gs1_value(self.product) + self.assertEqual(ai_code, "3103") + self.assertEqual(value, "001230") # 1.23 kg with 3 decimal places + + # Test Date (AI 11) + ai = Gs1AI.create( + { + "component_id": self.component.id, + "ai": "11", + "field_name": "create_date", + } + ) + ai_code, value = ai._format_gs1_value(self.product) + self.assertEqual(ai_code, "11") + self.assertEqual(value, self.product.create_date.strftime("%y%m%d")) + + def test_gs1_ai_sequence(self): + """Test GS1 AI sequencing""" + Gs1AI = self.env["printing.label.zpl2.gs1.ai"] + + # Create AIs in non-sequential order + ai2 = Gs1AI.create( + { + "component_id": self.component.id, + "ai": "01", + "field_name": "barcode", + "sequence": 20, + } + ) + ai1 = Gs1AI.create( + { + "component_id": self.component.id, + "ai": "10", + "field_name": "barcode", + "sequence": 10, + } + ) + + # Check ordering + ais = self.component.gs1_ai_ids.sorted() + self.assertEqual(ais[0], ai1) + self.assertEqual(ais[1], ai2) + + def test_gs1_weight_uom_conversion(self): + """Test weight UoM conversion for GS1 AIs""" + # Create a product with weight in pounds + lb_uom = self.env.ref("uom.product_uom_lb") + product_lb = self.env["product.product"].create( + { + "name": "Test Product (lb)", + "type": "product", + "weight": 2.2, # 2.2 lbs ≈ 1 kg + "uom_id": lb_uom.id, + "uom_po_id": lb_uom.id, # Purchase UoM must be in same category as uom_id + } + ) + + # Test weight conversion to kg (AI 310n) + ai = self.env["printing.label.zpl2.gs1.ai"].create( + { + "component_id": self.component.id, + "ai": "310n", + "field_name": "weight", + "uom_field_name": "uom_id", + "decimal_places": 3, + } + ) + ai_code, value = ai._format_gs1_value(product_lb) + self.assertEqual(ai_code, "3103") + # 2.2 lbs ≈ 1 kg, formatted with 3 decimal places + self.assertEqual(value, "001000") diff --git a/printer_zpl2/views/printing_label_zpl2.xml b/printer_zpl2/views/printing_label_zpl2.xml index 1825bb726a8..4eb3da1dd57 100644 --- a/printer_zpl2/views/printing_label_zpl2.xml +++ b/printer_zpl2/views/printing_label_zpl2.xml @@ -222,6 +222,33 @@ + + + + + + + + + + +