diff --git a/pos_printing_qztray/README.rst b/pos_printing_qztray/README.rst new file mode 100644 index 0000000000..b03e9fcb06 --- /dev/null +++ b/pos_printing_qztray/README.rst @@ -0,0 +1,174 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=============== +POS Printing QZ +=============== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:68e17147c1e7faf5af8e7828307f92c4d60fcc4e3149d420bf727daf51b96d44 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpos-lightgray.png?logo=github + :target: https://github.com/OCA/pos/tree/19.0/pos_printing_qztray + :alt: OCA/pos +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/pos-19-0/pos-19-0-pos_printing_qztray + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/pos&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a pure-software alternative to the Odoo IoT Box for +thermal receipt printing in the Point of Sale, using [QZ +Tray](https://qz.io/) as the print driver. + +It extends base_report_to_printer_qz — which adds QZ Tray backend +support to printing.printer — and brings that functionality into the +POS, allowing users to select a printer with backend = qztray directly +from the POS configuration. + +Key features: + +- **Server-side ESC/POS receipt template**: a full receipt template in + controllers/main.py generates ESC/POS commands using python-escpos. + The template is easily extendable and inheritable from Python without + touching any frontend code. +- **PNG fallback**: when no order can be matched (e.g. reprints from the + Ticket Screen), the standard HTML receipt is rendered to a raster PNG + and converted to ESC/POS format. +- **Cash drawer support**: the cash drawer open command (ESC p) is sent + automatically after each receipt print. +- **No IoT Box required**: designed as a software-only alternative for + setups where deploying IoT infrastructure is not desirable. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module requires the following non-Python dependency: + +- [QZ Tray](https://qz.io/) must be installed and running on the client + machine that operates the POS. + +Odoo module dependency (must be installed first): + +- base_report_to_printer_qz — provides the QZ Tray printer backend and + the printing.printer model with backend = qztray support. + +Python dependencies (installed automatically via external_dependencies): + +- python-escpos + +Configuration +============= + +To configure this module, you need to: + +1. Install and launch the [QZ Tray](https://qz.io/) desktop application + on the machine where the thermal printer is connected. +2. In Odoo, go to *Point of Sale* > *Configuration* > *Settings*. +3. Select your POS configuration and enable **QZ Tray Printing**. +4. Optionally select a **QZ Tray Printer** from the list — only printers + with backend = qztray (managed by base_report_to_printer_qz) are + shown. If left empty, QZ Tray will use the system default printer. +5. Save the settings and open the POS session. + +To extend or customise the receipt template, inherit PosEscposController +in your own module and override the relevant \_get\_\* methods (e.g. +\_get_header, \_get_order_lines). No frontend changes are required. + +Usage +===== + +To use this module, you need to: + +1. Open a POS session that has **QZ Tray Printing** enabled. +2. Make sure the QZ Tray desktop application is running on the POS + machine. +3. Process a sale and click **Payment** to print the receipt. + +The receipt is sent directly to the thermal printer via QZ Tray using +ESC/POS commands generated server-side. The cash drawer is opened +automatically after each print. + +If the order cannot be matched (e.g. when reprinting from the *Ticket +Screen*), the module falls back to rendering the standard HTML receipt +as a PNG image and converting it to ESC/POS format. + +No IoT Box or network printer configuration is required. + +Known issues / Roadmap +====================== + + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* APSL Nagarro + +Contributors +------------ + +- Miquel Alzanillas + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-miquelalzanillas| image:: https://github.com/miquelalzanillas.png?size=40px + :target: https://github.com/miquelalzanillas + :alt: miquelalzanillas + +Current `maintainer `__: + +|maintainer-miquelalzanillas| + +This module is part of the `OCA/pos `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/pos_printing_qztray/__init__.py b/pos_printing_qztray/__init__.py new file mode 100644 index 0000000000..f4a659efb5 --- /dev/null +++ b/pos_printing_qztray/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import models +from . import controllers diff --git a/pos_printing_qztray/__manifest__.py b/pos_printing_qztray/__manifest__.py new file mode 100644 index 0000000000..86ef26c47f --- /dev/null +++ b/pos_printing_qztray/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2026 Miquel Alzanillas +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "POS Printing QZ", + "summary": "POS receipt printing via QZ Tray", + "version": "19.0.1.0.0", + # see https://odoo-community.org/page/development-status + "development_status": "Alpha", + "category": "Point Of Sale", + "website": "https://github.com/OCA/pos", + "author": "APSL Nagarro, Odoo Community Association (OCA)", + "maintainers": ["miquelalzanillas"], + "license": "AGPL-3", + "application": False, + "installable": True, + "external_dependencies": {"python": ["python-escpos"]}, + "depends": [ + "point_of_sale", + "base_report_to_printer_qztray", + ], + "data": [ + "views/pos_config_views.xml", + ], + "assets": { + "point_of_sale._assets_pos": [ + "pos_printing_qztray/static/src/app/printer/qz_tray_connection.esm.js", + "pos_printing_qztray/static/src/app/printer/qz_tray_printer.esm.js", + "pos_printing_qztray/static/src/app/services/qz_tray_printer_service.esm.js", + ], + }, +} diff --git a/pos_printing_qztray/controllers/__init__.py b/pos_printing_qztray/controllers/__init__.py new file mode 100644 index 0000000000..f43232f012 --- /dev/null +++ b/pos_printing_qztray/controllers/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import main diff --git a/pos_printing_qztray/controllers/main.py b/pos_printing_qztray/controllers/main.py new file mode 100644 index 0000000000..cf19606bd2 --- /dev/null +++ b/pos_printing_qztray/controllers/main.py @@ -0,0 +1,263 @@ +import base64 +import io + +from escpos.printer import Dummy +from PIL import Image + +from odoo import http +from odoo.http import request + + +class PosEscposController(http.Controller): + @http.route("/pos/escpos/render-image", type="jsonrpc", auth="user") + def render_any_report(self, png_base64, width_mm=80): + p = Dummy() + try: + img_bytes = base64.b64decode(png_base64) + img = Image.open(io.BytesIO(img_bytes)) + except Exception as e: + return {"error": f"Invalid image: {e}"} + img = img.convert("1") + if width_mm == 58: + target_width = 384 + else: + target_width = 576 + w_percent = target_width / float(img.width) + h_size = int(float(img.height) * float(w_percent)) + img = img.resize((target_width, h_size), Image.LANCZOS) + try: + p.set(align="center") + p.image(img) + p.textln("") + except Exception as e: + return {"error": f"ESC/POS image error: {e}"} + p.cut() + return base64.b64encode(p.output).decode("utf-8") + + @http.route("/pos/escpos/receipt", type="jsonrpc", auth="user") + def render_template(self, order_id): + order = request.env["pos.order"].search( + [("pos_reference", "ilike", "%" + order_id + "%")], limit=1 + ) + if not order.exists(): + return {"error": "Order not found"} + company = order.company_id + currency = order.currency_id + LINE_WIDTH = 46 + p = Dummy() + self._get_logo(p, company, LINE_WIDTH) + self._get_header(p, company) + self._get_order_info(p, order, LINE_WIDTH) + self._get_order_lines(p, order, currency, LINE_WIDTH) + self._get_payments(p, order, currency, LINE_WIDTH) + self._get_tax_detail(p, order, currency, LINE_WIDTH) + self._get_footer(p, order, LINE_WIDTH) + + p.cut() + return base64.b64encode(p.output).decode("utf-8") + + def _table_columns(self, p, text_list, widths, align): + cols = [] + for i, text in enumerate(text_list): + width = widths[min(i, len(widths) - 1)] + alignment = align[min(i, len(align) - 1)] + text = str(text) + + # Recortar si excede + if len(text) > width: + text = text[:width] + + # Alinear + if alignment == "right": + text = text.rjust(width) + elif alignment == "center": + text = text.center(width) + else: + text = text.ljust(width) + + cols.append(text) + + p.textln("".join(cols)) + + def _get_logo(self, p, company, LINE_WIDTH): + if company.logo: + image_data = base64.b64decode(company.logo) + p.set(align="center") + p.image(io.BytesIO(image_data)) + p.textln("") + p.textln("" * LINE_WIDTH) + + def _get_header(self, p, company): + p.set(align="center", bold=True, width=2, height=2) + p.textln(company.name) + if company.vat: + p.textln(company.vat) + p.set(align="center") + if company.phone: + p.textln(request.env._("Tel: %(phone)s", phone=company.phone)) + if company.email: + p.textln(company.email) + if company.website: + p.textln(company.website) + + def _get_order_info(self, p, order, LINE_WIDTH): + p.textln("" * LINE_WIDTH) + p.set(align="center") + p.textln(request.env._("Served by %(name)s", name=order.user_id.name)) + p.textln(order.name) + + if order.tracking_number: + p.set(double_height=True, double_width=True) + p.textln(order.tracking_number) + p.set(normal_textsize=True) + if order.partner_id: + p.textln("" * LINE_WIDTH) + p.set(bold=True) + p.textln(request.env._("CUSTOMER")) + p.set(normal_textsize=True) + p.textln(order.partner_id.display_name) + p.textln(order.partner_id.street or "") + p.textln(f"{order.partner_id.city or ''} {order.partner_id.zip or ''}") + p.textln(order.partner_id.country_id.display_name or "") + p.textln(order.partner_id.vat or "") + p.textln("" * LINE_WIDTH) + + p.textln("" * LINE_WIDTH) + p.textln("" * LINE_WIDTH) + + def _get_order_lines(self, p, order, currency, LINE_WIDTH): + for line in order.lines: + name = line.product_id.display_name[:30] + line_amount = line.price_subtotal_incl + price = f"{line.price_unit:.2f}" + + if currency.position == "after": + line_amount = f"{line_amount:.2f} {currency.symbol}" + price = f"{price} {currency.symbol}" + else: + line_amount = f"{currency.symbol} {line_amount:.2f}" + price = f"{currency.symbol} {price}" + + qty_measure = line.product_id.uom_id.name or request.env._("unit(s)") + spaces = (LINE_WIDTH - 1) - len(name) - len(price) + + p.set(align="left", bold=True) + p.textln(f"{name}{' ' * spaces}{line_amount}") + p.set(bold=False, width=1, height=1) + p.textln( + request.env._( + "%(qty).2f %(um)s x %(price)s", + qty=line.qty, + um=qty_measure, + price=price, + ) + ) + + p.set(align="center") + p.textln("-" * LINE_WIDTH) + + p.set(align="left", bold=True, width=1, height=1) + if currency.position == "after": + amount_total = f"{order.amount_total:.2f} {currency.symbol}" + else: + amount_total = f"{currency.symbol} {order.amount_total:.2f}" + + total_label = request.env._("TOTAL") + spaces = LINE_WIDTH - len(total_label) - len(amount_total) + p.set(custom_size=True, width=1, height=2) + p.textln(f"{total_label}{' ' * spaces}{amount_total}") + p.set(align="left", bold=False, normal_textsize=True) + + def _get_payments(self, p, order, currency, LINE_WIDTH): + payments = [] + change_lines = [] + + for pmt in order.payment_ids: + if pmt.is_change: + change_lines.append(pmt) + else: + payments.append(pmt) + + for pmt in payments: + p.set(align="left", bold=False, width=1, height=1) + name = pmt.payment_method_id.display_name[:30] or "" + + if currency.position == "after": + amt = f"{pmt.amount:.2f} {currency.symbol}" + else: + amt = f"{currency.symbol} {pmt.amount:.2f}" + + spaces = LINE_WIDTH - len(name) - len(amt) + p.textln(f"{name}{' ' * spaces}{amt}") + + for pmt in change_lines: + p.set(bold=True, width=2, height=2) + name = request.env._("CHANGE") + if currency.position == "after": + amt = f"{pmt.amount:.2f} {currency.symbol}" + else: + amt = f"{currency.symbol} {pmt.amount:.2f}" + + spaces = LINE_WIDTH - len(name) - len(amt) + p.textln(f"{name}{' ' * spaces}{amt}") + + p.set(normal_textsize=True) + p.textln("-" * LINE_WIDTH) + + def _get_tax_detail(self, p, order, currency, LINE_WIDTH): + tax_summary = {} + for line in order.lines: + base = line.price_subtotal + total = line.price_subtotal_incl + tax_amount = total - base + + for tax in line.tax_ids_after_fiscal_position: + if tax.name not in tax_summary: + tax_summary[tax.description] = {"base": 0.0, "tax": 0.0} + tax_summary[tax.description]["base"] += base + tax_summary[tax.description]["tax"] += tax_amount + + if not tax_summary: + return + + p.textln("") + p.textln("-" * LINE_WIDTH) + + widths = [20, 12, 12] + align = ["left", "center", "center"] + + for tax_name, amounts in tax_summary.items(): + base = amounts["base"] + tax_amt = amounts["tax"] + + if currency.position == "after": + base_str = f"{base:.2f} {currency.symbol}" + tax_str = f"{tax_amt:.2f} {currency.symbol}" + else: + base_str = f"{currency.symbol} {base:.2f}" + tax_str = f"{currency.symbol} {tax_amt:.2f}" + + row = [ + tax_name[:20], + base_str, + tax_str, + ] + column_titles = [ + request.env._("Tax"), + request.env._("Base"), + request.env._("Tax Amount"), + ] + self._table_columns(p, column_titles, widths, ["left", "center", "center"]) + self._table_columns(p, row, widths, align) + + p.textln("-" * LINE_WIDTH) + + def _get_footer(self, p, order, LINE_WIDTH): + p.set(align="center") + p.textln(order.pos_reference) + p.textln(order.date_order.strftime("%Y-%m-%d %H:%M:%S")) + if order.company_id.point_of_sale_use_ticket_qr_code: + p.textln("") + p.textln(request.env._("Need an invoice? Scan the QR code")) + p.qr(f"{request.httprequest.host_url}pos/receipt/{order.id}", size=8) + p.textln(request.env._("Code: ") + (order.ticket_code or "")) diff --git a/pos_printing_qztray/i18n/es.po b/pos_printing_qztray/i18n/es.po new file mode 100644 index 0000000000..388a70ccc1 --- /dev/null +++ b/pos_printing_qztray/i18n/es.po @@ -0,0 +1,117 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_printing_qz +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-11-24 11:52+0000\n" +"PO-Revision-Date: 2025-11-24 11:52+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_printing_qz +#: model:ir.model.fields,field_description:pos_printing_qz.field_pos_config__is_qztray +msgid "Enable QZ Tray Printing" +msgstr "Activar impresión con QZ Tray" + +#. module: pos_printing_qz +#: model:ir.model,name:pos_printing_qz.model_pos_config +msgid "Point of Sale Configuration" +msgstr "Configuración del TPV" + +#. module: pos_printing_qz +#: model_terms:ir.ui.view,arch_db:pos_printing_qz.view_pos_config_form_qztray +msgid "Print receipts directly to a local printer using QZTray" +msgstr "Imprimir recibos directamente a una impresora local usando QZTray" + +#. module: pos_printing_qz +#: model:ir.model.fields,help:pos_printing_qz.field_pos_config__iface_qztray_printer_id +msgid "Printer to use for POS receipts when QZ Tray mode is enabled." +msgstr "" +"Impresora a utilizar para los recibos del TPV cuando el modo QZ Tray está " +"activado." + +#. module: pos_printing_qz +#: model:ir.model.fields,field_description:pos_printing_qz.field_pos_config__iface_qztray_printer_id +msgid "QZ Tray Printer" +msgstr "" + +#. module: pos_printing_qz +#: model_terms:ir.ui.view,arch_db:pos_printing_qz.view_pos_config_form_qztray +msgid "QZTray" +msgstr "" + +#. module: pos_printing_qz +#: model_terms:ir.ui.view,arch_db:pos_printing_qz.view_pos_config_form_qztray +msgid "QZTray Receipt Printer" +msgstr "" + +#. module: pos_printing_qz +#: model_terms:ir.ui.view,arch_db:pos_printing_qz.view_pos_config_form_qztray +msgid "Cashdrawer" +msgstr "Cajón portamonedas" + +#. module: pos_printing_qz +#: model:ir.model.fields,help:pos_printing_qz.field_pos_config__is_qztray +msgid "Use QZ Tray instead of IoT Box for printing POS receipts." +msgstr "" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Tax" +msgstr "Impuesto" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Base" +msgstr "B.I." + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Tax Amount" +msgstr "Cuota" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "CUSTOMER" +msgstr "CLIENTE" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "CHANGE" +msgstr "CAMBIO" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Served by %s" +msgstr "Servido por %s" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Cash" +msgstr "Efectivo" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Need an invoice? Scan the QR code" +msgstr "¿Necesita una factura? Escanee el código QR" + +#. module: pos_printing_qz +#. odoo-python +#: code:addons/pos_printing_qz/controllers/main.py:0 +msgid "Code: " +msgstr "Código: " \ No newline at end of file diff --git a/pos_printing_qztray/models/__init__.py b/pos_printing_qztray/models/__init__.py new file mode 100644 index 0000000000..c87f24f39a --- /dev/null +++ b/pos_printing_qztray/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import pos_config diff --git a/pos_printing_qztray/models/pos_config.py b/pos_printing_qztray/models/pos_config.py new file mode 100644 index 0000000000..d2750c6b25 --- /dev/null +++ b/pos_printing_qztray/models/pos_config.py @@ -0,0 +1,18 @@ +from odoo import fields, models + + +class PosConfig(models.Model): + _inherit = "pos.config" + + is_qztray = fields.Boolean( + string="Enable QZ Tray Printing", + help="Use QZ Tray instead of IoT Box for printing POS receipts.", + default=False, + ) + + iface_qztray_printer_id = fields.Many2one( + comodel_name="printing.printer", + string="QZ Tray Printer", + domain=[("backend", "=", "qztray")], + help="Printer to use for POS receipts when QZ Tray mode is enabled.", + ) diff --git a/pos_printing_qztray/pyproject.toml b/pos_printing_qztray/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/pos_printing_qztray/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/pos_printing_qztray/readme/CONFIGURE.md b/pos_printing_qztray/readme/CONFIGURE.md new file mode 100644 index 0000000000..d5bbd6a94a --- /dev/null +++ b/pos_printing_qztray/readme/CONFIGURE.md @@ -0,0 +1,15 @@ +To configure this module, you need to: + +1. Install and launch the \[QZ Tray\]() desktop + application on the machine where the thermal printer is connected. +2. In Odoo, go to *Point of Sale* \> *Configuration* \> *Settings*. +3. Select your POS configuration and enable **QZ Tray Printing**. +4. Optionally select a **QZ Tray Printer** from the list — only + printers with backend = qztray (managed by + base_report_to_printer_qz) are shown. If left empty, QZ Tray will + use the system default printer. +5. Save the settings and open the POS session. + +To extend or customise the receipt template, inherit PosEscposController +in your own module and override the relevant \_get\_\* methods (e.g. +\_get_header, \_get_order_lines). No frontend changes are required. diff --git a/pos_printing_qztray/readme/CONTRIBUTORS.md b/pos_printing_qztray/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..30642a1803 --- /dev/null +++ b/pos_printing_qztray/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Miquel Alzanillas \ diff --git a/pos_printing_qztray/readme/DESCRIPTION.md b/pos_printing_qztray/readme/DESCRIPTION.md new file mode 100644 index 0000000000..50714284fd --- /dev/null +++ b/pos_printing_qztray/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +This module provides a pure-software alternative to the Odoo IoT Box for +thermal receipt printing in the Point of Sale, using \[QZ +Tray\]() as the print driver. + +It extends base_report_to_printer_qz — which adds QZ Tray backend +support to printing.printer — and brings that functionality into the +POS, allowing users to select a printer with backend = qztray directly +from the POS configuration. + +Key features: + +- **Server-side ESC/POS receipt template**: a full receipt template in + controllers/main.py generates ESC/POS commands using python-escpos. + The template is easily extendable and inheritable from Python without + touching any frontend code. +- **PNG fallback**: when no order can be matched (e.g. reprints from the + Ticket Screen), the standard HTML receipt is rendered to a raster PNG + and converted to ESC/POS format. +- **Cash drawer support**: the cash drawer open command (ESC p) is sent + automatically after each receipt print. +- **No IoT Box required**: designed as a software-only alternative for + setups where deploying IoT infrastructure is not desirable. diff --git a/pos_printing_qztray/readme/INSTALL.md b/pos_printing_qztray/readme/INSTALL.md new file mode 100644 index 0000000000..deb3fa61e2 --- /dev/null +++ b/pos_printing_qztray/readme/INSTALL.md @@ -0,0 +1,13 @@ +This module requires the following non-Python dependency: + +- \[QZ Tray\]() must be installed and running on the + client machine that operates the POS. + +Odoo module dependency (must be installed first): + +- base_report_to_printer_qz — provides the QZ Tray printer backend and + the printing.printer model with backend = qztray support. + +Python dependencies (installed automatically via external_dependencies): + +- python-escpos diff --git a/pos_printing_qztray/readme/ROADMAP.md b/pos_printing_qztray/readme/ROADMAP.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pos_printing_qztray/readme/USAGE.md b/pos_printing_qztray/readme/USAGE.md new file mode 100644 index 0000000000..0b1cb0b79a --- /dev/null +++ b/pos_printing_qztray/readme/USAGE.md @@ -0,0 +1,16 @@ +To use this module, you need to: + +1. Open a POS session that has **QZ Tray Printing** enabled. +2. Make sure the QZ Tray desktop application is running on the POS + machine. +3. Process a sale and click **Payment** to print the receipt. + +The receipt is sent directly to the thermal printer via QZ Tray using +ESC/POS commands generated server-side. The cash drawer is opened +automatically after each print. + +If the order cannot be matched (e.g. when reprinting from the *Ticket +Screen*), the module falls back to rendering the standard HTML receipt +as a PNG image and converting it to ESC/POS format. + +No IoT Box or network printer configuration is required. diff --git a/pos_printing_qztray/readme/newsfragments/.gitkeep b/pos_printing_qztray/readme/newsfragments/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pos_printing_qztray/static/description/icon.png b/pos_printing_qztray/static/description/icon.png new file mode 100644 index 0000000000..1dcc49c24f Binary files /dev/null and b/pos_printing_qztray/static/description/icon.png differ diff --git a/pos_printing_qztray/static/description/icon.svg b/pos_printing_qztray/static/description/icon.svg new file mode 100644 index 0000000000..ed6aaa04e4 --- /dev/null +++ b/pos_printing_qztray/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pos_printing_qztray/static/description/index.html b/pos_printing_qztray/static/description/index.html new file mode 100644 index 0000000000..ed4995685d --- /dev/null +++ b/pos_printing_qztray/static/description/index.html @@ -0,0 +1,515 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

