diff --git a/addons/dine360_qz_printer/__manifest__.py b/addons/dine360_qz_printer/__manifest__.py index 0d7249a..ed5147d 100644 --- a/addons/dine360_qz_printer/__manifest__.py +++ b/addons/dine360_qz_printer/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Dine360 QZ Tray Printer', - 'version': '1.0', + 'version': '17.0.1.1', 'category': 'Point of Sale', 'summary': 'Integrate Odoo POS with Star/Epson Printers via QZ Tray.', 'depends': ['point_of_sale'], diff --git a/addons/dine360_qz_printer/models/pos_config.py b/addons/dine360_qz_printer/models/pos_config.py index d86d84d..66174d4 100644 --- a/addons/dine360_qz_printer/models/pos_config.py +++ b/addons/dine360_qz_printer/models/pos_config.py @@ -5,3 +5,25 @@ class PosConfig(models.Model): use_qz_printer = fields.Boolean("Use QZ Tray Printer", help="Print directly using QZ Tray locally") qz_printer_name = fields.Char("QZ Printer Name", help="Name of the printer mapped in QZ Tray") + qz_paper_width = fields.Selection( + [('42', '58mm / 42 columns'), ('48', '80mm / 48 columns')], + string="QZ Receipt Width", + default='42', + help="Character width used for ESC/POS receipt formatting." + ) + qz_print_logo = fields.Boolean( + "Print Logo", + default=True, + help="Print the company logo at the top of the receipt when QZ printing is enabled." + ) + qz_enable_cutter = fields.Boolean( + "Enable Cutter", + default=True, + help="Send an ESC/POS paper cut command after the receipt is printed." + ) + qz_cut_mode = fields.Selection( + [('partial', 'Partial Cut'), ('full', 'Full Cut')], + string="Cut Mode", + default='partial', + help="Paper cut command sent to the printer." + ) diff --git a/addons/dine360_qz_printer/models/res_config_settings.py b/addons/dine360_qz_printer/models/res_config_settings.py index 72affc2..035a086 100644 --- a/addons/dine360_qz_printer/models/res_config_settings.py +++ b/addons/dine360_qz_printer/models/res_config_settings.py @@ -6,3 +6,7 @@ class ResConfigSettings(models.TransientModel): use_qz_printer = fields.Boolean(related='pos_config_id.use_qz_printer', readonly=False) qz_printer_name = fields.Char(related='pos_config_id.qz_printer_name', readonly=False) + qz_paper_width = fields.Selection(related='pos_config_id.qz_paper_width', readonly=False) + qz_print_logo = fields.Boolean(related='pos_config_id.qz_print_logo', readonly=False) + qz_enable_cutter = fields.Boolean(related='pos_config_id.qz_enable_cutter', readonly=False) + qz_cut_mode = fields.Selection(related='pos_config_id.qz_cut_mode', readonly=False) diff --git a/addons/dine360_qz_printer/static/src/js/qz_wrapper.js b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js index b1056ab..29d9493 100644 --- a/addons/dine360_qz_printer/static/src/js/qz_wrapper.js +++ b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js @@ -2,180 +2,352 @@ import { patch } from "@web/core/utils/patch"; import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; +import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup"; -const RECEIPT_COLUMNS = 42; -const NEWLINE = "\r\n"; +const ESC = 0x1b; +const GS = 0x1d; +const CR = 0x0d; +const LF = 0x0a; +const DEFAULT_COLUMNS = 42; -function normalizeReceiptText(text) { - return (text || "") - .replace(/\u00a0/g, " ") - .replace(/[ \t]+/g, " ") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); +function columnsFromConfig(config) { + const value = Number.parseInt(config?.qz_paper_width || DEFAULT_COLUMNS, 10); + return Number.isFinite(value) ? value : DEFAULT_COLUMNS; } -function wrapLine(line, width = RECEIPT_COLUMNS) { - if (line.length <= width) { - return [line]; +function cleanText(value) { + return String(value ?? "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[–—]/g, "-") + .replace(/\u00a0/g, " ") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, "") + .replace(/[ \t]+/g, " ") + .trim(); +} + +function formatMoney(value, currency) { + const amount = Number(value || 0).toFixed(2); + const symbol = cleanText(currency?.symbol || "$"); + return currency?.position === "after" ? `${amount} ${symbol}` : `${symbol}${amount}`; +} + +function center(text, width) { + const value = cleanText(text); + const padding = Math.max(0, Math.floor((width - value.length) / 2)); + return `${" ".repeat(padding)}${value}`; +} + +function leftRight(left, right, width) { + const lhs = cleanText(left); + const rhs = cleanText(right); + const space = Math.max(1, width - lhs.length - rhs.length); + return `${lhs}${" ".repeat(space)}${rhs}`; +} + +function wrapText(text, width) { + const clean = cleanText(text); + if (clean.length <= width) { + return [clean]; } const wrapped = []; - let remaining = line; - while (remaining.length > width) { - let breakpoint = remaining.lastIndexOf(" ", width); - if (breakpoint <= 0) { - breakpoint = width; + let rest = clean; + while (rest.length > width) { + let splitAt = rest.lastIndexOf(" ", width); + if (splitAt <= 0) { + splitAt = width; } - wrapped.push(remaining.slice(0, breakpoint).trimEnd()); - remaining = remaining.slice(breakpoint).trimStart(); + wrapped.push(rest.slice(0, splitAt).trimEnd()); + rest = rest.slice(splitAt).trimStart(); } - if (remaining) { - wrapped.push(remaining); + if (rest) { + wrapped.push(rest); } return wrapped; } -function money(value, currency) { - const amount = Number(value || 0).toFixed(2); - return currency?.symbol ? `${currency.symbol}${amount}` : amount; +function bytesToBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.slice(i, i + chunkSize)); + } + return btoa(binary); } -function leftRight(left, right, width = RECEIPT_COLUMNS) { - const cleanLeft = String(left || ""); - const cleanRight = String(right || ""); - const space = Math.max(1, width - cleanLeft.length - cleanRight.length); - return `${cleanLeft}${" ".repeat(space)}${cleanRight}`; -} - -function buildReceiptLinesFromOrder(order) { - const currency = order?.pos?.currency; - const lines = []; - const company = order?.pos?.company; - const client = order?.get_partner?.(); - const table = order?.table; - const cashier = order?.employee || order?.pos?.get_cashier?.(); - - // 1. HEADER (Centered-ish) - if (company?.name) { - lines.push(company.name.toUpperCase()); +class EscPosBuilder { + constructor(columns) { + this.columns = columns; + this.bytes = []; } - if (company?.street) lines.push(company.street); - if (company?.city) lines.push(company.city); - if (company?.phone) lines.push(`Tel: ${company.phone}`); - - // Custom Odoo Header - if (order?.pos?.config?.receipt_header) { - lines.push(order.pos.config.receipt_header); - } - lines.push("-".repeat(RECEIPT_COLUMNS)); - // 2. ORDER INFO - const receiptNumber = order?.name || ""; - if (receiptNumber) lines.push(`Order: ${receiptNumber}`); - - if (table) { - lines.push(`TABLE: ${table.name}`.padEnd(20) + `GUESTS: ${order.customer_count || 1}`); + raw(...values) { + this.bytes.push(...values); } - if (cashier) { - lines.push(`SERVER: ${cashier.name}`); - } - lines.push(`DATE: ${new Date().toLocaleString()}`); - if (client) { - lines.push(`CUSTOMER: ${client.name}`); - } - - lines.push("=".repeat(RECEIPT_COLUMNS)); - lines.push(leftRight("ITEM", "PRICE")); - lines.push("-".repeat(RECEIPT_COLUMNS)); - // 3. ORDER LINES - for (const orderline of order?.get_orderlines?.() || []) { - const product = orderline.get_product?.(); - const name = product?.display_name || product?.name || ""; - const qty = orderline.get_quantity?.() || 0; - const priceUnit = orderline.get_unit_display_price?.() || 0; - const total = orderline.get_price_with_tax?.() || 0; - - // "Qty x Name" on left, "Total" on right - const itemLabel = `${qty} x ${name}`; - const itemPrice = money(total, currency); - - if (itemLabel.length + itemPrice.length + 1 > RECEIPT_COLUMNS) { - lines.push(itemLabel); - lines.push(itemPrice.padStart(RECEIPT_COLUMNS)); - } else { - lines.push(leftRight(itemLabel, itemPrice)); - } - - // Show unit price if qty > 1 - if (qty > 1) { - lines.push(` @ ${money(priceUnit, currency)}`); + text(value) { + for (const char of cleanText(value)) { + this.bytes.push(char.charCodeAt(0) & 0xff); } } - // 4. TOTALS - lines.push("=".repeat(RECEIPT_COLUMNS)); - lines.push(leftRight("SUBTOTAL", money(order?.get_total_without_tax?.(), currency))); - - const tax = order?.get_total_tax?.(); - if (tax) { - lines.push(leftRight("TAX", money(tax, currency))); + line(value = "") { + this.text(value); + this.bytes.push(CR, LF); } - - lines.push("-".repeat(RECEIPT_COLUMNS)); - lines.push(leftRight("TOTAL", money(order?.get_total_with_tax?.(), currency))); - - const change = order?.get_change?.(); - if (change) { - lines.push(leftRight("CHANGE", money(change, currency))); + + lines(values) { + for (const value of values) { + this.line(value); + } } - - lines.push("-".repeat(RECEIPT_COLUMNS)); - - // Custom Odoo Footer - if (order?.pos?.config?.receipt_footer) { - lines.push(order.pos.config.receipt_footer); + + init() { + this.raw(ESC, 0x40); + } + + align(value) { + const modes = { left: 0, center: 1, right: 2 }; + this.raw(ESC, 0x61, modes[value] ?? 0); + } + + bold(enabled) { + this.raw(ESC, 0x45, enabled ? 1 : 0); + } + + size(value) { + const sizes = { normal: 0x00, doubleHeight: 0x01, doubleWidth: 0x10, double: 0x11 }; + this.raw(GS, 0x21, sizes[value] ?? 0x00); + } + + feed(lines = 1) { + this.raw(ESC, 0x64, Math.max(0, Math.min(8, lines))); + } + + cut(mode = "partial") { + this.raw(GS, 0x56, mode === "full" ? 0x41 : 0x42, 0x00); + } + + separator(char = "-") { + this.line(char.repeat(this.columns)); } - - lines.push("THANK YOU FOR DINING WITH US!"); - lines.push("PLEASE VISIT AGAIN"); - lines.push(NEWLINE); - - return lines; } -function buildReceiptLines(receiptElement, order) { - const domLines = normalizeReceiptText(receiptElement?.innerText || ""); - if (domLines.length > 1) { - return domLines; - } - return buildReceiptLinesFromOrder(order); -} - -function buildEscPosReceipt(receiptElement, order) { - const ESC = "\x1B"; - const GS = "\x1D"; - let lines = buildReceiptLines(receiptElement, order); - - // Safety mechanism: limit receipt length to prevent runaway printing - if (lines.length > 150) { - console.warn("Receipt is suspiciously long, truncating to 150 lines to prevent runaway printing."); - lines.length = 150; - lines.push("-".repeat(RECEIPT_COLUMNS)); - lines.push("TRUNCATED FOR SAFETY"); +async function getLogoRasterBytes(pos, columns) { + if (pos?.config?.qz_print_logo === false || !pos?.company_logo_base64) { + return []; } - const body = lines.flatMap((line) => wrapLine(line)).join(NEWLINE); + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = pos.company_logo_base64; + }); - console.log("Cutter command run: Preparing ESC/POS data with feed and partial cut."); + const maxWidth = Math.min(columns * 8, 384); + const maxHeight = 120; + const ratio = Math.min(maxWidth / image.width, maxHeight / image.height, 1); + const width = Math.max(8, Math.floor((image.width * ratio) / 8) * 8); + const height = Math.max(1, Math.floor(image.height * ratio)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(image, 0, 0, width, height); + + const pixels = ctx.getImageData(0, 0, width, height).data; + const widthBytes = Math.ceil(width / 8); + const data = []; + for (let y = 0; y < height; y++) { + for (let xb = 0; xb < widthBytes; xb++) { + let byte = 0; + for (let bit = 0; bit < 8; bit++) { + const x = xb * 8 + bit; + const index = (y * width + x) * 4; + const alpha = pixels[index + 3]; + const luminance = pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114; + if (alpha > 32 && luminance < 170) { + byte |= 0x80 >> bit; + } + } + data.push(byte); + } + } return [ - ESC + "@", // Initialize printer - ESC + "a" + "\x00", // Left align - body, - NEWLINE + NEWLINE + NEWLINE + NEWLINE + NEWLINE, - GS + "V" + "\x42" + "\x00", // Feed paper to cutting position and perform partial cut (standard ESC/POS) - ].join(""); + ESC, 0x61, 0x01, + GS, 0x76, 0x30, 0x00, + widthBytes & 0xff, (widthBytes >> 8) & 0xff, + height & 0xff, (height >> 8) & 0xff, + ...data, + CR, LF, + ESC, 0x61, 0x00, + ]; +} + +function addWrappedLine(builder, left, right = "", indent = 0) { + const width = builder.columns; + const rightText = cleanText(right); + const leftWidth = rightText ? width - rightText.length - 1 : width; + const wrapped = wrapText(left, Math.max(8, leftWidth - indent)); + if (rightText) { + builder.line(leftRight(`${" ".repeat(indent)}${wrapped[0]}`, rightText, width)); + for (const extra of wrapped.slice(1)) { + builder.line(`${" ".repeat(indent)}${extra}`); + } + } else { + for (const line of wrapped) { + builder.line(`${" ".repeat(indent)}${line}`); + } + } +} + +function addOrderLines(builder, order, currency) { + builder.bold(true); + builder.line(leftRight("ITEM", "TOTAL", builder.columns)); + builder.bold(false); + builder.separator("-"); + + for (const line of order?.get_orderlines?.() || []) { + const product = line.get_product?.(); + const name = product?.display_name || product?.name || line.get_full_product_name?.() || ""; + const qty = Number(line.get_quantity?.() || 0); + const qtyText = Number.isInteger(qty) ? String(qty) : qty.toFixed(2).replace(/\.?0+$/, ""); + const total = formatMoney(line.get_price_with_tax?.() || 0, currency); + addWrappedLine(builder, `${qtyText} x ${name}`, total); + + const unitPrice = line.get_unit_display_price?.(); + if (qty !== 1 && unitPrice !== undefined) { + builder.line(` @ ${formatMoney(unitPrice, currency)} each`); + } + const note = line.get_customer_note?.(); + if (note) { + addWrappedLine(builder, `Note: ${note}`, "", 2); + } + } +} + +function addTaxes(builder, receiptData, currency) { + const taxes = receiptData?.tax_details || []; + if (!taxes.length) { + return; + } + builder.separator("-"); + builder.bold(true); + builder.line("TAX SUMMARY"); + builder.bold(false); + for (const tax of taxes) { + const label = tax?.tax?.name || (tax?.tax?.amount ? `${tax.tax.amount}%` : "Tax"); + builder.line(leftRight(label, formatMoney(tax.amount || 0, currency), builder.columns)); + } +} + +function addPaymentLines(builder, receiptData, currency) { + const paymentLines = receiptData?.paymentlines || []; + if (!paymentLines.length) { + return; + } + builder.separator("-"); + for (const payment of paymentLines) { + builder.line(leftRight(payment.name, formatMoney(payment.amount, currency), builder.columns)); + } +} + +async function buildEscPosReceipt(order, pos) { + const config = pos?.config || {}; + const columns = columnsFromConfig(config); + const currency = pos?.currency; + const company = pos?.company || {}; + const receiptData = order?.export_for_printing?.() || {}; + const builder = new EscPosBuilder(columns); + + builder.init(); + builder.raw(...(await getLogoRasterBytes(pos, columns))); + builder.align("center"); + builder.bold(true); + builder.size("doubleHeight"); + builder.line(company.name || "Chennora"); + builder.size("normal"); + builder.bold(false); + if (company.street) builder.line(company.street); + if (company.city) builder.line(company.city); + if (company.phone) builder.line(`Tel: ${company.phone}`); + if (company.email) builder.line(company.email); + if (company.website) builder.line(company.website); + if (config.receipt_header) { + builder.line(""); + builder.lines(wrapText(config.receipt_header, columns).map((line) => center(line, columns))); + } + + builder.align("left"); + builder.separator("="); + if (receiptData.name) builder.line(leftRight("Receipt", receiptData.name, columns)); + if (receiptData.date) builder.line(leftRight("Date", receiptData.date, columns)); + if (receiptData.cashier || receiptData.headerData?.cashier) { + builder.line(leftRight("Served by", receiptData.cashier || receiptData.headerData.cashier, columns)); + } + if (order?.table?.name) { + builder.line(leftRight("Table", order.table.name, columns)); + } + if (order?.customer_count) { + builder.line(leftRight("Guests", order.customer_count, columns)); + } + if (receiptData.order_source_label) { + builder.line(leftRight("Source", receiptData.order_source_label, columns)); + } + if (receiptData.fulfilment_type_label) { + builder.line(leftRight("Fulfilment", receiptData.fulfilment_type_label, columns)); + } + + builder.separator("="); + addOrderLines(builder, order, currency); + builder.separator("="); + builder.line(leftRight("Subtotal", formatMoney(order?.get_total_without_tax?.(), currency), columns)); + const tax = order?.get_total_tax?.() || 0; + if (tax) { + builder.line(leftRight("Tax", formatMoney(tax, currency), columns)); + } + if (receiptData.total_discount) { + builder.line(leftRight("Discounts", formatMoney(receiptData.total_discount, currency), columns)); + } + builder.bold(true); + builder.size("doubleHeight"); + builder.line(leftRight("TOTAL", formatMoney(order?.get_total_with_tax?.(), currency), columns)); + builder.size("normal"); + builder.bold(false); + + addPaymentLines(builder, receiptData, currency); + const change = receiptData.change || order?.get_change?.() || 0; + if (change > 0) { + builder.line(leftRight("Change", formatMoney(change, currency), columns)); + } + addTaxes(builder, receiptData, currency); + + if (config.receipt_footer) { + builder.separator("-"); + builder.align("center"); + builder.lines(wrapText(config.receipt_footer, columns).map((line) => center(line, columns))); + builder.align("left"); + } + + builder.separator("-"); + builder.align("center"); + builder.bold(true); + builder.line("THANK YOU"); + builder.bold(false); + builder.line("Please visit again"); + builder.line("Powered by Dine360"); + builder.align("left"); + builder.feed(5); + if (config.qz_enable_cutter !== false) { + builder.cut(config.qz_cut_mode || "partial"); + } + return bytesToBase64(builder.bytes); } patch(ReceiptScreen.prototype, { @@ -191,38 +363,32 @@ patch(ReceiptScreen.prototype, { await qz.websocket.connect({ retries: 2, delay: 1 }); } - const printerName = this.pos.config.qz_printer_name; - const config = qz.configs.create(printerName, { + const config = qz.configs.create(this.pos.config.qz_printer_name, { encoding: "CP437", - spool: { end: "\n" }, + copies: 1, + spool: { size: 1 }, }); - - const receiptElement = document.querySelector(".pos-receipt") || document.querySelector(".pos-receipt-container"); - if (!receiptElement) { - return false; - } - - const printData = buildEscPosReceipt(receiptElement, this.currentOrder); - - await qz.print(config, [printData]); + const payload = await buildEscPosReceipt(this.currentOrder, this.pos); + await qz.print(config, [{ + type: "raw", + format: "command", + flavor: "base64", + data: payload, + }]); if (this.currentOrder) { this.currentOrder._printed = true; } - return true; - } catch (err) { console.error("QZ Tray Print Error:", err); - this.env.services.popup.add("ErrorPopup", { + this.env.services.popup.add(ErrorPopup, { title: "QZ Tray Printer Error", - body: "Failed to connect to local QZ Tray or printer. Make sure QZ Tray is running.", + body: "Failed to print through QZ Tray. Check that QZ Tray is running, the printer name is correct, and the printer supports ESC/POS raw commands.", }); return false; } - } else { - // Fallback to default Odoo print behavior - return super.printReceipt(...arguments); } - } + return super.printReceipt(...arguments); + }, }); diff --git a/addons/dine360_qz_printer/views/pos_config_views.xml b/addons/dine360_qz_printer/views/pos_config_views.xml index 50ea7a7..e798595 100644 --- a/addons/dine360_qz_printer/views/pos_config_views.xml +++ b/addons/dine360_qz_printer/views/pos_config_views.xml @@ -14,6 +14,22 @@