Revert "Print Odoo receipt HTML through QZ only"

This reverts commit ad5564aaf638eb4507dd039152e940c2594f8e7a.
This commit is contained in:
metatroncubeswdev 2026-05-07 18:03:10 -04:00
parent c08f546702
commit 56f6086024
3 changed files with 314 additions and 68 deletions

View File

@ -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,

View File

@ -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'],

View File

@ -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) {
</html>`;
}
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,13 +511,10 @@ 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 printer = await findConfiguredPrinter(printerName);
const config = qz.configs.create(printer, {
const config = qz.configs.create(printerName, {
copies: 1,
margins: 0,
units: "mm",
@ -295,13 +523,31 @@ patch(ReceiptScreen.prototype, {
scaleContent: true,
spool: { size: 1 },
});
const html = await buildHtmlReceipt(receiptElement, this.pos);
await enqueueQzPrint(() => qz.print(config, [{
await qz.print(config, [{
type: "pixel",
format: "html",
flavor: "plain",
data: html,
}]));
data: buildHtmlReceipt(receiptElement, this.pos),
}]);
if (this.currentOrder) {
this.currentOrder._printed = true;
}
return true;
}
const config = qz.configs.create(printerName, {
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;
@ -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);