Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 103 additions & 13 deletions POS/src/utils/printInvoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -277,6 +281,40 @@ 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"],
})
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)
return null
}
}

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))
}

function containsRawPrinterCommands(value) {
return typeof value === "string" && /[\x1b\x1d]/.test(value)
}

// ============================================================================
// Browser printing (opens /printview in a new window)
// ============================================================================
Expand All @@ -290,22 +328,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",
Expand Down Expand Up @@ -381,6 +422,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,
Expand All @@ -392,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 = `<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><style>${style}</style></head>
Expand All @@ -403,6 +454,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.
*/
Expand All @@ -420,38 +491,57 @@ 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)
return { method: "browser", success: false }
}
}

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)
Expand Down
46 changes: 45 additions & 1 deletion POS/src/utils/qzTray.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -274,3 +276,45 @@ 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<boolean>} 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 = [
{
type: "raw",
format: "command",
flavor: "plain",
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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}