forked from alaguraj/odoo-testing-addons
Improve QZ receipt printing format and cutter support
This commit is contained in:
parent
ec9977dd1f
commit
3e705e36a2
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Dine360 QZ Tray Printer',
|
'name': 'Dine360 QZ Tray Printer',
|
||||||
'version': '1.0',
|
'version': '17.0.1.1',
|
||||||
'category': 'Point of Sale',
|
'category': 'Point of Sale',
|
||||||
'summary': 'Integrate Odoo POS with Star/Epson Printers via QZ Tray.',
|
'summary': 'Integrate Odoo POS with Star/Epson Printers via QZ Tray.',
|
||||||
'depends': ['point_of_sale'],
|
'depends': ['point_of_sale'],
|
||||||
|
|||||||
@ -5,3 +5,25 @@ class PosConfig(models.Model):
|
|||||||
|
|
||||||
use_qz_printer = fields.Boolean("Use QZ Tray Printer", help="Print directly using QZ Tray locally")
|
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_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."
|
||||||
|
)
|
||||||
|
|||||||
@ -6,3 +6,7 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
|
|
||||||
use_qz_printer = fields.Boolean(related='pos_config_id.use_qz_printer', readonly=False)
|
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_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)
|
||||||
|
|||||||
@ -2,180 +2,352 @@
|
|||||||
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
import { patch } from "@web/core/utils/patch";
|
||||||
import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen";
|
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 ESC = 0x1b;
|
||||||
const NEWLINE = "\r\n";
|
const GS = 0x1d;
|
||||||
|
const CR = 0x0d;
|
||||||
|
const LF = 0x0a;
|
||||||
|
const DEFAULT_COLUMNS = 42;
|
||||||
|
|
||||||
function normalizeReceiptText(text) {
|
function columnsFromConfig(config) {
|
||||||
return (text || "")
|
const value = Number.parseInt(config?.qz_paper_width || DEFAULT_COLUMNS, 10);
|
||||||
.replace(/\u00a0/g, " ")
|
return Number.isFinite(value) ? value : DEFAULT_COLUMNS;
|
||||||
.replace(/[ \t]+/g, " ")
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapLine(line, width = RECEIPT_COLUMNS) {
|
function cleanText(value) {
|
||||||
if (line.length <= width) {
|
return String(value ?? "")
|
||||||
return [line];
|
.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 = [];
|
const wrapped = [];
|
||||||
let remaining = line;
|
let rest = clean;
|
||||||
while (remaining.length > width) {
|
while (rest.length > width) {
|
||||||
let breakpoint = remaining.lastIndexOf(" ", width);
|
let splitAt = rest.lastIndexOf(" ", width);
|
||||||
if (breakpoint <= 0) {
|
if (splitAt <= 0) {
|
||||||
breakpoint = width;
|
splitAt = width;
|
||||||
}
|
}
|
||||||
wrapped.push(remaining.slice(0, breakpoint).trimEnd());
|
wrapped.push(rest.slice(0, splitAt).trimEnd());
|
||||||
remaining = remaining.slice(breakpoint).trimStart();
|
rest = rest.slice(splitAt).trimStart();
|
||||||
}
|
}
|
||||||
if (remaining) {
|
if (rest) {
|
||||||
wrapped.push(remaining);
|
wrapped.push(rest);
|
||||||
}
|
}
|
||||||
return wrapped;
|
return wrapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(value, currency) {
|
function bytesToBase64(bytes) {
|
||||||
const amount = Number(value || 0).toFixed(2);
|
let binary = "";
|
||||||
return currency?.symbol ? `${currency.symbol}${amount}` : amount;
|
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) {
|
class EscPosBuilder {
|
||||||
const cleanLeft = String(left || "");
|
constructor(columns) {
|
||||||
const cleanRight = String(right || "");
|
this.columns = columns;
|
||||||
const space = Math.max(1, width - cleanLeft.length - cleanRight.length);
|
this.bytes = [];
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
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
|
raw(...values) {
|
||||||
const receiptNumber = order?.name || "";
|
this.bytes.push(...values);
|
||||||
if (receiptNumber) lines.push(`Order: ${receiptNumber}`);
|
|
||||||
|
|
||||||
if (table) {
|
|
||||||
lines.push(`TABLE: ${table.name}`.padEnd(20) + `GUESTS: ${order.customer_count || 1}`);
|
|
||||||
}
|
}
|
||||||
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
|
text(value) {
|
||||||
for (const orderline of order?.get_orderlines?.() || []) {
|
for (const char of cleanText(value)) {
|
||||||
const product = orderline.get_product?.();
|
this.bytes.push(char.charCodeAt(0) & 0xff);
|
||||||
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)}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. TOTALS
|
line(value = "") {
|
||||||
lines.push("=".repeat(RECEIPT_COLUMNS));
|
this.text(value);
|
||||||
lines.push(leftRight("SUBTOTAL", money(order?.get_total_without_tax?.(), currency)));
|
this.bytes.push(CR, LF);
|
||||||
|
|
||||||
const tax = order?.get_total_tax?.();
|
|
||||||
if (tax) {
|
|
||||||
lines.push(leftRight("TAX", money(tax, currency)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("-".repeat(RECEIPT_COLUMNS));
|
lines(values) {
|
||||||
lines.push(leftRight("TOTAL", money(order?.get_total_with_tax?.(), currency)));
|
for (const value of values) {
|
||||||
|
this.line(value);
|
||||||
const change = order?.get_change?.();
|
}
|
||||||
if (change) {
|
|
||||||
lines.push(leftRight("CHANGE", money(change, currency)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("-".repeat(RECEIPT_COLUMNS));
|
init() {
|
||||||
|
this.raw(ESC, 0x40);
|
||||||
// Custom Odoo Footer
|
}
|
||||||
if (order?.pos?.config?.receipt_footer) {
|
|
||||||
lines.push(order.pos.config.receipt_footer);
|
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) {
|
async function getLogoRasterBytes(pos, columns) {
|
||||||
const domLines = normalizeReceiptText(receiptElement?.innerText || "");
|
if (pos?.config?.qz_print_logo === false || !pos?.company_logo_base64) {
|
||||||
if (domLines.length > 1) {
|
return [];
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [
|
return [
|
||||||
ESC + "@", // Initialize printer
|
ESC, 0x61, 0x01,
|
||||||
ESC + "a" + "\x00", // Left align
|
GS, 0x76, 0x30, 0x00,
|
||||||
body,
|
widthBytes & 0xff, (widthBytes >> 8) & 0xff,
|
||||||
NEWLINE + NEWLINE + NEWLINE + NEWLINE + NEWLINE,
|
height & 0xff, (height >> 8) & 0xff,
|
||||||
GS + "V" + "\x42" + "\x00", // Feed paper to cutting position and perform partial cut (standard ESC/POS)
|
...data,
|
||||||
].join("");
|
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, {
|
patch(ReceiptScreen.prototype, {
|
||||||
@ -191,38 +363,32 @@ patch(ReceiptScreen.prototype, {
|
|||||||
await qz.websocket.connect({ retries: 2, delay: 1 });
|
await qz.websocket.connect({ retries: 2, delay: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const printerName = this.pos.config.qz_printer_name;
|
const config = qz.configs.create(this.pos.config.qz_printer_name, {
|
||||||
const config = qz.configs.create(printerName, {
|
|
||||||
encoding: "CP437",
|
encoding: "CP437",
|
||||||
spool: { end: "\n" },
|
copies: 1,
|
||||||
|
spool: { size: 1 },
|
||||||
});
|
});
|
||||||
|
const payload = await buildEscPosReceipt(this.currentOrder, this.pos);
|
||||||
const receiptElement = document.querySelector(".pos-receipt") || document.querySelector(".pos-receipt-container");
|
await qz.print(config, [{
|
||||||
if (!receiptElement) {
|
type: "raw",
|
||||||
return false;
|
format: "command",
|
||||||
}
|
flavor: "base64",
|
||||||
|
data: payload,
|
||||||
const printData = buildEscPosReceipt(receiptElement, this.currentOrder);
|
}]);
|
||||||
|
|
||||||
await qz.print(config, [printData]);
|
|
||||||
|
|
||||||
if (this.currentOrder) {
|
if (this.currentOrder) {
|
||||||
this.currentOrder._printed = true;
|
this.currentOrder._printed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("QZ Tray Print Error:", 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",
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Fallback to default Odoo print behavior
|
|
||||||
return super.printReceipt(...arguments);
|
|
||||||
}
|
}
|
||||||
}
|
return super.printReceipt(...arguments);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,22 @@
|
|||||||
<label string="Printer Name" for="qz_printer_name" class="col-lg-3 o_light_label"/>
|
<label string="Printer Name" for="qz_printer_name" class="col-lg-3 o_light_label"/>
|
||||||
<field name="qz_printer_name"/>
|
<field name="qz_printer_name"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Receipt Width" for="qz_paper_width" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_paper_width"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Print Logo" for="qz_print_logo" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_print_logo"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Enable Cutter" for="qz_enable_cutter" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_enable_cutter"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16" invisible="not qz_enable_cutter">
|
||||||
|
<label string="Cut Mode" for="qz_cut_mode" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_cut_mode"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
</xpath>
|
</xpath>
|
||||||
@ -33,6 +49,22 @@
|
|||||||
<label string="Printer Name" for="qz_printer_name" class="col-lg-3 o_light_label"/>
|
<label string="Printer Name" for="qz_printer_name" class="col-lg-3 o_light_label"/>
|
||||||
<field name="qz_printer_name"/>
|
<field name="qz_printer_name"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Receipt Width" for="qz_paper_width" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_paper_width"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Print Logo" for="qz_print_logo" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_print_logo"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16">
|
||||||
|
<label string="Enable Cutter" for="qz_enable_cutter" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_enable_cutter"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt16" invisible="not qz_enable_cutter">
|
||||||
|
<label string="Cut Mode" for="qz_cut_mode" class="col-lg-3 o_light_label"/>
|
||||||
|
<field name="qz_cut_mode"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
</setting>
|
</setting>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user