Files
kitchen-agent/printer.js
2025-10-23 19:02:56 -04:00

1758 lines
70 KiB
JavaScript

const { ThermalPrinter, PrinterTypes } = require('node-thermal-printer');
const path = require('path');
const fs = require('fs');
const os = require('os');
const PDFDocument = require('pdfkit');
const pdfPrinter = require('pdf-to-printer');
const { SerialPort } = require('serialport');
let sharp = null;
try { sharp = require('sharp'); } catch (_) {}
let PNGLib = null;
try { PNGLib = require('pngjs').PNG; } catch (_) {}
class PrinterManager {
constructor() {
this.printer = null;
this.config = null;
this.currentInterface = null; // normalized interface string used by printer lib
this.inFlightJobs = new Map(); // key -> { promise, timestamp } to dedupe concurrent prints
this.recentlyCompleted = new Map(); // key -> timestamp for recently completed jobs
this.cleanupInterval = null;
// Start periodic cleanup of old entries
this.startCleanupTimer();
}
startCleanupTimer() {
// Clean up old in-flight and recently completed entries every 30 seconds
this.cleanupInterval = setInterval(() => {
const now = Date.now();
const inFlightTimeout = 120000; // 2 minutes for stuck in-flight jobs
const completedTimeout = 60000; // 1 minute for completed jobs tracking
// Clean up old in-flight jobs (might be stuck)
for (const [key, data] of this.inFlightJobs.entries()) {
if (now - data.timestamp > inFlightTimeout) {
console.log(`[PrinterManager] Cleaning up stuck in-flight job: ${key}`);
this.inFlightJobs.delete(key);
}
}
// Clean up old recently completed tracking
for (const [key, timestamp] of this.recentlyCompleted.entries()) {
if (now - timestamp > completedTimeout) {
this.recentlyCompleted.delete(key);
}
}
}, 30000);
}
stopCleanupTimer() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
}
initializePrinter(config) {
this.config = config;
const printerTypeMap = {
'epson': PrinterTypes.EPSON,
'star': PrinterTypes.STAR,
'tanca': PrinterTypes.TANCA,
'brother': PrinterTypes.BROTHER,
'custom': PrinterTypes.CUSTOM
};
const printerType = printerTypeMap[config.printerType] || PrinterTypes.EPSON;
const printerWidth = parseInt(config.printerWidth, 10) || 48;
// Normalize interface from config (supports usb/serial/network)
const normalized = this.buildInterfaceFromConfig(config);
this.currentInterface = normalized;
this.printer = new ThermalPrinter({
type: printerType,
interface: normalized,
width: printerWidth,
characterSet: 'PC437_USA',
removeSpecialCharacters: false,
lineCharacter: config.lineStyle === 'double' ? '=' : (config.lineStyle === 'dashed' ? '-' : '-')
});
console.log('Printer initialized:', {
type: config.printerType,
interface: normalized,
width: printerWidth
});
}
// Ensure only one print per (orderId,type) runs at a time; others await the same promise
// Also track recently completed jobs to prevent rapid duplicate submissions
async _withInflight(key, executor, cooldownMs = 30000) {
try {
const now = Date.now();
// Check if there's an active in-flight job
if (this.inFlightJobs.has(key)) {
const existing = this.inFlightJobs.get(key);
console.log(`[PrinterManager] Reusing in-flight job for ${key}`);
return await existing.promise;
}
// Check if this job was recently completed (within the configured cooldown)
if (this.recentlyCompleted.has(key)) {
const completedTime = this.recentlyCompleted.get(key);
const timeSince = now - completedTime;
if (timeSince < cooldownMs) {
console.log(`[PrinterManager] Skipping duplicate print for ${key} (completed ${Math.floor(timeSince/1000)}s ago)`);
return { success: true, message: 'Duplicate print prevented - recently completed' };
}
}
const p = (async () => {
try {
const result = await executor();
// Track successful completion
if (result && result.success) {
this.recentlyCompleted.set(key, Date.now());
}
return result;
} finally {
this.inFlightJobs.delete(key);
}
})();
this.inFlightJobs.set(key, { promise: p, timestamp: now });
return await p;
} catch (err) {
throw err;
}
}
buildInterfaceFromConfig(config) {
const iface = (config.printerInterface || '').toLowerCase();
const rawPath = (config.printerPath || '').trim();
// Network: accept "ip" or "ip:port" and prefix tcp://
if (iface === 'network') {
if (!rawPath) return 'tcp://127.0.0.1:9100';
if (rawPath.startsWith('tcp://')) return rawPath;
// If port not provided, default 9100
const hasPort = rawPath.includes(':');
return hasPort ? `tcp://${rawPath}` : `tcp://${rawPath}:9100`;
}
// Serial on Windows needs \\ \\ . \\ COMX
if (iface === 'serial') {
// If looks like COMx, normalize, else pass-through (linux e.g. /dev/ttyS0)
if (/^COM\d+$/i.test(rawPath)) {
return `\\\\.\\${rawPath.toUpperCase()}`;
}
return rawPath || 'COM1';
}
// Default USB or direct path
return rawPath || '/dev/usb/lp0';
}
// Helper to safely call printer APIs that may not exist in some printer profiles
safeCall(methodName, ...args) {
try {
if (!this.printer) return;
const fn = this.printer[methodName];
if (typeof fn === 'function') {
return fn.apply(this.printer, args);
}
} catch (err) {
console.warn(`Printer method ${methodName} failed:`, err.message);
}
}
// Helper to safely call a specific printer instance
safeCallOn(instance, methodName, ...args) {
try {
if (!instance) return;
const fn = instance[methodName];
if (typeof fn === 'function') {
return fn.apply(instance, args);
}
} catch (err) {
console.warn(`Printer method ${methodName} failed:`, err.message);
}
}
// Direct execute for COM ports to bypass library-side connectivity quirks on Windows
async executeDirectWrite() {
return new Promise((resolve, reject) => {
try {
if (!this.printer) return reject(new Error('Printer not initialized'));
const buffer = this.printer.getBuffer();
const comPath = this.currentInterface && this.currentInterface.startsWith('\\\\.\\')
? this.currentInterface
: null;
if (!comPath) {
return reject(new Error('Direct write is only for COM ports'));
}
const port = new SerialPort({
path: comPath,
baudRate: 115200,
autoOpen: false
});
port.on('error', (err) => {
reject(err);
});
port.open((err) => {
if (err) {
reject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
port.write(buffer, (err2) => {
if (err2) {
try { port.close(); } catch (_) {}
reject(new Error(`Failed to write to printer: ${err2.message}`));
} else {
setTimeout(() => {
try {
port.close(() => resolve(true));
} catch (_) {
resolve(true);
}
}, 1000);
}
});
});
} catch (error) {
reject(error);
}
});
}
// Direct write for a specific printer instance and COM path
async executeDirectWriteFor(instance, comPath) {
return new Promise((resolve, reject) => {
try {
if (!instance) return reject(new Error('Printer not initialized'));
const buffer = instance.getBuffer();
if (!comPath) {
return reject(new Error('Direct write is only for COM ports'));
}
const port = new SerialPort({
path: comPath,
baudRate: 9600,
autoOpen: false
});
port.on('error', (err) => {
reject(err);
});
port.open((err) => {
if (err) {
reject(new Error(`Failed to open serial port: ${err.message}`));
return;
}
port.write(buffer, (err2) => {
if (err2) {
try { port.close(); } catch (_) {}
reject(new Error(`Failed to write to printer: ${err2.message}`));
} else {
setTimeout(() => {
try {
port.close(() => resolve(true));
} catch (_) {
resolve(true);
}
}, 1000);
}
});
});
} catch (error) {
reject(error);
}
});
}
// Build list of target interfaces from config.selectedPrintersJson
// If empty, falls back to main printer config
getSelectedTargets() {
const targets = [];
try {
const raw = this.config && this.config.selectedPrintersJson;
if (!raw) return targets;
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(arr)) return targets;
arr.forEach(t => {
if (!t || !t.type || !t.interface) return;
const norm = this.normalizeTargetInterface(t.type, t.interface);
if (norm && (t.type === 'network' || t.type === 'com' || t.type === 'serial' || t.type === 'usb' || t.type === 'system')) {
targets.push({ type: t.type, interface: norm });
}
});
// Dedupe by type+normalized interface to avoid double prints
const seen = new Set();
const unique = [];
for (const t of targets) {
const key = `${String(t.type).toLowerCase()}|${String(t.interface)}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(t);
}
return unique;
} catch (e) {
console.warn('Failed to parse selectedPrintersJson:', e.message);
}
return targets;
}
// Build a target object from main printer config
getMainPrinterTarget() {
try {
if (!this.config || !this.config.printerInterface || !this.config.printerPath) {
return null;
}
const iface = (this.config.printerInterface || '').toLowerCase();
let type = iface; // network, serial, usb
if (iface === 'serial') type = 'com';
const norm = this.normalizeTargetInterface(type, this.config.printerPath);
if (!norm) return null;
return { type, interface: norm };
} catch (e) {
return null;
}
}
// Check if main printer is included in multi-printer selection
isMainPrinterInSelection(targets) {
const mainTarget = this.getMainPrinterTarget();
if (!mainTarget) return false;
return targets.some(t =>
t.type === mainTarget.type && t.interface === mainTarget.interface
);
}
normalizeTargetInterface(type, value) {
const val = String(value || '').trim();
if (!val) return null;
const t = String(type || '').toLowerCase();
if (t === 'network') {
if (val.startsWith('tcp://')) return val;
const hasPort = val.includes(':');
return hasPort ? `tcp://${val}` : `tcp://${val}:9100`;
}
if (t === 'com' || t === 'serial') {
if (/^\\\\\.\\COM\d+$/i.test(val)) return val;
if (/^COM\d+$/i.test(val)) return `\\\\.\\${val.toUpperCase()}`;
return val; // linux tty path
}
if (t === 'usb') {
return val; // e.g., /dev/usb/lp0
}
if (t === 'system') {
return val; // spooler printer name
}
return null;
}
createPrinterForInterface(normalizedInterface) {
const printerTypeMap = {
'epson': PrinterTypes.EPSON,
'star': PrinterTypes.STAR,
'tanca': PrinterTypes.TANCA,
'brother': PrinterTypes.BROTHER,
'custom': PrinterTypes.CUSTOM
};
const printerType = printerTypeMap[this.config.printerType] || PrinterTypes.EPSON;
const printerWidth = parseInt(this.config.printerWidth, 10) || 48;
return new ThermalPrinter({
type: printerType,
interface: normalizedInterface,
width: printerWidth,
characterSet: 'PC437_USA',
removeSpecialCharacters: false,
lineCharacter: this.config.lineStyle === 'double' ? '=' : (this.config.lineStyle === 'dashed' ? '-' : '-')
});
}
// Create a printer instance from a full printer configuration object
createPrinterInstanceFromConfig(printerConfig) {
const printerTypeMap = {
'epson': PrinterTypes.EPSON,
'star': PrinterTypes.STAR,
'tanca': PrinterTypes.TANCA,
'brother': PrinterTypes.BROTHER,
'custom': PrinterTypes.CUSTOM
};
const printerType = printerTypeMap[printerConfig.printer_type] || PrinterTypes.EPSON;
const printerWidth = parseInt(printerConfig.paper_width, 10) || 48;
// Normalize the interface based on type
let normalizedInterface = printerConfig.interface;
if (printerConfig.type === 'network') {
if (!normalizedInterface.startsWith('tcp://')) {
const hasPort = normalizedInterface.includes(':');
normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`;
}
} else if (printerConfig.type === 'com' || printerConfig.type === 'serial') {
if (/^COM\d+$/i.test(normalizedInterface)) {
normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`;
}
}
return new ThermalPrinter({
type: printerType,
interface: normalizedInterface,
width: printerWidth,
characterSet: 'PC437_USA',
removeSpecialCharacters: false,
lineCharacter: printerConfig.line_style === 'double' ? '=' : (printerConfig.line_style === 'dashed' ? '-' : '-')
});
}
// Get printer max dots width from config
getPrinterMaxDotsWidthFromConfig(cfg) {
if (cfg.logo_max_width_dots && Number.isFinite(Number(cfg.logo_max_width_dots))) {
const n = Number(cfg.logo_max_width_dots);
if (n > 0) return n;
}
const widthChars = parseInt(cfg.paper_width, 10) || 48;
if (widthChars >= 48) return 576; // typical 80mm
if (widthChars >= 42) return 512;
return 384; // typical 58mm
}
async executeForInstance(instance, normalizedInterface) {
if (normalizedInterface && /^\\\\\.\\COM\d+/i.test(normalizedInterface)) {
await this.executeDirectWriteFor(instance, normalizedInterface);
} else {
await instance.execute();
}
}
getPrinterMaxDotsWidth() {
const cfg = this.config || {};
if (cfg.logoMaxWidthDots && Number.isFinite(Number(cfg.logoMaxWidthDots))) {
const n = Number(cfg.logoMaxWidthDots);
if (n > 0) return n;
}
const widthChars = parseInt(cfg.printerWidth, 10) || 48;
if (widthChars >= 48) return 576; // typical 80mm
if (widthChars >= 42) return 512;
return 384; // typical 58mm
}
async printLogoWithFit(instance, logoPath) {
try {
if (!logoPath || !fs.existsSync(logoPath)) return false;
const maxWidth = this.getPrinterMaxDotsWidth();
this.safeCallOn(instance, 'alignCenter');
// Prefer sharp when available (handles PNG/JPEG/GIF and resizes well)
if (sharp) {
try {
const resized = await sharp(logoPath)
.resize({ width: maxWidth, fit: 'inside', withoutEnlargement: true })
.png()
.toBuffer();
await instance.printImageBuffer(resized);
instance.newLine();
return true;
} catch (e) {
console.warn('Sharp resize failed, falling back:', e.message);
}
}
// Fallback for PNG using pngjs and nearest-neighbor downscale
const lower = logoPath.toLowerCase();
if (PNGLib && (lower.endsWith('.png'))) {
try {
const input = fs.readFileSync(logoPath);
const png = PNGLib.sync.read(input);
if (png.width <= maxWidth) {
await instance.printImageBuffer(input);
instance.newLine();
return true;
}
const ratio = maxWidth / png.width;
const targetW = Math.max(1, Math.floor(png.width * ratio));
const targetH = Math.max(1, Math.floor(png.height * ratio));
const out = new PNGLib({ width: targetW, height: targetH });
for (let y2 = 0; y2 < targetH; y2++) {
const y1 = Math.floor(y2 / ratio);
for (let x2 = 0; x2 < targetW; x2++) {
const x1 = Math.floor(x2 / ratio);
const idx1 = (png.width * y1 + x1) << 2;
const idx2 = (targetW * y2 + x2) << 2;
out.data[idx2] = png.data[idx1];
out.data[idx2 + 1] = png.data[idx1 + 1];
out.data[idx2 + 2] = png.data[idx1 + 2];
out.data[idx2 + 3] = png.data[idx1 + 3];
}
}
const buf = PNGLib.sync.write(out);
await instance.printImageBuffer(buf);
instance.newLine();
return true;
} catch (e) {
console.warn('PNG fallback resize failed:', e.message);
}
}
// Last resort: try library native path (may crop if too wide)
await instance.printImage(logoPath);
instance.newLine();
return true;
} catch (err) {
console.error('Failed to print logo:', err.message);
return false;
}
}
async printLogoWithFitCustom(instance, logoPath, maxWidth) {
try {
if (!logoPath || !fs.existsSync(logoPath)) return false;
this.safeCallOn(instance, 'alignCenter');
// Prefer sharp when available (handles PNG/JPEG/GIF and resizes well)
if (sharp) {
try {
const resized = await sharp(logoPath)
.resize({ width: maxWidth, fit: 'inside', withoutEnlargement: true })
.png()
.toBuffer();
await instance.printImageBuffer(resized);
instance.newLine();
return true;
} catch (e) {
console.warn('Sharp resize failed, falling back:', e.message);
}
}
// Fallback for PNG using pngjs and nearest-neighbor downscale
const lower = logoPath.toLowerCase();
if (PNGLib && (lower.endsWith('.png'))) {
try {
const input = fs.readFileSync(logoPath);
const png = PNGLib.sync.read(input);
if (png.width <= maxWidth) {
await instance.printImageBuffer(input);
instance.newLine();
return true;
}
const ratio = maxWidth / png.width;
const targetW = Math.max(1, Math.floor(png.width * ratio));
const targetH = Math.max(1, Math.floor(png.height * ratio));
const out = new PNGLib({ width: targetW, height: targetH });
for (let y2 = 0; y2 < targetH; y2++) {
const y1 = Math.floor(y2 / ratio);
for (let x2 = 0; x2 < targetW; x2++) {
const x1 = Math.floor(x2 / ratio);
const idx1 = (png.width * y1 + x1) << 2;
const idx2 = (targetW * y2 + x2) << 2;
out.data[idx2] = png.data[idx1];
out.data[idx2 + 1] = png.data[idx1 + 1];
out.data[idx2 + 2] = png.data[idx1 + 2];
out.data[idx2 + 3] = png.data[idx1 + 3];
}
}
const buf = PNGLib.sync.write(out);
await instance.printImageBuffer(buf);
instance.newLine();
return true;
} catch (e) {
console.warn('PNG fallback resize failed:', e.message);
}
}
// Last resort: try library native path (may crop if too wide)
await instance.printImage(logoPath);
instance.newLine();
return true;
} catch (err) {
console.error('Failed to print logo:', err.message);
return false;
}
}
buildQrContent(template, order) {
try {
const tpl = (template || 'ORDER-{id}').toString();
const createdAt = order && order.createdAt ? new Date(order.createdAt * 1000).toISOString() : new Date().toISOString();
const type = order && order.order && order.order.type ? order.order.type : '';
const total = (order && (order.totalAmount || (order.order && order.order.amount))) || 0;
return tpl
.replace(/\{id\}/g, String(order && order.id || ''))
.replace(/\{total\}/g, String(total))
.replace(/\{type\}/g, String(type))
.replace(/\{createdAt\}/g, String(createdAt));
} catch (_) {
return `ORDER-${order && order.id ? order.id : ''}`;
}
}
// Get optimal column widths based on printer width
getColumnWidths(config) {
const width = parseInt(config.paper_width || config.printerWidth, 10) || 48;
// For narrow receipts (58mm / 32 chars), use tighter layout
if (width <= 32) {
// Total: 0.95 (5% buffer to ensure no edge wrapping)
return {
useTable: true,
itemWidth: 0.55, // 55% for item name
priceWidth: 0.40, // 40% for price (generous space to prevent wrapping)
priceMinChars: 8 // Minimum chars needed for price column
};
}
// For standard receipts (80mm / 48 chars), use balanced layout
else if (width <= 48) {
// Total: 0.95 (5% buffer to ensure no edge wrapping)
return {
useTable: true,
itemWidth: 0.60, // 60% for item name
priceWidth: 0.35, // 35% for price (good space to prevent wrapping)
priceMinChars: 10 // Minimum chars needed for price column
};
}
// For wide receipts (letter / 80 chars), use spacious layout
else {
// Total: 0.95 (5% buffer to ensure no edge wrapping)
return {
useTable: true,
itemWidth: 0.65, // 65% for item name
priceWidth: 0.30, // 30% for price (adequate space)
priceMinChars: 12 // Minimum chars needed for price column
};
}
}
// Truncate text if needed to fit in column
truncateText(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
async renderTest(instance) {
this.safeCallOn(instance, 'clear');
this.safeCallOn(instance, 'alignCenter');
instance.println('TEST PRINT');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'alignLeft');
instance.println('Printer Type: ' + (this.config.printerType || 'N/A'));
instance.println('Interface: ' + (instance.interface || this.currentInterface || this.config.printerPath || 'N/A'));
instance.println('Width: ' + (this.config.printerWidth || 'N/A') + ' chars');
instance.println('Time: ' + new Date().toLocaleString());
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'alignCenter');
instance.println('Test Successful');
this.safeCallOn(instance, 'cut');
}
async renderReceipt(instance, order, type = 'new', options = {}, cfg = null) {
// Use provided config or fallback to this.config
// Merge in global toggles for backward compatibility when per-printer config omits them
const base = cfg || this.config || {};
const globalCfg = this.config || {};
const config = {
...base,
show_customer_info: typeof base.show_customer_info !== 'undefined' ? base.show_customer_info : undefined,
show_order_items: typeof base.show_order_items !== 'undefined' ? base.show_order_items : undefined,
show_prices: typeof base.show_prices !== 'undefined' ? base.show_prices : undefined,
show_timestamps: typeof base.show_timestamps !== 'undefined' ? base.show_timestamps : undefined,
// Fallback to global string flags if per-printer booleans are not provided
showCustomerInfo: typeof base.show_customer_info === 'undefined' && typeof base.showCustomerInfo === 'undefined' ? globalCfg.showCustomerInfo : base.showCustomerInfo,
showOrderItems: typeof base.show_order_items === 'undefined' && typeof base.showOrderItems === 'undefined' ? globalCfg.showOrderItems : base.showOrderItems,
showPrices: typeof base.show_prices === 'undefined' && typeof base.showPrices === 'undefined' ? globalCfg.showPrices : base.showPrices,
showTimestamps: typeof base.show_timestamps === 'undefined' && typeof base.showTimestamps === 'undefined' ? globalCfg.showTimestamps : base.showTimestamps
};
// Reset buffer
this.safeCallOn(instance, 'clear');
// Logo (resized to fit) - use config-specific logo path and max width
if (config.logo_path && fs.existsSync(config.logo_path)) {
const maxWidth = this.getPrinterMaxDotsWidthFromConfig(config);
await this.printLogoWithFitCustom(instance, config.logo_path, maxWidth);
} else if (config.logoPath && fs.existsSync(config.logoPath)) {
// Backward compatibility with old config key
await this.printLogoWithFit(instance, config.logoPath);
}
// Business header
this.safeCallOn(instance, 'alignCenter');
const businessName = config.business_name || config.businessName;
const paperWidth = parseInt(config.paper_width || config.printerWidth, 10) || 48;
if (businessName) {
// Adjust text size based on business name length and paper width
const nameLength = businessName.length;
const maxCharsAtSize1 = paperWidth / 2; // Approximate chars at 2x size
// Use smaller text for long names on narrow paper
if (nameLength > maxCharsAtSize1 && paperWidth <= 32) {
this.safeCallOn(instance, 'setTextNormal');
this.safeCallOn(instance, 'bold', true);
instance.println(businessName);
this.safeCallOn(instance, 'bold', false);
} else {
this.safeCallOn(instance, 'setTextSize', 1, 1);
this.safeCallOn(instance, 'bold', true);
instance.println(businessName);
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
}
this.safeCallOn(instance, 'newLine');
}
const contactLines = [
config.business_address || config.businessAddress,
config.business_phone || config.businessPhone,
config.business_website || config.businessWebsite,
config.business_email || config.businessEmail
].filter(Boolean);
const contactSize = config.business_contact_size || config.businessContactSize || 'normal';
if (contactLines.length > 0) {
// For narrow paper, force normal size to avoid overflow
if (paperWidth <= 32 && contactSize === 'large') {
this.safeCallOn(instance, 'setTextNormal');
} else if (contactSize === 'large') {
this.safeCallOn(instance, 'setTextSize', 1, 1);
} else {
this.safeCallOn(instance, 'setTextNormal');
}
contactLines.forEach(line => instance.println(line));
// Reset size after contacts
this.safeCallOn(instance, 'setTextNormal');
}
// Optional header text
const headerText = config.header_text || config.headerText;
if (headerText) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'bold', true);
instance.println(headerText);
this.safeCallOn(instance, 'bold', false);
}
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'newLine');
// Order type indicator
if (type === 'canceled') {
this.safeCallOn(instance, 'bold', true);
this.safeCallOn(instance, 'setTextSize', 1, 1);
instance.println('*** ORDER CANCELED ***');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
this.safeCallOn(instance, 'newLine');
if (options && options.reason) {
this.safeCallOn(instance, 'alignLeft');
instance.println('Cancellation reason:');
this.safeCallOn(instance, 'bold', true);
instance.println(String(options.reason));
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'newLine');
}
} else {
this.safeCallOn(instance, 'bold', true);
this.safeCallOn(instance, 'setTextSize', 1, 1);
instance.println('NEW ORDER');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
this.safeCallOn(instance, 'newLine');
}
// Order details
this.safeCallOn(instance, 'alignLeft');
this.safeCallOn(instance, 'setTextSize', 1, 1);
this.safeCallOn(instance, 'bold', true);
instance.println(`Order #${order.id}`);
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
this.safeCallOn(instance, 'newLine');
const showTimestamps = (config.show_timestamps !== false) && (config.showTimestamps !== 'false');
if (showTimestamps) {
instance.println(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`);
}
instance.println(`Type: ${order.order.type || 'N/A'}`);
// Customer info
const showCustomerInfo = (config.show_customer_info !== false) && (config.showCustomerInfo !== 'false');
if (showCustomerInfo) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'bold', true);
instance.println('CUSTOMER INFO:');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'newLine');
instance.println(`Name: ${order.customer.name || 'N/A'}`);
if (order.customer.phoneNumber) instance.println(`Phone: ${order.customer.phoneNumber}`);
if (order.order.deliveryAddress) {
instance.println(`Address: ${order.order.deliveryAddress}`);
}
}
// Items
const showOrderItems = (config.show_order_items !== false) && (config.showOrderItems !== 'false');
if (showOrderItems) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'bold', true);
instance.println('ORDER ITEMS:');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'newLine');
if (order.order.items && Array.isArray(order.order.items)) {
const showPrices = (config.show_prices !== false) && (config.showPrices !== 'false');
// Get optimal column widths for this printer
const colWidths = this.getColumnWidths(config);
// Ensure proper alignment for tableCustom
this.safeCallOn(instance, 'alignLeft');
order.order.items.forEach(item => {
const itemName = item.itemName || item.name || 'Unknown Item';
const qty = item.qty || 1;
const price = item.price || 0;
const totalItemPrice = (price * qty).toFixed(2);
// Use tableCustom for proper alignment of item and price
if (showPrices) {
try {
// Ensure price text doesn't have extra spaces
const priceText = `$${totalItemPrice}`.trim();
instance.tableCustom([
{ text: `${qty}x ${itemName}`, align: "LEFT", width: colWidths.itemWidth },
{ text: priceText, align: "RIGHT", width: colWidths.priceWidth }
]);
} catch (e) {
// Fallback if tableCustom is not available
instance.println(`${qty}x ${itemName} - $${totalItemPrice}`);
}
} else {
instance.println(`${qty}x ${itemName}`);
}
// Print addons with indentation
if (item.addons && Array.isArray(item.addons) && item.addons.length > 0) {
item.addons.forEach(addon => {
const addonName = addon.name || addon;
if (showPrices && addon.price && addon.price > 0) {
const addonPrice = (addon.price || 0).toFixed(2);
// Ensure price text doesn't have extra spaces
const priceText = `$${addonPrice}`.trim();
try {
instance.tableCustom([
{ text: ` + ${addonName}`, align: "LEFT", width: colWidths.itemWidth },
{ text: priceText, align: "RIGHT", width: colWidths.priceWidth }
]);
} catch (e) {
instance.println(` + ${addonName} - $${addonPrice}`);
}
} else {
instance.println(` + ${addonName}`);
}
});
}
// Print exclusions with indentation
if (item.exclude && Array.isArray(item.exclude) && item.exclude.length > 0) {
item.exclude.forEach(ex => {
const excludeName = ex.name || ex;
instance.println(` - NO ${excludeName}`);
});
}
});
}
}
// Special instructions
if (order.order.specialInstructions) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'bold', true);
instance.println('SPECIAL INSTRUCTIONS:');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'newLine');
instance.println(order.order.specialInstructions);
}
// Delivery instructions
if (order.order.deliveryInstructions) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'bold', true);
instance.println('DELIVERY INSTRUCTIONS:');
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'newLine');
instance.println(order.order.deliveryInstructions);
}
// Food allergy
if (order.order.foodAllergy) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'alignCenter');
this.safeCallOn(instance, 'setTextSize', 1, 1);
this.safeCallOn(instance, 'bold', true);
this.safeCallOn(instance, 'invert', true);
instance.println(' FOOD ALLERGY WARNING ');
this.safeCallOn(instance, 'invert', false);
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'alignLeft');
if (order.order.foodAllergyNotes) {
instance.println(order.order.foodAllergyNotes.toUpperCase());
}
}
// Total
const showPricesForTotal = (config.show_prices !== false) && (config.showPrices !== 'false');
if (showPricesForTotal) {
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'alignLeft'); // Ensure proper alignment for tableCustom
//this.safeCallOn(instance, 'setTextSize', 1, 1);
// Price breakdown (normal font, right-aligned): Subtotal, Tax, Delivery Fee
// Note: Keep TOTAL styling unchanged below
try {
// Extract values with fallbacks to support multiple payload shapes
const rawSubtotal = (order && typeof order.amount !== 'undefined')
? order.amount
: (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null);
const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined')
? order.taxAmount
: (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null);
const rawTaxRate = (order && typeof order.taxRate !== 'undefined')
? order.taxRate
: (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null);
const rawDelivery = (order && typeof order.deliveryFee !== 'undefined')
? order.deliveryFee
: (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null);
this.safeCallOn(instance, 'alignRight');
if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) {
instance.println(`Subtotal: $${rawSubtotal.toFixed(2)}`);
}
if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) {
let taxLabel = 'Tax';
if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) {
const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, '');
taxLabel = `Tax (${rateStr}%)`;
}
instance.println(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`);
}
if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) {
instance.println(`Delivery Fee: $${rawDelivery.toFixed(2)}`);
}
} catch (_) {}
this.safeCallOn(instance, 'setTextSize', 1, 1);
this.safeCallOn(instance, 'bold', true);
this.safeCallOn(instance, 'alignRight');
const totalAmount = (order.totalAmount || 0).toFixed(2);
instance.println(`TOTAL: $${totalAmount}`);
this.safeCallOn(instance, 'bold', false);
this.safeCallOn(instance, 'setTextNormal');
}
// QR code
const qrCodeEnabled = config.qr_code_enabled !== false && config.qrCodeEnabled !== 'false';
if (qrCodeEnabled) {
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'alignCenter');
try {
const qrSize = config.qr_code_size || config.qrCodeSize || 3;
let sizeRaw = parseInt(qrSize, 10);
// Auto-adjust QR size based on paper width if not explicitly set
const paperWidth = parseInt(config.paper_width || config.printerWidth, 10) || 48;
if (isNaN(sizeRaw) || sizeRaw <= 0) {
// Default sizes based on paper width
if (paperWidth <= 32) {
sizeRaw = 2; // Small QR for narrow paper
} else if (paperWidth <= 48) {
sizeRaw = 3; // Medium QR for standard paper
} else {
sizeRaw = 4; // Larger QR for wide paper
}
}
const size = Math.min(8, Math.max(2, sizeRaw));
const correction = config.qr_code_correction || config.qrCodeCorrection || 'M';
const template = config.qr_code_content_template || config.qrCodeContentTemplate || 'ORDER-{id}';
const content = this.buildQrContent(template, order);
instance.printQR(content, { cellSize: size, correction, model: 2 });
} catch (error) {
console.error('Failed to print QR code:', error.message);
}
}
// Footer
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'alignCenter');
const footerText = (typeof config.footer_text !== 'undefined' ? config.footer_text : config.footerText);
if (footerText) {
this.safeCallOn(instance, 'bold', true);
instance.println(footerText);
this.safeCallOn(instance, 'bold', false);
}
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'newLine');
this.safeCallOn(instance, 'cut');
}
async testPrint() {
if (!this.printer) {
throw new Error('Printer not initialized');
}
const targets = this.getSelectedTargets();
console.log('[PrinterManager] Test print - Selected targets:', targets.length);
if (targets.length === 0) {
// Single/fallback printer path using main printer config
console.log('[PrinterManager] Using single/fallback printer:', this.currentInterface);
this.safeCall('clear');
await this.renderTest(this.printer);
try {
if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) {
await this.executeDirectWrite();
} else {
await this.printer.execute();
}
console.log('[PrinterManager] Test print successful (single printer)');
return { success: true };
} catch (error) {
console.error('[PrinterManager] Test print failed:', error.message);
return { success: false, error: error.message };
}
}
// Multi-printer: fan out concurrently
console.log('[PrinterManager] Printing to', targets.length, 'printer(s)');
const jobs = targets.map(async (t, idx) => {
console.log(`[PrinterManager] Test print job ${idx + 1}/${targets.length}: ${t.type} -> ${t.interface}`);
if (t.type === 'system') {
await this.printSystemTest(t.interface);
} else {
const p = this.createPrinterForInterface(t.interface);
await this.renderTest(p);
await this.executeForInstance(p, t.interface);
}
});
const results = await Promise.allSettled(jobs);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.length - succeeded;
console.log(`[PrinterManager] Test print results: ${succeeded} succeeded, ${failed} failed`);
if (succeeded === 0) {
const firstErr = results.find(r => r.status === 'rejected');
return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All test prints failed' };
}
return { success: true, message: `Test sent to ${succeeded} printer(s)` + (failed > 0 ? `, ${failed} failed` : '') };
}
// Test print with specific printer configuration
async testPrintWithConfig(printerConfig) {
try {
console.log(`[PrinterManager] Test print for: ${printerConfig.name} (${printerConfig.type}:${printerConfig.interface})`);
if (printerConfig.type === 'system') {
await this.printSystemTest(printerConfig.interface);
return { success: true, message: 'Test sent to system printer' };
}
// Create printer instance from config
const instance = this.createPrinterInstanceFromConfig(printerConfig);
// Render test receipt
this.safeCallOn(instance, 'clear');
this.safeCallOn(instance, 'alignCenter');
instance.println('TEST PRINT');
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'alignLeft');
instance.println(`Printer: ${printerConfig.name}`);
instance.println(`Type: ${printerConfig.printer_type}`);
instance.println(`Paper: ${printerConfig.paper_format} (${printerConfig.paper_width} chars)`);
instance.println(`Interface: ${printerConfig.interface}`);
instance.println(`Time: ${new Date().toLocaleString()}`);
this.safeCallOn(instance, 'drawLine');
this.safeCallOn(instance, 'alignCenter');
instance.println('Test Successful');
this.safeCallOn(instance, 'cut');
// Execute print
let normalizedInterface = printerConfig.interface;
if (printerConfig.type === 'network') {
if (!normalizedInterface.startsWith('tcp://')) {
const hasPort = normalizedInterface.includes(':');
normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`;
}
} else if (printerConfig.type === 'com' || printerConfig.type === 'serial') {
if (/^COM\d+$/i.test(normalizedInterface)) {
normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`;
}
}
await this.executeForInstance(instance, normalizedInterface);
console.log(`[PrinterManager] Test print successful for: ${printerConfig.name}`);
return { success: true, message: `Test sent to ${printerConfig.name}` };
} catch (error) {
console.error(`[PrinterManager] Test print failed for ${printerConfig.name}:`, error.message);
return { success: false, error: error.message };
}
}
async printOrderReceipt(order, type = 'new', options = {}) {
if (!this.printer) {
throw new Error('Printer not initialized');
}
const key = `order:${order && order.id}:${type}`;
const cooldownMs = (options && typeof options.cooldownMs === 'number') ? options.cooldownMs : 30000;
return await this._withInflight(key, async () => {
try {
const targets = this.getSelectedTargets();
console.log(`[PrinterManager] Print order #${order.id} - Selected targets:`, targets.length);
if (targets.length === 0) {
// Single/fallback printer path using main printer config
console.log(`[PrinterManager] Printing order #${order.id} to single/fallback printer:`, this.currentInterface);
await this.renderReceipt(this.printer, order, type, options);
// Execute print with one-time reinitialize-on-failure retry
try {
if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) {
await this.executeDirectWrite();
} else {
await this.printer.execute();
}
} catch (execErr) {
console.warn('[PrinterManager] Execute failed, attempting reinitialize and retry:', execErr.message);
try {
if (this.config) {
this.initializePrinter(this.config);
await this.renderReceipt(this.printer, order, type, options);
if (this.currentInterface && this.currentInterface.startsWith('\\\\.\\')) {
await this.executeDirectWrite();
} else {
await this.printer.execute();
}
} else {
throw execErr;
}
} catch (retryErr) {
throw retryErr;
}
}
console.log(`[PrinterManager] Receipt printed for order #${order.id}`);
return { success: true };
}
// Multi-printer
console.log(`[PrinterManager] Printing order #${order.id} to ${targets.length} printer(s)`);
const jobs = targets.map(async (t, idx) => {
console.log(`[PrinterManager] Print job ${idx + 1}/${targets.length} for order #${order.id}: ${t.type} -> ${t.interface}`);
if (t.type === 'system') {
await this.printSystemReceipt(order, type, options, t.interface);
} else {
const p = this.createPrinterForInterface(t.interface);
await this.renderReceipt(p, order, type, options);
try {
await this.executeForInstance(p, t.interface);
} catch (multiErr) {
console.warn(`[PrinterManager] Execute failed for ${t.interface}, retrying after reinit:`, multiErr.message);
// Recreate instance and retry once
const p2 = this.createPrinterForInterface(t.interface);
await this.renderReceipt(p2, order, type, options);
await this.executeForInstance(p2, t.interface);
}
}
});
const results = await Promise.allSettled(jobs);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.length - succeeded;
console.log(`[PrinterManager] Print results for order #${order.id}: ${succeeded} succeeded, ${failed} failed`);
if (succeeded === 0) {
const firstErr = results.find(r => r.status === 'rejected');
return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All prints failed' };
}
return { success: true };
} catch (error) {
console.error('[PrinterManager] Print error:', error.message);
return { success: false, error: error.message };
}
}, cooldownMs);
}
// Print order with per-printer configurations from database
async printOrderReceiptWithPrinterConfigs(order, printerConfigs, type = 'new', options = {}) {
const key = `order:${order && order.id}:${type}`;
const cooldownMs = (options && typeof options.cooldownMs === 'number') ? options.cooldownMs : 30000;
return await this._withInflight(key, async () => {
try {
if (!printerConfigs || printerConfigs.length === 0) {
console.log('[PrinterManager] No printer configurations provided, falling back to legacy method');
return await this.printOrderReceipt(order, type, options);
}
console.log(`[PrinterManager] Printing order #${order.id} to ${printerConfigs.length} configured printer(s)`);
const jobs = printerConfigs.map(async (config, idx) => {
console.log(`[PrinterManager] Print job ${idx + 1}/${printerConfigs.length} for order #${order.id}: ${config.name}`);
if (config.type === 'system') {
await this.printSystemReceiptWithConfig(order, type, options, config);
} else {
const instance = this.createPrinterInstanceFromConfig(config);
await this.renderReceipt(instance, order, type, options, config);
// Normalize interface for execution
let normalizedInterface = config.interface;
if (config.type === 'network') {
if (!normalizedInterface.startsWith('tcp://')) {
const hasPort = normalizedInterface.includes(':');
normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`;
}
} else if (config.type === 'com' || config.type === 'serial') {
if (/^COM\d+$/i.test(normalizedInterface)) {
normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`;
}
}
await this.executeForInstance(instance, normalizedInterface);
}
console.log(`[PrinterManager] Successfully printed to: ${config.name}`);
});
const results = await Promise.allSettled(jobs);
const succeeded = results.filter(r => r.status === 'fulfilled').length;
const failed = results.length - succeeded;
console.log(`[PrinterManager] Print results for order #${order.id}: ${succeeded} succeeded, ${failed} failed`);
if (succeeded === 0) {
const firstErr = results.find(r => r.status === 'rejected');
return { success: false, error: firstErr && firstErr.reason ? firstErr.reason.message || String(firstErr.reason) : 'All prints failed' };
}
return { success: true };
} catch (error) {
console.error('[PrinterManager] Print error:', error.message);
return { success: false, error: error.message };
}
}, cooldownMs);
}
// ===== System printer (Windows spooler) support via PDF rendering =====
async printSystemTest(printerName) {
const filePath = path.join(os.tmpdir(), `kitchen-agent-test-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
try {
await this.writeTestPdfToPath(filePath);
await pdfPrinter.print(filePath, { printer: printerName });
// Give the spooler a moment to open the file before deletion
await new Promise(resolve => setTimeout(resolve, 1500));
} finally {
try { fs.unlinkSync(filePath); } catch (_) {}
}
}
async printSystemReceipt(order, type, options, printerName) {
const filePath = path.join(os.tmpdir(), `kitchen-agent-receipt-${order && order.id ? order.id : 'x'}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
try {
await this.writeReceiptPdfToPath(filePath, order, type, options);
await pdfPrinter.print(filePath, { printer: printerName });
// Give the spooler a moment to open the file before deletion
await new Promise(resolve => setTimeout(resolve, 2000));
} finally {
try { fs.unlinkSync(filePath); } catch (_) {}
}
}
async printSystemReceiptWithConfig(order, type, options, printerConfig) {
const filePath = path.join(os.tmpdir(), `kitchen-agent-receipt-${order && order.id ? order.id : 'x'}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
try {
await this.writeReceiptPdfToPathWithConfig(filePath, order, type, options, printerConfig);
await pdfPrinter.print(filePath, { printer: printerConfig.interface });
// Give the spooler a moment to open the file before deletion
await new Promise(resolve => setTimeout(resolve, 2000));
} finally {
try { fs.unlinkSync(filePath); } catch (_) {}
}
}
async writeTestPdfToPath(filePath) {
const doc = new PDFDocument({ size: 'A4', margin: 36 });
return new Promise((resolve, reject) => {
const stream = fs.createWriteStream(filePath);
doc.pipe(stream);
// Header
doc.fontSize(18).text('TEST PRINT', { align: 'center' });
doc.moveDown(0.5);
doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke();
doc.moveDown(0.5);
doc.fontSize(12).text(`Printer Type: ${this.config && this.config.printerType || 'N/A'}`);
doc.text(`Interface: ${this.currentInterface || this.config && this.config.printerPath || 'N/A'}`);
doc.text(`Width: ${this.config && this.config.printerWidth || 'N/A'} chars`);
doc.text(`Time: ${new Date().toLocaleString()}`);
doc.moveDown(0.5);
doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke();
doc.moveDown(0.5);
doc.fontSize(14).text('Test Successful', { align: 'center' });
doc.end();
stream.on('finish', () => resolve(true));
stream.on('error', reject);
});
}
async writeReceiptPdfToPath(filePath, order, type = 'new', options = {}) {
const cfg = this.config || {};
const doc = new PDFDocument({ size: 'A4', margin: 36 });
const maxWidth = 523; // 559 - 36
return new Promise(async (resolve, reject) => {
const stream = fs.createWriteStream(filePath);
doc.pipe(stream);
// Business header
doc.fontSize(18).text(cfg.businessName || 'KITCHEN ORDER', { align: 'center' });
if (cfg.businessAddress || cfg.businessPhone || cfg.businessWebsite || cfg.businessEmail) {
doc.moveDown(0.2);
doc.fontSize((cfg.businessContactSize || 'normal') === 'large' ? 12 : 10);
[cfg.businessAddress, cfg.businessPhone, cfg.businessWebsite, cfg.businessEmail]
.filter(Boolean)
.forEach(line => doc.text(line, { align: 'center' }));
}
if (cfg.headerText) {
doc.moveDown(0.4);
doc.fontSize(12).text(cfg.headerText, { align: 'center' });
}
doc.moveDown(0.4);
doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke();
// Order banner
doc.moveDown(0.4);
doc.fontSize(14).text(type === 'canceled' ? '*** ORDER CANCELED ***' : 'NEW ORDER', { align: 'left' });
if (type === 'canceled' && options && options.reason) {
doc.moveDown(0.2);
doc.fontSize(12).text(`Cancellation reason: ${String(options.reason)}`);
}
// Order details
doc.moveDown(0.4);
doc.fontSize(12).text(`Order #${order.id}`);
if (cfg.showTimestamps === 'true' || !cfg.showTimestamps) {
doc.text(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`);
}
doc.text(`Type: ${order.order.type || 'N/A'}`);
// Customer
if (cfg.showCustomerInfo === 'true' || !cfg.showCustomerInfo) {
doc.moveDown(0.4);
doc.fontSize(12).text('CUSTOMER:');
doc.fontSize(11).text(order.customer.name || 'N/A');
if (order.customer.phoneNumber) doc.text(order.customer.phoneNumber);
if (order.order.deliveryAddress) doc.text(`Address: ${order.order.deliveryAddress}`);
}
// Items
if (order.order.items && Array.isArray(order.order.items) && (cfg.showOrderItems === 'true' || !cfg.showOrderItems)) {
doc.moveDown(0.4);
doc.fontSize(12).text('ITEMS:');
doc.fontSize(11);
order.order.items.forEach(item => {
const itemName = item.itemName || item.name || 'Unknown Item';
const qty = item.qty || 1;
const price = item.price || 0;
const line = (cfg.showPrices === 'true' || !cfg.showPrices)
? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}`
: `${qty}x ${itemName}`;
doc.text(line, { width: maxWidth });
if (item.addons && Array.isArray(item.addons)) {
item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`));
}
if (item.exclude && Array.isArray(item.exclude)) {
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
}
});
}
if (order.order.specialInstructions) {
doc.moveDown(0.4);
doc.fontSize(12).text('SPECIAL INSTRUCTIONS:');
doc.fontSize(11).text(String(order.order.specialInstructions), { width: maxWidth });
}
if (order.order.deliveryInstructions) {
doc.moveDown(0.4);
doc.fontSize(12).text('DELIVERY INSTRUCTIONS:');
doc.fontSize(11).text(String(order.order.deliveryInstructions), { width: maxWidth });
}
if (order.order.foodAllergy) {
doc.moveDown(0.4);
doc.fontSize(12).text('FOOD ALLERGY WARNING', { align: 'center' });
if (order.order.foodAllergyNotes) doc.fontSize(11).text(order.order.foodAllergyNotes, { align: 'center' });
}
if (cfg.showPrices === 'true' || !cfg.showPrices) {
doc.moveDown(0.4);
// Price breakdown (normal font, right-aligned)
try {
const rawSubtotal = (order && typeof order.amount !== 'undefined')
? order.amount
: (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null);
const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined')
? order.taxAmount
: (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null);
const rawTaxRate = (order && typeof order.taxRate !== 'undefined')
? order.taxRate
: (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null);
const rawDelivery = (order && typeof order.deliveryFee !== 'undefined')
? order.deliveryFee
: (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null);
if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) {
doc.fontSize(12).text(`Subtotal: $${rawSubtotal.toFixed(2)}`, { align: 'right' });
}
if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) {
let taxLabel = 'Tax';
if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) {
const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, '');
taxLabel = `Tax (${rateStr}%)`;
}
doc.fontSize(12).text(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`, { align: 'right' });
}
if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) {
doc.fontSize(12).text(`Delivery Fee: $${rawDelivery.toFixed(2)}`, { align: 'right' });
}
} catch (_) {}
// Keep TOTAL style unchanged
doc.fontSize(12).text(`TOTAL: $${(order.totalAmount || 0).toFixed(2)}`);
}
if (cfg.footerText) {
doc.moveDown(0.6);
doc.fontSize(12).text(cfg.footerText, { align: 'center' });
}
doc.end();
stream.on('finish', () => resolve(true));
stream.on('error', reject);
});
}
async writeReceiptPdfToPathWithConfig(filePath, order, type = 'new', options = {}, config = {}) {
const doc = new PDFDocument({ size: 'A4', margin: 36 });
const maxWidth = 523; // 559 - 36
return new Promise(async (resolve, reject) => {
const stream = fs.createWriteStream(filePath);
doc.pipe(stream);
// Business header
const businessName = config.business_name || config.businessName || 'KITCHEN ORDER';
doc.fontSize(18).text(businessName, { align: 'center' });
const businessAddress = config.business_address || config.businessAddress;
const businessPhone = config.business_phone || config.businessPhone;
const businessWebsite = config.business_website || config.businessWebsite;
const businessEmail = config.business_email || config.businessEmail;
if (businessAddress || businessPhone || businessWebsite || businessEmail) {
doc.moveDown(0.2);
const contactSize = config.business_contact_size || config.businessContactSize || 'normal';
doc.fontSize(contactSize === 'large' ? 12 : 10);
[businessAddress, businessPhone, businessWebsite, businessEmail]
.filter(Boolean)
.forEach(line => doc.text(line, { align: 'center' }));
}
const headerText = config.header_text || config.headerText;
if (headerText) {
doc.moveDown(0.4);
doc.fontSize(12).text(headerText, { align: 'center' });
}
doc.moveDown(0.4);
doc.moveTo(36, doc.y).lineTo(559, doc.y).stroke();
// Order banner
doc.moveDown(0.4);
doc.fontSize(14).text(type === 'canceled' ? '*** ORDER CANCELED ***' : 'NEW ORDER', { align: 'left' });
if (type === 'canceled' && options && options.reason) {
doc.moveDown(0.2);
doc.fontSize(12).text(`Cancellation reason: ${String(options.reason)}`);
}
// Order details
doc.moveDown(0.4);
doc.fontSize(12).text(`Order #${order.id}`);
// Merge global toggles for PDF path as well
const mergedShowTimestamps = (config.show_timestamps !== false) && (config.showTimestamps !== 'false');
if (mergedShowTimestamps) {
doc.text(`Time: ${new Date(order.createdAt * 1000).toLocaleString()}`);
}
doc.text(`Type: ${order.order.type || 'N/A'}`);
// Customer
const mergedShowCustomer = (config.show_customer_info !== false) && (config.showCustomerInfo !== 'false');
if (mergedShowCustomer) {
doc.moveDown(0.4);
doc.fontSize(12).text('CUSTOMER:');
doc.fontSize(11).text(order.customer.name || 'N/A');
if (order.customer.phoneNumber) doc.text(order.customer.phoneNumber);
if (order.order.deliveryAddress) doc.text(`Address: ${order.order.deliveryAddress}`);
}
// Items
const mergedShowItems = (config.show_order_items !== false) && (config.showOrderItems !== 'false');
const mergedShowPrices = (config.show_prices !== false) && (config.showPrices !== 'false');
if (order.order.items && Array.isArray(order.order.items) && mergedShowItems) {
doc.moveDown(0.4);
doc.fontSize(12).text('ITEMS:');
doc.fontSize(11);
order.order.items.forEach(item => {
const itemName = item.itemName || item.name || 'Unknown Item';
const qty = item.qty || 1;
const price = item.price || 0;
const line = mergedShowPrices
? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}`
: `${qty}x ${itemName}`;
doc.text(line, { width: maxWidth });
if (item.addons && Array.isArray(item.addons)) {
item.addons.forEach(addon => doc.text(` + ${addon.name || addon}`));
}
if (item.exclude && Array.isArray(item.exclude)) {
item.exclude.forEach(ex => doc.text(` - NO ${ex.name || ex}`));
}
});
}
if (order.order.specialInstructions) {
doc.moveDown(0.4);
doc.fontSize(12).text('SPECIAL INSTRUCTIONS:');
doc.fontSize(11).text(String(order.order.specialInstructions), { width: maxWidth });
}
if (order.order.deliveryInstructions) {
doc.moveDown(0.4);
doc.fontSize(12).text('DELIVERY INSTRUCTIONS:');
doc.fontSize(11).text(String(order.order.deliveryInstructions), { width: maxWidth });
}
if (order.order.foodAllergy) {
doc.moveDown(0.4);
doc.fontSize(12).text('FOOD ALLERGY WARNING', { align: 'center' });
if (order.order.foodAllergyNotes) doc.fontSize(11).text(order.order.foodAllergyNotes, { align: 'center' });
}
if (mergedShowPrices) {
doc.moveDown(0.4);
// Price breakdown (normal font, right-aligned)
try {
const rawSubtotal = (order && typeof order.amount !== 'undefined')
? order.amount
: (order && order.order && typeof order.order.amount !== 'undefined' ? order.order.amount : null);
const rawTaxAmount = (order && typeof order.taxAmount !== 'undefined')
? order.taxAmount
: (order && order.order && typeof order.order.taxAmount !== 'undefined' ? order.order.taxAmount : null);
const rawTaxRate = (order && typeof order.taxRate !== 'undefined')
? order.taxRate
: (order && order.order && typeof order.order.taxRate !== 'undefined' ? order.order.taxRate : null);
const rawDelivery = (order && typeof order.deliveryFee !== 'undefined')
? order.deliveryFee
: (order && order.order && typeof order.order.deliveryFee !== 'undefined' ? order.order.deliveryFee : null);
if (typeof rawSubtotal === 'number' && isFinite(rawSubtotal)) {
doc.fontSize(12).text(`Subtotal: $${rawSubtotal.toFixed(2)}`, { align: 'right' });
}
if (typeof rawTaxAmount === 'number' && isFinite(rawTaxAmount) && rawTaxAmount > 0) {
let taxLabel = 'Tax';
if (typeof rawTaxRate === 'number' && isFinite(rawTaxRate) && rawTaxRate > 0) {
const rateStr = Number(rawTaxRate).toFixed(2).replace(/\.?0+$/, '');
taxLabel = `Tax (${rateStr}%)`;
}
doc.fontSize(12).text(`${taxLabel}: $${rawTaxAmount.toFixed(2)}`, { align: 'right' });
}
if (typeof rawDelivery === 'number' && isFinite(rawDelivery) && rawDelivery > 0) {
doc.fontSize(12).text(`Delivery Fee: $${rawDelivery.toFixed(2)}`, { align: 'right' });
}
} catch (_) {}
// Keep TOTAL style unchanged
doc.fontSize(12).text(`TOTAL: $${(order.totalAmount || 0).toFixed(2)}`);
}
const footerText = config.footer_text || config.footerText;
if (footerText) {
doc.moveDown(0.6);
doc.fontSize(12).text(footerText, { align: 'center' });
}
doc.end();
stream.on('finish', () => resolve(true));
stream.on('error', reject);
});
}
isConnected() {
// Check if printer interface is accessible
if (!this.config || !this.currentInterface) {
return false;
}
// For file-based interfaces (USB, serial), check if file exists
if (this.config.printerInterface === 'usb') {
return fs.existsSync(this.currentInterface);
}
// For serial COM on Windows, SerialPort.list() will detect; assume connected and rely on write errors
if (this.config.printerInterface === 'serial') {
return true;
}
// For network printers, assume connected (will fail on execute if not)
return true;
}
// Lightweight reachability check for a specific normalized interface
async isTargetReachable(target) {
try {
if (!target || !target.type || !target.interface) return false;
const t = String(target.type).toLowerCase();
const iface = String(target.interface);
if (t === 'usb') {
return fs.existsSync(iface);
}
if (t === 'com' || t === 'serial') {
// Assume reachable; OS/driver may hold the port and our open test would be unreliable.
// Actual execute will surface connectivity errors which the worker handles by retrying later.
return true;
}
if (t === 'network') {
// Attempt TCP connection quickly (Node net not used here to avoid extra deps). Rely on library on execute.
return true;
}
if (t === 'system') {
// Assume system spooler availability; printing will surface errors.
return true;
}
return false;
} catch (_) {
return false;
}
}
// Determine if any configured printers are likely reachable now
async anyConfiguredPrinterReachable(printerConfigs) {
try {
if (Array.isArray(printerConfigs) && printerConfigs.length > 0) {
for (const cfg of printerConfigs) {
let normalizedInterface = cfg.interface;
let type = cfg.type;
if (type === 'network') {
if (!normalizedInterface.startsWith('tcp://')) {
const hasPort = normalizedInterface.includes(':');
normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`;
}
} else if (type === 'com' || type === 'serial') {
if (/^COM\d+$/i.test(normalizedInterface)) {
normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`;
type = 'com';
}
}
const reachable = await this.isTargetReachable({ type, interface: normalizedInterface });
if (reachable) return true;
}
return false;
}
// Also consider selected printers from config (selectedPrintersJson)
const selectedTargets = this.getSelectedTargets();
if (Array.isArray(selectedTargets) && selectedTargets.length > 0) {
for (const t of selectedTargets) {
const reachable = await this.isTargetReachable(t);
if (reachable) return true;
}
return false;
}
// Fallback to single configured printer
const main = this.getMainPrinterTarget();
if (!main) return false;
return await this.isTargetReachable(main);
} catch (_) {
return false;
}
}
async getAvailablePrinters() {
try {
const all = [];
// Windows system printers via pdf-to-printer
try {
const printers = await pdfPrinter.getPrinters();
printers.forEach(p => {
const sysName = p && (p.name || p.deviceId) ? (p.name || p.deviceId) : String(p);
all.push({
name: sysName,
type: 'system',
interface: sysName,
isDefault: !!(p && p.isDefault),
status: 'Available'
});
});
} catch (err) {
console.error('List system printers failed:', err.message);
}
// Serial/COM ports
try {
const ports = await SerialPort.list();
ports.forEach(port => {
all.push({
name: `${port.path} ${port.manufacturer ? '(' + port.manufacturer + ')' : ''}`.trim(),
type: 'com',
interface: port.path,
isDefault: false,
status: 'Available',
details: {
manufacturer: port.manufacturer,
serialNumber: port.serialNumber,
vendorId: port.vendorId,
productId: port.productId
}
});
});
} catch (err) {
console.error('List COM ports failed:', err.message);
}
return all;
} catch (error) {
console.error('getAvailablePrinters error:', error.message);
return [];
}
}
}
module.exports = new PrinterManager();