commit bca71aff557b59b9ca23ece9be026102310c3388 Author: Suherdy Yacob Date: Mon Jan 26 11:26:45 2026 +0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce28130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Mac +.DS_Store + +# Odoo +*.hot-update.js +*.hot-update.json +.odoo_nb/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..88e20a3 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Citizen ZPL Printer for Odoo + +This module allows direct ZPL printing from Odoo to Citizen Label Printers (e.g., CL-S621, CL-S631) via a network connection (socket). It provides a wizard to select lots and quantities before printing. + +## Features + +* **Direct Printing**: Sends ZPL commands directly to the printer's IP address. +* **Print Wizard**: Select specific lots and quantity of labels to print from a Stock Picking. +* **Customizable Layout**: Configure label size, margins, and content size in dots. +* **Image Support**: Print Company Logo and a Custom Image (e.g., "Fragile" icon). +* **Barcode Support**: Prints Code128 and Datamatrix/GS1 barcodes. + +## Configuration + +Go to **Settings > Inventory > Citizen ZPL Printer**. + +### 1. Connection +* **IP Address**: The IP address of your printer. +* **Port**: Port number (default is 9100). + +### 2. Label Dimensions (Dots) +Configure the physical size of your label. +* **Formula (203 DPI)**: `1 mm ≈ 8 dots` +* **Example (60mm x 30mm)**: + * Width: 480 dots + * Height: 240 dots + +### 3. Content Settings +* **Font Size**: Height of the text in dots (30 dots ≈ 3.75mm). +* **Barcode Height**: Height of the barcode in dots. +* **Module Width**: Density of the barcode (Default 2). + +### 4. Images +* **Print Company Logo**: Prints the company logo at the bottom center. +* **Custom Image**: Upload an additional image to print at the bottom right. + +## Usage + +1. Open a **Stock Picking** (Transfer) in "Done" state. +2. Click the **Print ZPL Barcode** button. +3. A wizard will open listing all lots in the picking. +4. Adjust the **Copies** (Quantity) for each lot if needed. +5. Click **Print**. + +## Technical Details + +* **DPI**: This module is optimized for 203 DPI printers. For 300 DPI, multiply dot values by ~1.5. +* **ZPL Template**: The ZPL code is generated using QWeb view `citizen_zpl_printer.label_citizen_template_view`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9b42961 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..ee1f013 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,25 @@ +{ + 'name': 'Citizen ZPL Printer', + 'version': '18.0.1.0.0', + 'category': 'Inventory/Hardware', + 'summary': 'Print ZPL labels directly to Citizen Printer via Network', + 'description': """ + This module allows printing ZPL labels directly to a Citizen ZPL printer using its IP address. + It adds a "Print Barcode" button to Inventory Receipts and Manufacturing Orders. + """, + 'author': 'Antigravity', + 'depends': ['stock', 'mrp'], + 'data': [ + 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', + 'views/stock_picking_views.xml', + 'views/mrp_production_views.xml', + 'views/citizen_zpl_report.xml', + 'wizard/citizen_printer_wizard_views.xml', + ], + + + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d365f12 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import stock_picking +from . import mrp_production diff --git a/models/mrp_production.py b/models/mrp_production.py new file mode 100644 index 0000000..49d2156 --- /dev/null +++ b/models/mrp_production.py @@ -0,0 +1,50 @@ +from odoo import models, _ +from odoo.exceptions import UserError +from .printer_utils import send_zpl_to_printer + +class MrpProduction(models.Model): + _inherit = 'mrp.production' + + def action_print_citizen_label(self): + self.ensure_one() + if self.state != 'done': + raise UserError(_("You can only print labels for done manufacturing orders.")) + + # For MRP, we usually have lot_producing_id + lots = self.lot_producing_id + + # Also check if there are other lots produced in case of multi-step or whatever (usually lot_producing_id is the main one) + # If we want all produced lots (e.g. byproducts or split production?), we might look at finished_move_line_ids + + if not lots: + # Try to find from finished moves + lots = self.finished_move_line_ids.mapped('lot_id') + + if not lots: + raise UserError(_("No lots found to print.")) + + # Retrieve printer config + ip = self.company_id.zpl_printer_ip + port = self.company_id.zpl_printer_port or 9100 + + if not ip: + raise UserError(_("Please configure the ZPL Printer IP in Settings.")) + + report = self.env.ref('stock.label_lot_template') + + try: + zpl_data, _ = report._render_qweb_text(lots.ids) + send_zpl_to_printer(ip, port, zpl_data) + except Exception as e: + raise UserError(_("Printing Failed: %s") % str(e)) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Labels sent to printer.'), + 'type': 'success', + 'sticky': False, + } + } diff --git a/models/printer_utils.py b/models/printer_utils.py new file mode 100644 index 0000000..e3d1054 --- /dev/null +++ b/models/printer_utils.py @@ -0,0 +1,82 @@ +import socket +import logging + +_logger = logging.getLogger(__name__) + +def send_zpl_to_printer(ip, port, zpl_data): + """ + Send ZPL data to a printer via socket. + :param ip: Printer IP address (str) + :param port: Printer Port (int) + :param zpl_data: ZPL content (str or bytes) + :return: True if successful, raises Exception otherwise + """ + if not ip: + raise ValueError("Printer IP is not configured.") + + if not zpl_data: + return + + try: + if isinstance(zpl_data, str): + zpl_data = zpl_data.encode('utf-8') + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(5) # 5 seconds timeout + sock.connect((ip, int(port))) + sock.sendall(zpl_data) + _logger.info(f"Sent {len(zpl_data)} bytes to printer at {ip}:{port}") + return True + except Exception as e: + _logger.error(f"Failed to print to {ip}:{port}. Error: {e}") + raise e + +def convert_to_zpl_hex(image_data, width_dots=100, height_dots=100): + """ + Convert image binary data to ZPL hex string (^GFA command). + :param image_data: Binary image data + :param width_dots: Max width in dots + :param height_dots: Max height in dots + :return: ZPL command string (^GFA...) + """ + if not image_data: + return "" + + try: + from PIL import Image + import io + import binascii + + image = Image.open(io.BytesIO(image_data)) + + # Resize preserving aspect ratio + image.thumbnail((width_dots, height_dots), Image.Resampling.LANCZOS) + + # Convert to 1-bit monochrome (dithered) + # Convert to grayscale first, then 1-bit + image = image.convert('L') + image = image.convert('1') + + width, height = image.size + row_bytes = (width + 7) // 8 + total_bytes = row_bytes * height + + # Get bit data + data = image.tobytes() + + # Convert to hex + hex_data = binascii.hexlify(data).upper().decode('utf-8') + + # Create ZPL command + # ^GFA,b,c,d,data + # a = compression type (A=ASCII) + # b = binary byte count + # c = graphic field count (total bytes) + # d = bytes per row + + zpl_cmd = f"^GFA,{total_bytes},{total_bytes},{row_bytes},{hex_data}" + return zpl_cmd + + except Exception as e: + _logger.error(f"Image conversion failed: {e}") + return "" diff --git a/models/res_company.py b/models/res_company.py new file mode 100644 index 0000000..e8dff85 --- /dev/null +++ b/models/res_company.py @@ -0,0 +1,60 @@ +from odoo import fields, models + +class ResCompany(models.Model): + _inherit = "res.company" + + zpl_printer_ip = fields.Char(string="Valid ZPL Printer IP Address") + zpl_printer_port = fields.Integer(string="ZPL Printer Port", default=9100) + + # Label Dimensions (dots) + zpl_label_width = fields.Integer(string="Label Width (dots)", default=480, help="For 203dpi, 1mm ~ 8 dots. 60mm = 480 dots") + zpl_label_height = fields.Integer(string="Label Height (dots)", default=240, help="30mm = 240 dots") + + # Margins + zpl_margin_left = fields.Integer(string="Left Margin", default=10) + zpl_margin_top = fields.Integer(string="Top Margin", default=10) + + # Content Sizing + zpl_font_size = fields.Integer(string="Font Size", default=30) + zpl_barcode_height = fields.Integer(string="Barcode Height", default=100) + zpl_barcode_width = fields.Integer(string="Barcode Module Width", default=2, help="Narrow bar width in dots (1-10)") + + # Images + zpl_print_logo = fields.Boolean(string="Print Company Logo", default=False) + zpl_custom_image = fields.Binary(string="Custom Image") + zpl_custom_image_filename = fields.Char(string="Custom Image Filename") + + def get_zpl_logo_string(self): + self.ensure_one() + if not self.zpl_print_logo or not self.logo: + return "" + + try: + from .printer_utils import convert_to_zpl_hex + import base64 + + # Decode logo + image_data = base64.b64decode(self.logo) + # Resize to reasonable small logo size, e.g. 150x150 dots + zpl_hex = convert_to_zpl_hex(image_data, width_dots=150, height_dots=150) + return zpl_hex + except Exception as e: + return "" + + def get_zpl_custom_image_string(self): + self.ensure_one() + if not self.zpl_custom_image: + return "" + + try: + from .printer_utils import convert_to_zpl_hex + import base64 + + image_data = base64.b64decode(self.zpl_custom_image) + # Resize to reasonable small icon size, e.g. 100x100 dots + zpl_hex = convert_to_zpl_hex(image_data, width_dots=100, height_dots=100) + return zpl_hex + except Exception as e: + return "" + + diff --git a/models/res_config_settings.py b/models/res_config_settings.py new file mode 100644 index 0000000..73b7acc --- /dev/null +++ b/models/res_config_settings.py @@ -0,0 +1,21 @@ +from odoo import fields, models + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + zpl_printer_ip = fields.Char(related='company_id.zpl_printer_ip', readonly=False) + zpl_printer_port = fields.Integer(related='company_id.zpl_printer_port', readonly=False) + + zpl_label_width = fields.Integer(related='company_id.zpl_label_width', readonly=False) + zpl_label_height = fields.Integer(related='company_id.zpl_label_height', readonly=False) + zpl_margin_left = fields.Integer(related='company_id.zpl_margin_left', readonly=False) + zpl_margin_top = fields.Integer(related='company_id.zpl_margin_top', readonly=False) + zpl_font_size = fields.Integer(related='company_id.zpl_font_size', readonly=False) + zpl_barcode_height = fields.Integer(related='company_id.zpl_barcode_height', readonly=False) + zpl_barcode_width = fields.Integer(related='company_id.zpl_barcode_width', readonly=False) + + zpl_print_logo = fields.Boolean(related='company_id.zpl_print_logo', readonly=False) + zpl_custom_image = fields.Binary(related='company_id.zpl_custom_image', readonly=False) + zpl_custom_image_filename = fields.Char(related='company_id.zpl_custom_image_filename', readonly=False) + + diff --git a/models/stock_picking.py b/models/stock_picking.py new file mode 100644 index 0000000..0945785 --- /dev/null +++ b/models/stock_picking.py @@ -0,0 +1,56 @@ +from odoo import models, fields, _ +from odoo.exceptions import UserError + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + def _compute_has_printable_lots(self): + for picking in self: + picking.has_printable_lots = any(picking.move_line_ids.mapped('lot_id')) + + has_printable_lots = fields.Boolean(compute='_compute_has_printable_lots') + + def action_print_citizen_label(self): + self.ensure_one() + if self.state != 'done': + raise UserError(_("You can only print labels for done pickings.")) + + # Filter lines with lots + move_lines_with_lots = self.move_line_ids.filtered(lambda ml: ml.lot_id) + if not move_lines_with_lots: + raise UserError(_("No lots found in this picking to print.")) + + # Group by lots to avoid duplicates if needed, or just print all lines? + # Usually one label per lot. + + lot_ids = move_lines_with_lots.mapped('lot_id') + + if not lot_ids: + raise UserError(_("No lots found.")) + + # Create Wizard + wizard_vals = { + 'picking_id': self.id, + 'line_ids': [], + } + + # Use set to avoid duplicates if move_lines map to same lot multiple times + # But we want to preserve order if possible or just use unique lots + unique_lots = list(set(lot_ids)) + + for lot in unique_lots: + wizard_vals['line_ids'].append((0, 0, { + 'lot_id': lot.id, + 'quantity': 1, + })) + + wizard = self.env['citizen.printer.wizard'].create(wizard_vals) + + return { + 'name': _('Print ZPL Labels'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'citizen.printer.wizard', + 'res_id': wizard.id, + 'target': 'new', + } diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv new file mode 100644 index 0000000..d2bec89 --- /dev/null +++ b/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_citizen_printer_wizard,citizen.printer.wizard,model_citizen_printer_wizard,base.group_user,1,1,1,1 +access_citizen_printer_wizard_line,citizen.printer.wizard.line,model_citizen_printer_wizard_line,base.group_user,1,1,1,1 diff --git a/views/citizen_zpl_report.xml b/views/citizen_zpl_report.xml new file mode 100644 index 0000000..6fcf6b8 --- /dev/null +++ b/views/citizen_zpl_report.xml @@ -0,0 +1,54 @@ + + + + + Citizen ZPL Label + stock.lot + qweb-text + citizen_zpl_printer.label_citizen_template_view + citizen_zpl_printer.label_citizen_template_view + + + + + + diff --git a/views/mrp_production_views.xml b/views/mrp_production_views.xml new file mode 100644 index 0000000..b1801a8 --- /dev/null +++ b/views/mrp_production_views.xml @@ -0,0 +1,17 @@ + + + + mrp.production.form.inherit.citizen.zpl + mrp.production + + +
+
+
+
+
diff --git a/views/res_config_settings_views.xml b/views/res_config_settings_views.xml new file mode 100644 index 0000000..28445e3 --- /dev/null +++ b/views/res_config_settings_views.xml @@ -0,0 +1,73 @@ + + + + res.config.settings.view.form.inherit.citizen.zpl + res.config.settings + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/views/stock_picking_views.xml b/views/stock_picking_views.xml new file mode 100644 index 0000000..25d3d9f --- /dev/null +++ b/views/stock_picking_views.xml @@ -0,0 +1,18 @@ + + + + stock.picking.form.inherit.citizen.zpl + stock.picking + + +
+
+
+
+
diff --git a/wizard/__init__.py b/wizard/__init__.py new file mode 100644 index 0000000..d56ceb8 --- /dev/null +++ b/wizard/__init__.py @@ -0,0 +1 @@ +from . import citizen_printer_wizard diff --git a/wizard/citizen_printer_wizard.py b/wizard/citizen_printer_wizard.py new file mode 100644 index 0000000..fecd466 --- /dev/null +++ b/wizard/citizen_printer_wizard.py @@ -0,0 +1,63 @@ +from odoo import models, fields, _ +from odoo.exceptions import UserError +from ..models.printer_utils import send_zpl_to_printer +import logging + +_logger = logging.getLogger(__name__) + +class CitizenPrinterWizard(models.TransientModel): + _name = 'citizen.printer.wizard' + _description = 'Citizen ZPL Printer Wizard' + + picking_id = fields.Many2one('stock.picking', string="Picking", required=True) + line_ids = fields.One2many('citizen.printer.wizard.line', 'wizard_id', string="Lines") + + def action_print(self): + self.ensure_one() + + # Retrieve printer config + ip = self.picking_id.company_id.zpl_printer_ip + port = self.picking_id.company_id.zpl_printer_port or 9100 + + if not ip: + raise UserError(_("Please configure the ZPL Printer IP in Settings.")) + + report = self.env.ref('citizen_zpl_printer.report_citizen_label') + + # Print logic + try: + for line in self.line_ids: + if line.quantity <= 0: + continue + + # Render ZPL for the single lot + zpl_data, zpl_format = report._render_qweb_text(report.id, [line.lot_id.id]) + + _logger.info(f"Generated ZPL Data: {len(zpl_data)} bytes. Start: {zpl_data[:50]}") + + # Send to printer 'quantity' times + for i in range(line.quantity): + send_zpl_to_printer(ip, port, zpl_data) + + except Exception as e: + raise UserError(_("Printing Failed: %s") % str(e)) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Labels sent to printer.'), + 'type': 'success', + 'sticky': False, + } + } + +class CitizenPrinterWizardLine(models.TransientModel): + _name = 'citizen.printer.wizard.line' + _description = 'Citizen ZPL Printer Wizard Line' + + wizard_id = fields.Many2one('citizen.printer.wizard', string="Wizard") + lot_id = fields.Many2one('stock.lot', string="Lot/Serial Number", required=True, readonly=True) + product_id = fields.Many2one('product.product', string="Product", related='lot_id.product_id', readonly=True) + quantity = fields.Integer(string="Copies", default=1) diff --git a/wizard/citizen_printer_wizard_views.xml b/wizard/citizen_printer_wizard_views.xml new file mode 100644 index 0000000..c36cfad --- /dev/null +++ b/wizard/citizen_printer_wizard_views.xml @@ -0,0 +1,32 @@ + + + + citizen.printer.wizard.form + citizen.printer.wizard + +
+ + + + + + + + + + +
+
+
+
+
+ + + Print ZPL Labels + citizen.printer.wizard + form + new + +