From 54a9872394a0a31b579fb9805ab2ccac1bb48d57 Mon Sep 17 00:00:00 2001 From: lubshad Date: Sat, 23 May 2026 21:28:58 +0530 Subject: [PATCH 1/2] Add raw receipt printing --- POS/src/utils/printInvoice.js | 102 +++++++++++++++--- POS/src/utils/qzTray.js | 39 ++++++- .../pos_next_esc_pos_receipt.json | 31 ++++++ 3 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 pos_next/pos_next/print_format/pos_next_esc_pos_receipt/pos_next_esc_pos_receipt.json diff --git a/POS/src/utils/printInvoice.js b/POS/src/utils/printInvoice.js index d6111d989..98bd280b5 100644 --- a/POS/src/utils/printInvoice.js +++ b/POS/src/utils/printInvoice.js @@ -3,11 +3,15 @@ import { logger } from "@/utils/logger" import { getOfflineReceiptPayload } from "@/utils/offline/offlineReceiptCache" import { getOfflineInvoiceByOfflineId } from "@/utils/offline/sync" import { offlineWorker } from "@/utils/offline/workerClient" -import { printHTML as qzPrintHTML } from "@/utils/qzTray" +import { + printHTML as qzPrintHTML, + printRawCommands as qzPrintRawCommands, +} from "@/utils/qzTray" const log = logger.create("PrintInvoice") const DEFAULT_PRINT_FORMAT = "POS Next Receipt" +const printFormatMetaCache = new Map() // ============================================================================ // Shared helpers @@ -277,6 +281,32 @@ async function resolvePrintSettings(posProfile, printFormat, letterhead) { return { printFormat: DEFAULT_PRINT_FORMAT, letterhead } } +async function getPrintFormatMeta(printFormat) { + if (!printFormat) return null + if (printFormatMetaCache.has(printFormat)) { + return printFormatMetaCache.get(printFormat) + } + + try { + const meta = await call("frappe.client.get_value", { + doctype: "Print Format", + filters: { name: printFormat }, + fieldname: ["name", "raw_printing"], + }) + printFormatMetaCache.set(printFormat, meta || null) + return meta || null + } catch (err) { + log.warn(`Could not fetch Print Format metadata for ${printFormat}:`, err?.message || err) + printFormatMetaCache.set(printFormat, null) + return null + } +} + +async function isRawPrintFormat(printFormat) { + const meta = await getPrintFormatMeta(printFormat) + return Boolean(Number.parseInt(meta?.raw_printing || 0, 10)) +} + // ============================================================================ // Browser printing (opens /printview in a new window) // ============================================================================ @@ -290,22 +320,25 @@ export async function printInvoice(invoiceData, printFormat = null, letterhead = try { if (!invoiceData?.name) throw new Error("Invalid invoice data") - invoiceData = await hydrateLocalOnlyInvoice(invoiceData) + const printableInvoice = await hydrateLocalOnlyInvoice(invoiceData) // Pending offline / local IDs are not in ERPNext — use embedded receipt HTML. - if (isLocalOnlyInvoiceName(invoiceData.name)) { - if (invoiceData.items?.length > 0) return printInvoiceCustom(invoiceData) + if (isLocalOnlyInvoiceName(printableInvoice.name)) { + if (printableInvoice.items?.length > 0) return printInvoiceCustom(printableInvoice) throw new Error( __("This offline receipt is no longer in browser storage. Sync the invoice, then print from history."), ) } - const doctype = invoiceData.doctype || "Sales Invoice" + const doctype = printableInvoice.doctype || "Sales Invoice" const format = printFormat || DEFAULT_PRINT_FORMAT + if (await isRawPrintFormat(format)) { + return rawPrintInvoice(printableInvoice.name, format) + } const params = new URLSearchParams({ doctype, - name: invoiceData.name, + name: printableInvoice.name, format, no_letterhead: letterhead ? 0 : 1, _lang: "en", @@ -381,6 +414,10 @@ export async function silentPrintInvoice(invoiceName, printFormat = null) { } const format = printFormat || DEFAULT_PRINT_FORMAT + if (await isRawPrintFormat(format)) { + return rawPrintInvoice(invoiceName, format) + } + const result = await call("frappe.www.printview.get_html_and_style", { doc: "Sales Invoice", name: invoiceName, @@ -403,6 +440,26 @@ export async function silentPrintInvoice(invoiceName, printFormat = null) { return true } +/** + * Fetch server-rendered raw commands and send them directly to QZ Tray. + * The selected print format must have Raw Printing enabled in Frappe. + */ +export async function rawPrintInvoice(invoiceName, printFormat) { + const format = printFormat || DEFAULT_PRINT_FORMAT + const result = await call("frappe.www.printview.get_rendered_raw_commands", { + doc: "Sales Invoice", + name: invoiceName, + print_format: format, + }) + + const rawCommands = result?.raw_commands || result?.message?.raw_commands + if (!rawCommands) throw new Error("Failed to get raw print commands from server") + + await qzPrintRawCommands(rawCommands) + log.info(`Raw silent print sent for ${invoiceName}`) + return true +} + /** * Silent-print a full invoice dict using the same HTML as the offline receipt fallback. */ @@ -420,22 +477,22 @@ export async function silentPrintInvoiceFromDoc(invoiceData) { * internally, so no separate connection logic is needed here. */ export async function printWithSilentFallback(invoiceData, printFormat = null) { - invoiceData = await hydrateLocalOnlyInvoice(invoiceData) - const invoiceName = invoiceData?.name + const printableInvoice = await hydrateLocalOnlyInvoice(invoiceData) + const invoiceName = printableInvoice?.name if (!invoiceName) throw new Error("Invalid invoice data — missing name") if ( isLocalOnlyInvoiceName(invoiceName) && - invoiceData.items?.length > 0 + printableInvoice.items?.length > 0 ) { try { - await silentPrintInvoiceFromDoc(invoiceData) + await silentPrintInvoiceFromDoc(printableInvoice) return { method: "silent", success: true } } catch (err) { log.warn("Silent local receipt failed, falling back to browser:", err?.message || err) } try { - printInvoiceCustom(invoiceData) + printInvoiceCustom(printableInvoice) return { method: "browser", success: true } } catch (err) { log.error("Browser print for local receipt failed:", err) @@ -443,15 +500,34 @@ export async function printWithSilentFallback(invoiceData, printFormat = null) { } } + let resolvedPrintFormat = printFormat try { - await silentPrintInvoice(invoiceName, printFormat) + if (!resolvedPrintFormat) { + if (printableInvoice?.pos_profile) { + const settings = await resolvePrintSettings(printableInvoice.pos_profile, printFormat, null) + resolvedPrintFormat = settings.printFormat + } else { + const invoiceDoc = await call("pos_next.api.invoices.get_invoice", { + invoice_name: invoiceName, + }) + if (invoiceDoc?.pos_profile) { + const settings = await resolvePrintSettings(invoiceDoc.pos_profile, printFormat, null) + resolvedPrintFormat = settings.printFormat + } + } + } + + await silentPrintInvoice(invoiceName, resolvedPrintFormat) return { method: "silent", success: true } } catch (err) { log.warn("Silent print failed, falling back to browser:", err?.message || err) } try { - await printInvoiceByName(invoiceName, printFormat) + const fallbackFormat = (await isRawPrintFormat(resolvedPrintFormat)) + ? DEFAULT_PRINT_FORMAT + : printFormat + await printInvoiceByName(invoiceName, fallbackFormat) return { method: "browser", success: true } } catch (err) { log.error("Browser print fallback also failed:", err) diff --git a/POS/src/utils/qzTray.js b/POS/src/utils/qzTray.js index e9e3b3e6c..f0514838f 100644 --- a/POS/src/utils/qzTray.js +++ b/POS/src/utils/qzTray.js @@ -241,7 +241,9 @@ export async function printHTML(html, printerName, options = {}) { const printer = printerName || getSavedPrinterName() if (!printer) { - throw new Error("No printer selected. Please select a printer in POS Settings.") + throw new Error( + "No printer selected. Please select a printer in POS Settings.", + ) } const config = qz.configs.create(printer, { @@ -274,3 +276,38 @@ export async function printHTML(html, printerName, options = {}) { throw err } } + +/** + * Send raw printer commands, such as ESC/POS, directly to a printer via QZ Tray. + * + * @param {string} commands - Rendered printer-native command string + * @param {string} [printerName] - Target printer. Falls back to saved printer. + * @returns {Promise} true if print was dispatched successfully + */ +export async function printRawCommands(commands, printerName) { + if (!qz.websocket.isActive()) { + const ok = await connect() + if (!ok) { + throw new Error("QZ Tray is not available") + } + } + + const printer = printerName || getSavedPrinterName() + if (!printer) { + throw new Error( + "No printer selected. Please select a printer in POS Settings.", + ) + } + + const config = qz.configs.create(printer) + const data = [commands] + + try { + await qz.print(config, data) + log.info(`Raw print job sent to "${printer}"`) + return true + } catch (err) { + log.error(`Raw print failed on "${printer}":`, err?.message || err) + throw err + } +} diff --git a/pos_next/pos_next/print_format/pos_next_esc_pos_receipt/pos_next_esc_pos_receipt.json b/pos_next/pos_next/print_format/pos_next_esc_pos_receipt/pos_next_esc_pos_receipt.json new file mode 100644 index 000000000..6bee7ae12 --- /dev/null +++ b/pos_next/pos_next/print_format/pos_next_esc_pos_receipt/pos_next_esc_pos_receipt.json @@ -0,0 +1,31 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2026-05-23 00:00:00.000000", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Sales Invoice", + "docstatus": 0, + "doctype": "Print Format", + "font_size": 12, + "html": null, + "idx": 0, + "line_breaks": 0, + "margin_bottom": 0.0, + "margin_left": 0.0, + "margin_right": 0.0, + "margin_top": 0.0, + "modified": "2026-05-23 00:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "POS Next ESC/POS Receipt", + "owner": "Administrator", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_type": "Jinja", + "raw_commands": "{{ \"\\x1B@\" }}\n{{ \"\\x1Ba\\x01\" }}\n{{ doc.company }}\nTAX INVOICE\n{{ \"\\x1Ba\\x00\" }}\nInvoice: {{ doc.name }}\nDate: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}\n{% if doc.customer_name %}Customer: {{ doc.customer_name }}\n{% endif %}--------------------------------\n{% for item in doc.items %}\n{{ (item.item_name or item.item_code)[:32] }}{% if item.is_free_item %} (FREE){% endif %}\n{% if item.is_free_item %}{{ \"%.0f\"|format(item.qty) }} x FREE 0.00\n{% else %}{{ \"%.0f\"|format(item.qty) }} x {{ \"%.2f\"|format(item.price_list_rate or item.rate) }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n{% endif %}{% if item.discount_amount %}Discount: -{{ \"%.2f\"|format(item.discount_amount or 0) }}\n{% endif %}{% if item.serial_no %}Serial: {{ item.serial_no | replace(\"\\n\", \", \") }}\n{% endif %}{% endfor %}--------------------------------\n{% if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 %}Subtotal: {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\nTax: {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n{% endif %}{% if doc.discount_amount %}Additional Discount: -{{ \"%.2f\"|format(doc.discount_amount|abs) }}\n{% endif %}{{ \"\\x1B!\\x08\" }}TOTAL: {{ \"%.2f\"|format(doc.grand_total) }}{{ \"\\x1B!\\x00\" }}\n{% if doc.payments %}--------------------------------\nPayments:\n{% for payment in doc.payments %}{{ payment.mode_of_payment }}: {{ \"%.2f\"|format(payment.amount) }}\n{% endfor %}Paid: {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n{% if doc.change_amount and doc.change_amount > 0 %}Change: {{ \"%.2f\"|format(doc.change_amount) }}\n{% endif %}{% if doc.outstanding_amount and doc.outstanding_amount > 0 %}BALANCE DUE: {{ \"%.2f\"|format(doc.outstanding_amount) }}\n{% endif %}{% endif %}--------------------------------\n{{ \"\\x1Ba\\x01\" }}\nThank you for your business!\nPowered by POS Next\n\n\n{{ \"\\x1DVA\\x00\" }}", + "raw_printing": 1, + "show_section_headings": 0, + "standard": "No" +} From 301991cd01f922cd292acf3c42b3dfc071911aac Mon Sep 17 00:00:00 2001 From: lubshad Date: Mon, 25 May 2026 16:52:55 +0530 Subject: [PATCH 2/2] Fix QZ raw print dispatch --- POS/src/utils/printInvoice.js | 20 +++++++++++++++++--- POS/src/utils/qzTray.js | 9 ++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/POS/src/utils/printInvoice.js b/POS/src/utils/printInvoice.js index 98bd280b5..0f7228813 100644 --- a/POS/src/utils/printInvoice.js +++ b/POS/src/utils/printInvoice.js @@ -293,8 +293,9 @@ async function getPrintFormatMeta(printFormat) { filters: { name: printFormat }, fieldname: ["name", "raw_printing"], }) - printFormatMetaCache.set(printFormat, meta || null) - return meta || null + const normalizedMeta = meta?.message || meta + printFormatMetaCache.set(printFormat, normalizedMeta || null) + return normalizedMeta || null } catch (err) { log.warn(`Could not fetch Print Format metadata for ${printFormat}:`, err?.message || err) printFormatMetaCache.set(printFormat, null) @@ -303,8 +304,15 @@ async function getPrintFormatMeta(printFormat) { } async function isRawPrintFormat(printFormat) { + if (typeof printFormat === "string" && /esc[\s/-]*pos/i.test(printFormat)) { + return true + } const meta = await getPrintFormatMeta(printFormat) - return Boolean(Number.parseInt(meta?.raw_printing || 0, 10)) + return Boolean(Number.parseInt(meta?.raw_printing ?? 0, 10)) +} + +function containsRawPrinterCommands(value) { + return typeof value === "string" && /[\x1b\x1d]/.test(value) } // ============================================================================ @@ -429,6 +437,12 @@ export async function silentPrintInvoice(invoiceName, printFormat = null) { const style = result?.style || result?.message?.style || "" if (!html) throw new Error("Failed to get print HTML from server") + if (containsRawPrinterCommands(html)) { + await qzPrintRawCommands(html) + log.info(`Raw print sent from rendered command body for ${invoiceName}`) + return true + } + const fullHTML = ` diff --git a/POS/src/utils/qzTray.js b/POS/src/utils/qzTray.js index f0514838f..e2a1b4b48 100644 --- a/POS/src/utils/qzTray.js +++ b/POS/src/utils/qzTray.js @@ -300,7 +300,14 @@ export async function printRawCommands(commands, printerName) { } const config = qz.configs.create(printer) - const data = [commands] + const data = [ + { + type: "raw", + format: "command", + flavor: "plain", + data: commands, + }, + ] try { await qz.print(config, data)