first commit
This commit is contained in:
commit
bca71aff55
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
48
README.md
Normal 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
2
__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
25
__manifest__.py
Normal file
25
__manifest__.py
Normal 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
4
models/__init__.py
Normal 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
50
models/mrp_production.py
Normal 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
82
models/printer_utils.py
Normal 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
60
models/res_company.py
Normal 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 ""
|
||||||
|
|
||||||
|
|
||||||
21
models/res_config_settings.py
Normal file
21
models/res_config_settings.py
Normal 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
56
models/stock_picking.py
Normal 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',
|
||||||
|
}
|
||||||
3
security/ir.model.access.csv
Normal file
3
security/ir.model.access.csv
Normal 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
|
||||||
|
54
views/citizen_zpl_report.xml
Normal file
54
views/citizen_zpl_report.xml
Normal 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>
|
||||||
17
views/mrp_production_views.xml
Normal file
17
views/mrp_production_views.xml
Normal 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>
|
||||||
73
views/res_config_settings_views.xml
Normal file
73
views/res_config_settings_views.xml
Normal 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 & 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 & 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>
|
||||||
18
views/stock_picking_views.xml
Normal file
18
views/stock_picking_views.xml
Normal 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
1
wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import citizen_printer_wizard
|
||||||
63
wizard/citizen_printer_wizard.py
Normal file
63
wizard/citizen_printer_wizard.py
Normal 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)
|
||||||
32
wizard/citizen_printer_wizard_views.xml
Normal file
32
wizard/citizen_printer_wizard_views.xml
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user