first commit

This commit is contained in:
Suherdy Yacob 2026-01-26 11:26:45 +07:00
commit bca71aff55
18 changed files with 621 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Python
__pycache__/
*.py[cod]
*$py.class
# Mac
.DS_Store
# Odoo
*.hot-update.js
*.hot-update.json
.odoo_nb/

48
README.md Normal file
View File

@ -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`.

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import models
from . import wizard

25
__manifest__.py Normal file
View File

@ -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',
}

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from . import res_company
from . import res_config_settings
from . import stock_picking
from . import mrp_production

50
models/mrp_production.py Normal file
View File

@ -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,
}
}

82
models/printer_utils.py Normal file
View File

@ -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 ""

60
models/res_company.py Normal file
View File

@ -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 ""

View File

@ -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)

56
models/stock_picking.py Normal file
View File

@ -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',
}

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_citizen_printer_wizard citizen.printer.wizard model_citizen_printer_wizard base.group_user 1 1 1 1
3 access_citizen_printer_wizard_line citizen.printer.wizard.line model_citizen_printer_wizard_line base.group_user 1 1 1 1

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="report_citizen_label" model="ir.actions.report">
<field name="name">Citizen ZPL Label</field>
<field name="model">stock.lot</field>
<field name="report_type">qweb-text</field>
<field name="report_name">citizen_zpl_printer.label_citizen_template_view</field>
<field name="report_file">citizen_zpl_printer.label_citizen_template_view</field>
<field name="binding_model_id" eval="False"/>
</record>
<template id="label_citizen_template_view">
<t t-foreach="docs" t-as="lot">
<t t-translation="off">
^XA^CI28
^PW<t t-esc="res_company.zpl_label_width"/>
^LL<t t-esc="res_company.zpl_label_height"/>
^FO<t t-esc="res_company.zpl_margin_left"/>,<t t-esc="res_company.zpl_margin_top"/>
^A0N,<t t-esc="res_company.zpl_font_size"/>,<t t-esc="int(res_company.zpl_font_size * 0.75)"/>^FD<t t-out="lot.product_id.display_name"/>^FS
^FO<t t-esc="res_company.zpl_margin_left"/>,<t t-esc="res_company.zpl_margin_top + res_company.zpl_font_size + 10"/>
^A0N,<t t-esc="res_company.zpl_font_size"/>,<t t-esc="int(res_company.zpl_font_size * 0.75)"/>^FDLN/SN: <t t-out="lot.name"/>^FS
<t t-set="barcode_y" t-value="res_company.zpl_margin_top + (res_company.zpl_font_size * 2) + 20"/>
<t t-if="env.user.has_group('stock.group_stock_lot_print_gs1')">
<t t-set="final_barcode" t-value="''" />
<t t-if="lot.product_id.valid_ean" t-set="final_barcode" t-value="'01' + '0' * (14 - len(lot.product_id.barcode)) + lot.product_id.barcode"/>
<t t-if="lot.product_id.tracking == 'lot'" name="datamatrix_lot" t-set="final_barcode" t-value="(final_barcode or '') + '10' + lot.name"/>
<t t-elif="lot.product_id.tracking == 'serial'" t-set="final_barcode" t-value="(final_barcode or '') + '21' + lot.name"/>
^FO<t t-esc="res_company.zpl_margin_left"/>,<t t-esc="barcode_y"/>^BY<t t-esc="res_company.zpl_barcode_width"/>
^BXN,<t t-esc="int(res_company.zpl_barcode_height / 10)"/>,200
^FD<t t-out="final_barcode"/>^FS
</t>
<t t-else="" name="code128_barcode">
^FO<t t-esc="res_company.zpl_margin_left"/>,<t t-esc="barcode_y"/>^BY<t t-esc="res_company.zpl_barcode_width"/>
^BCN,<t t-esc="res_company.zpl_barcode_height"/>,Y,N,N
^FD<t t-out="lot.name"/>^FS
</t>
<t t-set="logo_x" t-value="res_company.zpl_margin_left + 200"/>
<t t-set="custom_x" t-value="res_company.zpl_margin_left + 350"/>
<t t-set="img_y" t-value="barcode_y"/>
<t t-out="'^FO%s,%s' % (logo_x, img_y)"/>
<t t-out="(lot.company_id or res_company).get_zpl_logo_string()"/>
<t t-out="'^FO%s,%s' % (custom_x, img_y)"/>
<t t-out="(lot.company_id or res_company).get_zpl_custom_image_string()"/>
^XZ
</t>
</t>
</template>
</data>
</odoo>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mrp_production_form_view_inherit_citizen_zpl" model="ir.ui.view">
<field name="name">mrp.production.form.inherit.citizen.zpl</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
<field name="arch" type="xml">
<header position="inside">
<button name="action_print_citizen_label"
string="Print ZPL Barcode"
type="object"
class="oe_highlight"
invisible="state != 'done'"/>
</header>
</field>
</record>
</odoo>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.citizen.zpl</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="stock.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//block[@name='barcode_setting_container']" position="after">
<block title="Citizen ZPL Printer" id="citizen_zpl_printer_settings">
<setting help="Configure the ZPL Printer IP Address and Port">
<field name="zpl_printer_ip" placeholder="e.g. 192.168.1.100"/>
<field name="zpl_printer_port"/>
</setting>
<setting help="Configure Label Dimensions (in dots). 203 DPI: 1mm ~ 8 dots.">
<label for="zpl_label_width" string="Label Size"/>
<div class="text-muted">
Width: <field name="zpl_label_width" class="oe_inline"/> dots
Height: <field name="zpl_label_height" class="oe_inline"/> dots
</div>
</setting>
<setting help="Configure Label Margins (in dots)">
<label for="zpl_margin_left" string="Margins"/>
<div class="text-muted">
Left: <field name="zpl_margin_left" class="oe_inline"/> dots
Top: <field name="zpl_margin_top" class="oe_inline"/> dots
</div>
</setting>
<setting help="Configure Content Sizing (in dots)">
<label for="zpl_font_size" string="Content Size"/>
<div class="text-muted">
Font Size: <field name="zpl_font_size" class="oe_inline"/>
Barcode Height: <field name="zpl_barcode_height" class="oe_inline"/>
Module Width: <field name="zpl_barcode_width" class="oe_inline"/>
</div>
</setting>
<setting help="Configure Image Printing">
<label for="zpl_print_logo" string="Image Settings"/>
<div class="content-group">
<div class="row mt16">
<label for="zpl_print_logo" class="col-lg-3 o_light_label"/>
<field name="zpl_print_logo"/>
</div>
<div class="row mt16">
<label for="zpl_custom_image" class="col-lg-3 o_light_label"/>
<field name="zpl_custom_image" filename="zpl_custom_image_filename"/>
<field name="zpl_custom_image_filename" invisible="1"/>
</div>
</div>
</setting>
<setting string="Configuration Help">
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">Configuration Guide (203 DPI)</h4>
<hr/>
<strong>1. Label Size &amp; Margins (Dots vs CM/MM)</strong><br/>
<ul>
<li><strong>Formula:</strong> <code>1 mm ≈ 8 dots</code> (e.g., 60mm = 480 dots)</li>
<li><strong>Left/Top Margin:</strong> Defines whitespace. <code>8 dots ≈ 1mm</code>.</li>
</ul>
<strong>2. Content Size (Font &amp; Barcode)</strong><br/>
<ul>
<li><strong>Font Size:</strong> Height of text. <code>30 dots ≈ 3.75mm</code>.</li>
<li><strong>Barcode Height:</strong> Height of bars. <code>100 dots ≈ 12.5mm</code>.</li>
<li><strong>Module Width:</strong> Density. <code>2</code> is standard, <code>3+</code> is wider/easier to scan.</li>
</ul>
</div>
</setting>
</block>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_form_inherit_citizen_zpl" model="ir.ui.view">
<field name="name">stock.picking.form.inherit.citizen.zpl</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="stock.view_picking_form"/>
<field name="arch" type="xml">
<header position="inside">
<button name="action_print_citizen_label"
string="Print ZPL Barcode"
type="object"
class="oe_highlight"
invisible="state != 'done' or not has_printable_lots"/>
<field name="has_printable_lots" invisible="1"/>
</header>
</field>
</record>
</odoo>

1
wizard/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import citizen_printer_wizard

View File

@ -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)

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_citizen_printer_wizard_form" model="ir.ui.view">
<field name="name">citizen.printer.wizard.form</field>
<field name="model">citizen.printer.wizard</field>
<field name="arch" type="xml">
<form string="Print ZPL Labels">
<group>
<field name="picking_id" invisible="1"/>
</group>
<field name="line_ids">
<list editable="bottom" create="0" delete="0">
<field name="lot_id"/>
<field name="product_id"/>
<field name="quantity"/>
</list>
</field>
<footer>
<button string="Print" name="action_print" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_citizen_printer_wizard" model="ir.actions.act_window">
<field name="name">Print ZPL Labels</field>
<field name="res_model">citizen.printer.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>