POS Printing QZ

+ +

Alpha License: AGPL-3 OCA/pos Translate me on Weblate Try me on Runboat

+

This module provides a pure-software alternative to the Odoo IoT Box for +thermal receipt printing in the Point of Sale, using [QZ +Tray](https://qz.io/) as the print driver.

+

It extends base_report_to_printer_qz — which adds QZ Tray backend +support to printing.printer — and brings that functionality into the +POS, allowing users to select a printer with backend = qztray directly +from the POS configuration.

+

Key features:

+
    +
  • Server-side ESC/POS receipt template: a full receipt template in +controllers/main.py generates ESC/POS commands using python-escpos. +The template is easily extendable and inheritable from Python without +touching any frontend code.
  • +
  • PNG fallback: when no order can be matched (e.g. reprints from the +Ticket Screen), the standard HTML receipt is rendered to a raster PNG +and converted to ESC/POS format.
  • +
  • Cash drawer support: the cash drawer open command (ESC p) is sent +automatically after each receipt print.
  • +
  • No IoT Box required: designed as a software-only alternative for +setups where deploying IoT infrastructure is not desirable.
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Installation

+

This module requires the following non-Python dependency:

+
    +
  • [QZ Tray](https://qz.io/) must be installed and running on the client +machine that operates the POS.
  • +
+

Odoo module dependency (must be installed first):

+
    +
  • base_report_to_printer_qz — provides the QZ Tray printer backend and +the printing.printer model with backend = qztray support.
  • +
+

Python dependencies (installed automatically via external_dependencies):

+
    +
  • python-escpos
  • +
+
+
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Install and launch the [QZ Tray](https://qz.io/) desktop application +on the machine where the thermal printer is connected.
  2. +
  3. In Odoo, go to Point of Sale > Configuration > Settings.
  4. +
  5. Select your POS configuration and enable QZ Tray Printing.
  6. +
  7. Optionally select a QZ Tray Printer from the list — only printers +with backend = qztray (managed by base_report_to_printer_qz) are +shown. If left empty, QZ Tray will use the system default printer.
  8. +
  9. Save the settings and open the POS session.
  10. +
+

To extend or customise the receipt template, inherit PosEscposController +in your own module and override the relevant _get_* methods (e.g. +_get_header, _get_order_lines). No frontend changes are required.

+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Open a POS session that has QZ Tray Printing enabled.
  2. +
  3. Make sure the QZ Tray desktop application is running on the POS +machine.
  4. +
  5. Process a sale and click Payment to print the receipt.
  6. +
+

The receipt is sent directly to the thermal printer via QZ Tray using +ESC/POS commands generated server-side. The cash drawer is opened +automatically after each print.

+

If the order cannot be matched (e.g. when reprinting from the Ticket +Screen), the module falls back to rendering the standard HTML receipt +as a PNG image and converting it to ESC/POS format.

+

No IoT Box or network printer configuration is required.

+
+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • APSL Nagarro
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

miquelalzanillas

+

This module is part of the OCA/pos project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/pos_printing_qztray/static/src/app/printer/qz_tray_connection.esm.js b/pos_printing_qztray/static/src/app/printer/qz_tray_connection.esm.js new file mode 100644 index 0000000000..1a82612fe6 --- /dev/null +++ b/pos_printing_qztray/static/src/app/printer/qz_tray_connection.esm.js @@ -0,0 +1,74 @@ +/** @odoo-module **/ +/* global */ + +export class QZConnection { + static async _ensureLoaded() { + if (window.qz) return true; + // Fallback: dynamically load qz-tray.js if POS runs in isolated iframe + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "/base_report_to_printer_qz/static/src/lib/qz-tray.js"; + script.type = "text/javascript"; + script.onload = () => { + console.info("[QZ Tray] dynamically loaded for POS iframe"); + resolve(true); + }; + script.onerror = () => { + console.error("[QZ Tray] failed to load"); + reject(new Error("QZ Tray script could not be loaded")); + }; + document.head.appendChild(script); + }); + } + + static async connect(host = null) { + await this._ensureLoaded(); + if (!window.qz) throw new Error("QZ Tray is not available"); + const opts = host ? {host} : {}; + if (!window.qz.websocket.isActive()) { + await window.qz.websocket.connect(opts); + } + } + + static async disconnect() { + if (window.qz?.websocket?.isActive()) { + await window.qz.websocket.disconnect(); + } + } + + static async print(printerName, data, opts = {}) { + await this._ensureLoaded(); + const qz = window.qz; + await qz.security.setCertificatePromise((resolve, reject) => { + fetch("/qz-certificate", { + cache: "no-store", + headers: {"Content-Type": "text/plain"}, + }) + .then((response) => + response + .text() + .then((text) => (response.ok ? resolve(text) : reject(text))) + ) + .catch(reject); + }); + + await qz.security.setSignatureAlgorithm("SHA512"); + await qz.security.setSignaturePromise((toSign) => (resolve, reject) => { + fetch(`/qz-sign-message?request=${toSign}`, { + cache: "no-store", + headers: {"Content-Type": "text/plain"}, + }) + .then((response) => + response + .text() + .then((text) => (response.ok ? resolve(text) : reject(text))) + ) + .catch(reject); + }); + await qz.websocket.connect(); + const config = qz.configs.create(printerName, opts); + await qz.print(config, data); + await qz.websocket.disconnect(); + return true; + } +} diff --git a/pos_printing_qztray/static/src/app/printer/qz_tray_printer.esm.js b/pos_printing_qztray/static/src/app/printer/qz_tray_printer.esm.js new file mode 100644 index 0000000000..ecd849462e --- /dev/null +++ b/pos_printing_qztray/static/src/app/printer/qz_tray_printer.esm.js @@ -0,0 +1,118 @@ +/** @odoo-module **/ +/* global */ + +import {htmlToCanvas} from "@point_of_sale/app/services/render_service"; +import {QZConnection} from "./qz_tray_connection.esm"; +import {rpc} from "@web/core/network/rpc"; + +/** + * QZTrayPrinter + * ------------- + * POS printer driver that uses QZ Tray to send ESC/POS commands. + * It requests the backend to convert the HTML receipt into ESC/POS bytes + * using python-escpos, and then sends them to the QZ Tray client. + */ +export class QZTrayPrinter { + constructor(name) { + this.name = name || "QZTray Printer"; + this.type = "qztray"; + } + + /** + * Print a POS receipt element by converting its HTML to ESC/POS. + * @param {HTMLElement} el - The HTML element representing the receipt. + * @returns {Promise<{successful: boolean, message?: object}>} + */ + async printReceipt(el) { + let activeConnection = false; + + try { + /** Try to extract POS order reference from native HTML receipt. Needed to fetch ESC/POS data from backend **/ + const orderId = this._extractOrderId(el); + if (orderId) { + const escpos_data = await rpc("/pos/escpos/receipt", { + order_id: orderId, + }); + + const cashdrawer = "\x1B\x70\x00\x19\x19"; + + activeConnection = true; + await QZConnection.print(this.name, [ + {type: "raw", format: "base64", data: escpos_data}, + cashdrawer, + ]); + + return {successful: true}; + } + console.info( + "[POS][QZTray] No order detected → printing native Odoo ticket." + ); + const canvas = await htmlToCanvas(el, {addClass: "pos-receipt-print"}); + const pngBase64 = canvas + .toDataURL("image/png") + .replace(/^data:image\/png;base64,/, ""); + const escpos_data = await rpc("/pos/escpos/render-image", { + png_base64: pngBase64, + }); + activeConnection = true; + await QZConnection.print(this.name, [ + { + type: "raw", + format: "base64", + data: escpos_data, + }, + ]); + + return {successful: true}; + } catch (error) { + console.error("[POS][QZTray] Printing error:", error); + return { + successful: false, + message: { + title: "Printing Error", + body: error.message || "Unable to print using QZ Tray.", + }, + }; + } finally { + if (activeConnection) { + try { + await QZConnection.disconnect(); + } catch { + /* Ignore */ + } + } + } + } + + async openCashbox() { + try { + const opencashcommand = "\x1B\x70\x00\x19\x19"; + await QZConnection.print(this.name, [ + {type: "raw", format: "base64", data: opencashcommand}, + opencashcommand, + ]); + return {successful: true}; + } catch (error) { + console.error("[POS][QZTray] ESC/POS Open Chasdrawer error:", error); + return { + successful: false, + message: { + title: "ESC/POS Print Error", + body: error.message || "Could not open chashdrawer via QZ Tray.", + }, + }; + } finally { + try { + await QZConnection.disconnect(); + } catch { + /* Ignore disconnect errors */ + } + } + } + + _extractOrderId(el) { + const html = el?.outerHTML || ""; + const match = html.match(/\b\d{3}-\d{1}-\d{6}\b/); + return match ? match[0] : null; + } +} diff --git a/pos_printing_qztray/static/src/app/services/qz_tray_printer_service.esm.js b/pos_printing_qztray/static/src/app/services/qz_tray_printer_service.esm.js new file mode 100644 index 0000000000..ac9d561ec7 --- /dev/null +++ b/pos_printing_qztray/static/src/app/services/qz_tray_printer_service.esm.js @@ -0,0 +1,45 @@ +/** @odoo-module **/ +/* global */ + +import {posPrinterService} from "@point_of_sale/app/services/pos_printer_service"; +import {QZTrayPrinter} from "../printer/qz_tray_printer.esm"; +import {registry} from "@web/core/registry"; +import {rpc} from "@web/core/network/rpc"; + +export const QZTrayPrinterService = { + dependencies: ["hardware_proxy"], + async start(env, {hardware_proxy}) { + let printerName = "QZTray"; + + try { + const posConfigId = odoo.pos_config_id; + if (posConfigId) { + const result = await rpc("/web/dataset/call_kw", { + model: "pos.config", + method: "read", + args: [[posConfigId], ["iface_qztray_printer_id"]], + kwargs: {}, + }); + + const config = result?.[0]; + if (config?.iface_qztray_printer_id) { + printerName = config.iface_qztray_printer_id[1]; + console.info(`[POS][QZTray] Printer from backend: ${printerName}`); + } + } else { + console.warn("[POS][QZTray] No pos_config_id found in odoo global."); + } + } catch (error) { + console.error("[POS][QZTray] Could not fetch printer config", error); + } + + const device = new QZTrayPrinter(printerName, "escpos"); + hardware_proxy.printer = device; + + console.info(`[POS][QZTray] Printer service initialized: ${printerName}`); + + posPrinterService.start(env, {hardware_proxy}); + }, +}; + +registry.category("services").add("printer.qztray", QZTrayPrinterService); diff --git a/pos_printing_qztray/tests/__init__.py b/pos_printing_qztray/tests/__init__.py new file mode 100644 index 0000000000..6d3d190a6d --- /dev/null +++ b/pos_printing_qztray/tests/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from . import test_escpos_controller, test_pos_config diff --git a/pos_printing_qztray/tests/test_escpos_controller.py b/pos_printing_qztray/tests/test_escpos_controller.py new file mode 100644 index 0000000000..724bd80d5b --- /dev/null +++ b/pos_printing_qztray/tests/test_escpos_controller.py @@ -0,0 +1,152 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import base64 +import io +from unittest.mock import MagicMock, patch + +from PIL import Image + +from odoo.tests.common import TransactionCase + +from odoo.addons.pos_printing_qztray.controllers.main import PosEscposController + + +def _make_png_base64(width=10, height=10): + """Helper: generate a minimal white PNG encoded as base64.""" + img = Image.new("RGB", (width, height), color=(255, 255, 255)) + buf = io.BytesIO() + img.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("utf-8") + + +class TestTableColumns(TransactionCase): + """Unit tests for PosEscposController._table_columns formatting logic.""" + + def setUp(self): + super().setUp() + self.controller = PosEscposController() + + def _printer(self): + from escpos.printer import Dummy + + return Dummy() + + def _text_output(self, p): + return p.output.decode("latin-1") + + def test_left_align_pads_text_to_column_width(self): + p = self._printer() + self.controller._table_columns(p, ["Hi"], [8], ["left"]) + self.assertIn("Hi ", self._text_output(p)) + + def test_right_align_pads_text_to_column_width(self): + p = self._printer() + self.controller._table_columns(p, ["Hi"], [8], ["right"]) + self.assertIn(" Hi", self._text_output(p)) + + def test_center_align_contains_text(self): + p = self._printer() + self.controller._table_columns(p, ["Hi"], [8], ["center"]) + self.assertIn("Hi", self._text_output(p)) + + def test_text_truncated_when_exceeds_column_width(self): + p = self._printer() + self.controller._table_columns(p, ["LongText"], [4], ["left"]) + output = self._text_output(p) + self.assertIn("Long", output) + self.assertNotIn("LongText", output) + + def test_multiple_columns_joined_in_single_line(self): + p = self._printer() + self.controller._table_columns( + p, ["AAA", "BBB", "CCC"], [5, 5, 5], ["left", "center", "right"] + ) + output = self._text_output(p) + self.assertIn("AAA", output) + self.assertIn("BBB", output) + self.assertIn("CCC", output) + + def test_width_list_shorter_than_columns_uses_last_width(self): + """If widths list is shorter than columns, last width is reused.""" + p = self._printer() + # 3 columns, only 1 width specified — should not raise + self.controller._table_columns(p, ["A", "B", "C"], [5], ["left"]) + self.assertIn("A", self._text_output(p)) + + def test_align_list_shorter_than_columns_uses_last_align(self): + """If align list is shorter than columns, last alignment is reused.""" + p = self._printer() + self.controller._table_columns(p, ["A", "B", "C"], [5, 5, 5], ["left"]) + self.assertIn("A", self._text_output(p)) + + +class TestRenderAnyReport(TransactionCase): + """Unit tests for PosEscposController.render_any_report (image → ESC/POS).""" + + def setUp(self): + super().setUp() + self.controller = PosEscposController() + + def test_valid_png_returns_base64_string(self): + result = self.controller.render_any_report(_make_png_base64(), width_mm=80) + self.assertIsInstance(result, str) + # Must be valid base64 + decoded = base64.b64decode(result) + self.assertGreater(len(decoded), 0) + + def test_valid_png_58mm_returns_base64_string(self): + result = self.controller.render_any_report(_make_png_base64(), width_mm=58) + self.assertIsInstance(result, str) + base64.b64decode(result) # should not raise + + def test_invalid_base64_returns_error_dict(self): + result = self.controller.render_any_report("NOT!VALID!BASE64", width_mm=80) + self.assertIsInstance(result, dict) + self.assertIn("error", result) + + def test_valid_base64_not_an_image_returns_error_dict(self): + not_image = base64.b64encode(b"this is not image data").decode("utf-8") + result = self.controller.render_any_report(not_image, width_mm=80) + self.assertIsInstance(result, dict) + self.assertIn("error", result) + + def test_result_contains_escpos_cut_command(self): + """ESC/POS cut command (GS V) must be present in the output.""" + result = self.controller.render_any_report(_make_png_base64(), width_mm=80) + raw = base64.b64decode(result) + # GS V 65 = full cut (0x1d 0x56 0x41) + self.assertIn(b"\x1d\x56", raw) + + +class TestRenderTemplate(TransactionCase): + """Tests for PosEscposController.render_template route logic.""" + + def setUp(self): + super().setUp() + self.controller = PosEscposController() + + def test_order_not_found_returns_error_dict(self): + """When no pos.order matches the reference, an error dict is returned.""" + mock_req = MagicMock() + mock_req.env = self.env + with patch( + "odoo.addons.pos_printing_qztray.controllers.main.request", + new=mock_req, + ): + result = self.controller.render_template("00000-000-0000") + + self.assertIsInstance(result, dict) + self.assertIn("error", result) + self.assertEqual(result["error"], "Order not found") + + def test_order_not_found_does_not_raise(self): + """render_template must never propagate an exception for missing orders.""" + mock_req = MagicMock() + mock_req.env = self.env + with patch( + "odoo.addons.pos_printing_qztray.controllers.main.request", + new=mock_req, + ): + try: + self.controller.render_template("NONEXISTENT-REF-99999") + except Exception as exc: # noqa: BLE001 + self.fail(f"render_template raised an unexpected exception: {exc}") diff --git a/pos_printing_qztray/tests/test_pos_config.py b/pos_printing_qztray/tests/test_pos_config.py new file mode 100644 index 0000000000..a767adb574 --- /dev/null +++ b/pos_printing_qztray/tests/test_pos_config.py @@ -0,0 +1,38 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.tests.common import TransactionCase + + +class TestPosConfigQzFields(TransactionCase): + """Tests for the pos.config fields added by pos_printing_qz.""" + + def test_is_qztray_field_is_boolean(self): + field = self.env["pos.config"]._fields.get("is_qztray") + self.assertIsNotNone(field, "Field is_qztray must exist on pos.config") + self.assertEqual(field.type, "boolean") + + def test_is_qztray_defaults_false(self): + field = self.env["pos.config"]._fields.get("is_qztray") + # Resolve the default value the same way Odoo does at create time + default_val = field.default + if callable(default_val): + default_val = default_val(self.env["pos.config"]) + self.assertFalse(default_val, "is_qztray must default to False") + + def test_iface_qztray_printer_id_field_is_many2one(self): + field = self.env["pos.config"]._fields.get("iface_qztray_printer_id") + self.assertIsNotNone( + field, "Field iface_qztray_printer_id must exist on pos.config" + ) + self.assertEqual(field.type, "many2one") + + def test_iface_qztray_printer_id_comodel_is_printing_printer(self): + field = self.env["pos.config"]._fields.get("iface_qztray_printer_id") + self.assertEqual( + field.comodel_name, + "printing.printer", + "iface_qztray_printer_id must relate to printing.printer", + ) + + def test_iface_qztray_printer_id_not_required(self): + field = self.env["pos.config"]._fields.get("iface_qztray_printer_id") + self.assertFalse(field.required, "iface_qztray_printer_id must not be required") diff --git a/pos_printing_qztray/views/pos_config_views.xml b/pos_printing_qztray/views/pos_config_views.xml new file mode 100644 index 0000000000..d2168d9e96 --- /dev/null +++ b/pos_printing_qztray/views/pos_config_views.xml @@ -0,0 +1,45 @@ + + + + pos.config.form.qztray + pos.config + + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000..3cdd91e072 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +python-escpos