/** @odoo-module */ 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"; import { PosStore } from "@point_of_sale/app/store/pos_store"; const ESC = 0x1b; const GS = 0x1d; const CR = 0x0d; const LF = 0x0a; const DEFAULT_COLUMNS = 42; const END_FEED_LINES = 8; function columnsFromConfig(config) { const value = Number.parseInt(config?.qz_paper_width || DEFAULT_COLUMNS, 10); return Number.isFinite(value) ? value : DEFAULT_COLUMNS; } 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 rest = clean; while (rest.length > width) { let splitAt = rest.lastIndexOf(" ", width); if (splitAt <= 0) { splitAt = width; } wrapped.push(rest.slice(0, splitAt).trimEnd()); rest = rest.slice(splitAt).trimStart(); } if (rest) { wrapped.push(rest); } return wrapped; } 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); } class EscPosBuilder { constructor(columns) { this.columns = columns; this.bytes = []; } raw(...values) { this.bytes.push(...values); } text(value) { for (const char of cleanText(value)) { this.bytes.push(char.charCodeAt(0) & 0xff); } } line(value = "") { this.text(value); this.bytes.push(CR, LF); } lines(values) { for (const value of values) { this.line(value); } } 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)); } } 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); 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 || "Shivasakthi"); 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(""); builder.line("Powered by Dine360"); 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() { if (this.pos.config.use_qz_printer && this.pos.config.qz_billing_printer_name) { try { if (!window.qz) { console.error("QZ Tray library not loaded."); return false; } if (!qz.websocket.isActive()) { await qz.websocket.connect({ retries: 2, delay: 1 }); } const config = qz.configs.create(this.pos.config.qz_billing_printer_name, { encoding: "CP437", copies: 1, spool: { size: 1 }, }); 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, { title: "QZ Tray Printer Error", 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; } } return super.printReceipt(...arguments); }, }); async function buildEscPosKitchenTicket(order, pos, changes) { const config = pos?.config || {}; const columns = columnsFromConfig(config); const builder = new EscPosBuilder(columns); builder.init(); builder.align("center"); builder.bold(true); builder.size("doubleHeight"); builder.line("KITCHEN ORDER"); builder.size("normal"); builder.line(""); builder.align("left"); if (order.name) builder.line(`Order: ${order.name}`); if (order.table?.name) builder.line(`Table: ${order.table.name}`); if (order.customer_count) builder.line(`Guests: ${order.customer_count}`); builder.line(`Time: ${new Date().toLocaleTimeString()}`); builder.separator("="); for (const category of Object.values(changes.categories || {})) { if (category.name) { builder.bold(true); builder.line(category.name.toUpperCase()); builder.bold(false); } for (const line of category.orderlines || []) { const qty = line.quantity; const qtyText = Number.isInteger(qty) ? String(qty) : qty.toFixed(2).replace(/\.?0+$/, ""); addWrappedLine(builder, `${qtyText} x ${line.name}`); if (line.note) { addWrappedLine(builder, `Note: ${line.note}`, "", 2); } } builder.line(""); } 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(PosStore.prototype, { async sendOrderToPrinter(order) { if (this.config.use_qz_printer && this.config.qz_kitchen_printer_name) { const changes = order.computeChanges(); if (Object.keys(changes.categories).length > 0) { try { if (!window.qz) { console.error("QZ Tray library not loaded."); return false; } if (!qz.websocket.isActive()) { await qz.websocket.connect({ retries: 2, delay: 1 }); } const config = qz.configs.create(this.config.qz_kitchen_printer_name, { encoding: "CP437", copies: 1, spool: { size: 1 }, }); const payload = await buildEscPosKitchenTicket(order, this, changes); await qz.print(config, [{ type: "raw", format: "command", flavor: "base64", data: payload, }]); order.saved_quantity = order.get_orderlines().reduce((acc, line) => acc + line.get_quantity(), 0); return true; } catch (err) { console.error("QZ Tray Kitchen Print Error:", err); // Fallback or show error } } return true; } return super.sendOrderToPrinter(...arguments); }, });