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();