forked from alaguraj/odoo-testing-addons
Revert "Print Odoo receipt HTML through QZ only"
This reverts commit ad5564aaf638eb4507dd039152e940c2594f8e7a.
This commit is contained in:
parent
c08f546702
commit
56f6086024
@ -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,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user