From 56f6086024be19e4fc5bc0dadf07fb8c92a3227d Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Thu, 7 May 2026 18:03:10 -0400 Subject: [PATCH] Revert "Print Odoo receipt HTML through QZ only" This reverts commit ad5564aaf638eb4507dd039152e940c2594f8e7a. --- addons/dine360_order_channels/__manifest__.py | 3 +- addons/dine360_qz_printer/__manifest__.py | 2 +- .../static/src/js/qz_wrapper.js | 377 +++++++++++++++--- 3 files changed, 314 insertions(+), 68 deletions(-) diff --git a/addons/dine360_order_channels/__manifest__.py b/addons/dine360_order_channels/__manifest__.py index c065cd2..cfd10d8 100644 --- a/addons/dine360_order_channels/__manifest__.py +++ b/addons/dine360_order_channels/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Dine360 Order Channels', - 'version': '17.0.1.1', + 'version': '17.0.1.0', 'category': 'Sales/Point of Sale', 'summary': 'Multi-channel order intake: Phone, WhatsApp, Social, Kiosk, Online', 'description': """ @@ -25,6 +25,7 @@ 'dine360_order_channels/static/src/js/channel_panel.js', 'dine360_order_channels/static/src/js/product_screen_patch.js', 'dine360_order_channels/static/src/xml/channel_panel.xml', + 'dine360_order_channels/static/src/xml/receipt_extension.xml', ], }, 'installable': True, diff --git a/addons/dine360_qz_printer/__manifest__.py b/addons/dine360_qz_printer/__manifest__.py index 75fed44..a5e1ffd 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': '17.0.1.4', + 'version': '17.0.1.3', '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/static/src/js/qz_wrapper.js b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js index 624e217..1bdb1d1 100644 --- a/addons/dine360_qz_printer/static/src/js/qz_wrapper.js +++ b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js @@ -11,7 +11,6 @@ const CR = 0x0d; const LF = 0x0a; const DEFAULT_COLUMNS = 42; const END_FEED_LINES = 8; -let qzPrintQueue = Promise.resolve(); function columnsFromConfig(config) { const value = Number.parseInt(config?.qz_paper_width || DEFAULT_COLUMNS, 10); @@ -26,20 +25,6 @@ function paperWidthMm(config) { return String(config?.qz_paper_width || DEFAULT_COLUMNS) === "48" ? 80 : 58; } -function enqueueQzPrint(task) { - const next = qzPrintQueue.catch(() => undefined).then(task); - qzPrintQueue = next.catch(() => undefined); - return next; -} - -async function findConfiguredPrinter(printerName) { - const name = cleanText(printerName); - if (!name) { - throw new Error("No QZ printer name is configured."); - } - return qz.printers.find(name); -} - function cleanText(value) { return String(value ?? "") .normalize("NFKD") @@ -53,10 +38,74 @@ function cleanText(value) { .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 safeUpper(value) { + return cleanText(value).toUpperCase(); +} + +function formatPhone(value) { + const clean = cleanText(value); + const digits = clean.replace(/\D/g, ""); + const local = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits; + if (local.length === 10) { + return `${local.slice(0, 3)}-${local.slice(3, 6)}-${local.slice(6)}`; + } + return clean; +} + +function getCompanyAddress(company) { + const parts = [ + company.street, + company.street2, + [company.city, company.state_id?.[1] || company.state_id?.name, company.zip].filter(Boolean).join(" "), + ].map(cleanText).filter(Boolean); + return parts.join(", "); +} + +function formatPlacedDate(value) { + const date = value ? new Date(value) : new Date(); + if (Number.isNaN(date.getTime())) { + return cleanText(value); + } + + const weekday = date.toLocaleDateString("en-CA", { weekday: "short" }); + const month = date.toLocaleDateString("en-CA", { month: "short" }); + const day = date.getDate(); + const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th"; + const time = date.toLocaleTimeString("en-CA", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).replace(/\s/g, ""); + return `${weekday}, ${month} ${day}${suffix}, ${time}`; +} + function getOrderTable(order) { return order?.getTable?.() || order?.table; } +function getOrderDisplayName(receiptData, order) { + return cleanText(receiptData?.name || order?.name || ""); +} + function wrapText(text, width) { const clean = cleanText(text); if (clean.length <= width) { @@ -94,24 +143,6 @@ function absoluteUrl(value) { return new URL(value, window.location.origin).href; } -async function imageToDataUrl(src) { - const url = absoluteUrl(src); - if (!url || url.startsWith("data:") || url.startsWith("blob:")) { - return url; - } - const response = await fetch(url, { credentials: "include" }); - if (!response.ok) { - throw new Error(`Unable to load receipt image: ${url}`); - } - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -} - function collectPrintableStyles() { return Array.from(document.querySelectorAll("link[rel='stylesheet'], style")) .map((node) => { @@ -123,13 +154,13 @@ function collectPrintableStyles() { .join("\n"); } -async function buildHtmlReceipt(receiptElement, pos) { +function buildHtmlReceipt(receiptElement, pos) { const config = pos?.config || {}; const widthMm = paperWidthMm(config); const clone = receiptElement.cloneNode(true); - await Promise.all(Array.from(clone.querySelectorAll("img[src]")).map(async (node) => { - node.setAttribute("src", await imageToDataUrl(node.getAttribute("src"))); - })); + for (const node of clone.querySelectorAll("[src]")) { + node.setAttribute("src", absoluteUrl(node.getAttribute("src"))); + } for (const node of clone.querySelectorAll("[href]")) { node.setAttribute("href", absoluteUrl(node.getAttribute("href"))); } @@ -164,12 +195,6 @@ async function buildHtmlReceipt(receiptElement, pos) { `; } -function findReceiptElement(screen) { - return screen.el?.querySelector?.(".pos-receipt") - || document.querySelector(".receipt-screen .pos-receipt") - || document.querySelector(".pos-receipt"); -} - class EscPosBuilder { constructor(columns) { this.columns = columns; @@ -249,6 +274,61 @@ class EscPosBuilder { } } +async function getLogoRasterBytes(pos, columns) { + if (pos?.config?.qz_print_logo === false || !pos?.company_logo_base64) { + return []; + } + + const image = await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = pos.company_logo_base64; + }); + + 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, 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); @@ -266,6 +346,157 @@ function addWrappedLine(builder, left, right = "", indent = 0) { } } +function addOrderLines(builder, order, currency) { + builder.bold(true); + builder.line(leftRight("Item", "Price", builder.columns)); + builder.bold(false); + + 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 addTaxSummary(builder, receiptData, currency) { + const taxes = receiptData?.tax_details || []; + if (!taxes.length) { + return; + } + 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); + const table = getOrderTable(order); + const companyName = cleanText(company.name || "Chennora"); + const fulfilmentLabel = safeUpper(receiptData.fulfilment_type_label || receiptData.fulfilment_type || "DINE-IN"); + const orderName = getOrderDisplayName(receiptData, order); + const qrValue = orderName || `${companyName} ${formatMoney(order?.get_total_with_tax?.(), currency)}`; + + builder.init(); + builder.raw(...(await getLogoRasterBytes(pos, columns))); + builder.align("center"); + builder.bold(true); + builder.line(companyName); + builder.bold(false); + builder.line(""); + + const address = getCompanyAddress(company); + if (address) { + builder.lines(wrapText(`Address: ${address}`, columns).map((line) => center(line, columns))); + } + if (company.email) { + builder.line(center(`Email: ${company.email}`, columns)); + } + if (company.phone) { + builder.line(center(`Phone: ${formatPhone(company.phone)}`, columns)); + } + + builder.line(""); + builder.bold(true); + builder.line(order?._printed ? "*** Duplicate Bill (1) ***" : "*** Bill ***"); + if (orderName) { + builder.line(`Invoice ${orderName}`); + } + builder.line(fulfilmentLabel); + builder.bold(false); + builder.line(""); + + builder.align("left"); + builder.separator("-"); + builder.line(leftRight("Placed", formatPlacedDate(receiptData.date), columns)); + builder.separator("-"); + builder.line(leftRight("Cashier", companyName, columns)); + if (order?.customer_count) { + builder.line(leftRight("Guests Served", order.customer_count, columns)); + } + if (table?.name) { + builder.line(leftRight("Table", table.name, columns)); + } + + builder.separator("="); + addOrderLines(builder, order, currency); + builder.separator("="); + builder.line(leftRight("Price", formatMoney(order?.get_total_without_tax?.(), currency), columns)); + const tax = order?.get_total_tax?.() || 0; + if (tax) { + const firstTax = receiptData?.tax_details?.[0]?.tax; + const taxLabel = firstTax?.name || (firstTax?.amount ? `HST (${firstTax.amount}%)` : "Tax"); + builder.line(leftRight(taxLabel, formatMoney(tax, currency), columns)); + } + if (receiptData.total_discount) { + builder.line(leftRight("Discounts", formatMoney(receiptData.total_discount, currency), columns)); + } + builder.bold(true); + builder.line(leftRight("Grand Total", formatMoney(order?.get_total_with_tax?.(), currency), columns)); + builder.bold(false); + + addPaymentLines(builder, receiptData, currency); + const change = receiptData.change || order?.get_change?.() || 0; + if (change > 0) { + builder.bold(true); + builder.line(leftRight("Change", formatMoney(change, currency), columns)); + builder.bold(false); + } + addTaxSummary(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("* PAID *"); + builder.bold(false); + builder.line(""); + builder.qrCode(qrValue); + builder.line(""); + builder.line("Powered by dind360"); + builder.align("left"); + builder.feed(END_FEED_LINES); + if (config.qz_enable_cutter !== false) { + builder.cut(config.qz_cut_mode || "partial"); + } + builder.feed(1); + return bytesToBase64(builder.bytes); +} + patch(ReceiptScreen.prototype, { async printReceipt() { const printerName = getBillingPrinterName(this.pos.config); @@ -280,28 +511,43 @@ patch(ReceiptScreen.prototype, { await qz.websocket.connect({ retries: 2, delay: 1 }); } - const receiptElement = findReceiptElement(this); - if (!receiptElement) { - throw new Error("The Odoo receipt preview was not found. Billing print was stopped to avoid printing fallback data."); + const receiptElement = this.el?.querySelector?.(".pos-receipt"); + if (receiptElement) { + const widthMm = paperWidthMm(this.pos.config); + const config = qz.configs.create(printerName, { + copies: 1, + margins: 0, + units: "mm", + size: { width: widthMm }, + rasterize: true, + scaleContent: true, + spool: { size: 1 }, + }); + await qz.print(config, [{ + type: "pixel", + format: "html", + flavor: "plain", + data: buildHtmlReceipt(receiptElement, this.pos), + }]); + + if (this.currentOrder) { + this.currentOrder._printed = true; + } + return true; } - const widthMm = paperWidthMm(this.pos.config); - const printer = await findConfiguredPrinter(printerName); - const config = qz.configs.create(printer, { + + const config = qz.configs.create(printerName, { + encoding: "CP437", copies: 1, - margins: 0, - units: "mm", - size: { width: widthMm }, - rasterize: true, - scaleContent: true, spool: { size: 1 }, }); - const html = await buildHtmlReceipt(receiptElement, this.pos); - await enqueueQzPrint(() => qz.print(config, [{ - type: "pixel", - format: "html", - flavor: "plain", - data: html, - }])); + 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; @@ -311,7 +557,7 @@ patch(ReceiptScreen.prototype, { console.error("QZ Tray Print Error:", err); this.env.services.popup.add(ErrorPopup, { title: "QZ Tray Printer Error", - body: err?.message || "Failed to print the rendered Odoo receipt through QZ Tray.", + 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; } @@ -393,19 +639,18 @@ patch(Order.prototype, { if (!qz.websocket.isActive()) { await qz.websocket.connect({ retries: 2, delay: 1 }); } - const printer = await findConfiguredPrinter(printerName); - const config = qz.configs.create(printer, { + const config = qz.configs.create(printerName, { encoding: "CP437", copies: 1, spool: { size: 1 }, }); const payload = await buildEscPosKitchenTicket(this, this.pos, orderChange, cancelled); - await enqueueQzPrint(() => qz.print(config, [{ + await qz.print(config, [{ type: "raw", format: "command", flavor: "base64", data: payload, - }])); + }]); return true; } catch (err) { console.error("QZ Tray Kitchen Print Error:", err);