2025-10-23 19:02:56 -04:00
|
|
|
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);
|
2026-03-01 17:10:03 -05:00
|
|
|
const includePrices = (cfg.showPrices === 'true' || !cfg.showPrices);
|
2025-10-23 19:02:56 -04:00
|
|
|
order.order.items.forEach(item => {
|
|
|
|
|
const itemName = item.itemName || item.name || 'Unknown Item';
|
|
|
|
|
const qty = item.qty || 1;
|
|
|
|
|
const price = item.price || 0;
|
2026-03-01 17:10:03 -05:00
|
|
|
const line = includePrices
|
2025-10-23 19:02:56 -04:00
|
|
|
? `${qty}x ${itemName} - $${(price * qty).toFixed(2)}`
|
|
|
|
|
: `${qty}x ${itemName}`;
|
|
|
|
|
doc.text(line, { width: maxWidth });
|
|
|
|
|
if (item.addons && Array.isArray(item.addons)) {
|
2026-03-01 17:10:03 -05:00
|
|
|
item.addons.forEach(addon => {
|
|
|
|
|
const addonName = addon.name || addon;
|
|
|
|
|
const addonPrice = Number(addon && addon.price);
|
|
|
|
|
if (includePrices && isFinite(addonPrice) && addonPrice > 0) {
|
|
|
|
|
doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`);
|
|
|
|
|
} else {
|
|
|
|
|
doc.text(` + ${addonName}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-23 19:02:56 -04:00
|
|
|
}
|
|
|
|
|
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)) {
|
2026-03-01 17:10:03 -05:00
|
|
|
item.addons.forEach(addon => {
|
|
|
|
|
const addonName = addon.name || addon;
|
|
|
|
|
const addonPrice = Number(addon && addon.price);
|
|
|
|
|
if (mergedShowPrices && isFinite(addonPrice) && addonPrice > 0) {
|
|
|
|
|
doc.text(` + ${addonName} - $${addonPrice.toFixed(2)}`);
|
|
|
|
|
} else {
|
|
|
|
|
doc.text(` + ${addonName}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-10-23 19:02:56 -04:00
|
|
|
}
|
|
|
|
|
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 [];
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-01 17:10:03 -05:00
|
|
|
|
|
|
|
|
// ===== Abandoned Call Receipt =====
|
|
|
|
|
|
|
|
|
|
async renderAbandonedCallReceipt(instance, call, cfg) {
|
|
|
|
|
const width = parseInt(cfg.paper_width, 10) || 48;
|
|
|
|
|
const priority = call.callback_priority || 'low';
|
|
|
|
|
const score = Number(call.callback_score) || 0;
|
|
|
|
|
const isNarrow = width < 40;
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'clear');
|
|
|
|
|
|
|
|
|
|
// Logo
|
|
|
|
|
const logoPath = cfg.logo_path || cfg.logoPath || null;
|
|
|
|
|
if (logoPath && fs.existsSync(logoPath)) {
|
|
|
|
|
try {
|
|
|
|
|
const maxDots = this.getPrinterMaxDotsWidthFromConfig(cfg);
|
|
|
|
|
await this.printLogoWithFitCustom(instance, logoPath, maxDots);
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Top border
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
|
|
|
|
|
// Title: "ABANDONED CALL" in large centered text
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'setTextSize', 1, 1);
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println('ABANDONED CALL');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'setTextNormal');
|
|
|
|
|
|
|
|
|
|
// Priority badge using inverted text for visual prominence
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
if (priority === 'critical') {
|
|
|
|
|
this.safeCallOn(instance, 'setTextSize', 1, 1);
|
|
|
|
|
this.safeCallOn(instance, 'invert', true);
|
|
|
|
|
const pad = isNarrow ? ' ' : ' ';
|
|
|
|
|
instance.println(`${pad}CRITICAL PRIORITY${pad}`);
|
|
|
|
|
this.safeCallOn(instance, 'invert', false);
|
|
|
|
|
this.safeCallOn(instance, 'setTextNormal');
|
|
|
|
|
} else if (priority === 'high') {
|
|
|
|
|
this.safeCallOn(instance, 'invert', true);
|
|
|
|
|
const pad = isNarrow ? ' ' : ' ';
|
|
|
|
|
instance.println(`${pad}HIGH PRIORITY${pad}`);
|
|
|
|
|
this.safeCallOn(instance, 'invert', false);
|
|
|
|
|
} else if (priority === 'medium') {
|
|
|
|
|
instance.println('MEDIUM PRIORITY');
|
|
|
|
|
} else {
|
|
|
|
|
instance.println(`Priority: ${priority.toUpperCase()}`);
|
|
|
|
|
}
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
|
|
|
|
|
// Timestamp
|
|
|
|
|
const callTime = call.call_started_at
|
|
|
|
|
? new Date(Number(call.call_started_at) * 1000)
|
|
|
|
|
: new Date();
|
|
|
|
|
const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
|
|
|
const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
instance.println(`${dateStr} ${timeStr}`);
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
|
|
|
|
|
// Caller section
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println('CALLER');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
|
|
|
|
|
const phone = call.caller_phone_normalized || call.caller_phone || 'Unknown';
|
|
|
|
|
const name = call.caller_name || '';
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'setTextSize', 1, 0);
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println(`Phone: ${phone}`);
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'setTextNormal');
|
|
|
|
|
if (name) {
|
|
|
|
|
instance.println(`Name: ${name}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (call.is_known_customer) {
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'invert', true);
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
const orderCount = Number(call.previous_order_count || 0);
|
|
|
|
|
instance.println(` RETURNING CUSTOMER ${orderCount} previous order${orderCount !== 1 ? 's' : ''} `);
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'invert', false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instance.println(`Callback Score: ${score}/100`);
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
|
|
|
|
|
// What happened section
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println('WHAT HAPPENED');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
|
|
|
|
|
const stageMessages = {
|
|
|
|
|
ring_only: 'Rang but hung up before AI could answer.',
|
|
|
|
|
greeting_hangup: 'Heard the greeting then disconnected.',
|
|
|
|
|
silent_post_greeting: `Stayed on for ${Math.round(Number(call.time_after_greeting_sec || 0))}s without speaking.`,
|
|
|
|
|
minimal_speech: 'Spoke briefly then disconnected.',
|
|
|
|
|
pre_intent: 'Began speaking but intent was unclear.',
|
|
|
|
|
intent_identified: 'Expressed interest in ordering but disconnected.',
|
|
|
|
|
partial_order: 'Started placing an order then disconnected.',
|
|
|
|
|
partial_appointment: 'Was booking an appointment then disconnected.',
|
|
|
|
|
pre_confirmation: 'Order was nearly complete before disconnecting.',
|
|
|
|
|
system_failure: 'A system error caused the disconnection.'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stageMsg = stageMessages[call.abandonment_stage] || 'Call ended before completion.';
|
|
|
|
|
instance.println(stageMsg);
|
|
|
|
|
|
|
|
|
|
const dur = Number(call.duration_seconds || 0);
|
|
|
|
|
const durLabel = dur >= 60
|
|
|
|
|
? `${Math.floor(dur / 60)}m ${dur % 60}s`
|
|
|
|
|
: `${dur}s`;
|
|
|
|
|
instance.println(`Call duration: ${durLabel}`);
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
|
|
|
|
|
// Partial order items
|
|
|
|
|
const items = call.items || [];
|
|
|
|
|
let parsedSnapshot = null;
|
|
|
|
|
if (items.length === 0 && call.partial_order_snapshot) {
|
|
|
|
|
try {
|
|
|
|
|
parsedSnapshot = typeof call.partial_order_snapshot === 'string'
|
|
|
|
|
? JSON.parse(call.partial_order_snapshot)
|
|
|
|
|
: call.partial_order_snapshot;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const displayItems = items.length > 0
|
|
|
|
|
? items
|
|
|
|
|
: (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || [];
|
|
|
|
|
|
|
|
|
|
if (displayItems.length > 0) {
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println('ITEMS MENTIONED');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
|
|
|
|
|
const colWidths = this.getColumnWidths(cfg);
|
|
|
|
|
for (const item of displayItems) {
|
|
|
|
|
const itemName = item.item_name || item.itemName || item.name || 'Unknown';
|
|
|
|
|
const qty = item.quantity || item.qty || 1;
|
|
|
|
|
const price = item.unit_price || item.price || null;
|
|
|
|
|
|
|
|
|
|
if (price && colWidths.useTable) {
|
|
|
|
|
try {
|
|
|
|
|
instance.tableCustom([
|
|
|
|
|
{ text: `${qty}x ${itemName}`, align: 'LEFT', width: colWidths.itemWidth },
|
|
|
|
|
{ text: `$${Number(price).toFixed(2)}`, align: 'RIGHT', width: colWidths.priceWidth }
|
|
|
|
|
]);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
instance.println(` ${qty}x ${itemName} $${Number(price).toFixed(2)}`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
instance.println(` ${qty}x ${itemName}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (call.partial_order_value || call.estimated_order_value) {
|
|
|
|
|
const est = Number(call.partial_order_value || call.estimated_order_value);
|
|
|
|
|
if (est > 0) {
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignRight');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println(`Est. Value: $${est.toFixed(2)}`);
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LLM callback script
|
|
|
|
|
if (call.llm_callback_script) {
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println('SUGGESTED CALLBACK SCRIPT');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
|
|
|
|
|
const script = String(call.llm_callback_script);
|
|
|
|
|
const maxLineLen = width || 48;
|
|
|
|
|
const words = script.split(' ');
|
|
|
|
|
let line = '';
|
|
|
|
|
for (const word of words) {
|
|
|
|
|
if ((line + ' ' + word).trim().length > maxLineLen) {
|
|
|
|
|
instance.println(line.trim());
|
|
|
|
|
line = word;
|
|
|
|
|
} else {
|
|
|
|
|
line += (line ? ' ' : '') + word;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (line.trim()) instance.println(line.trim());
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Action section
|
|
|
|
|
if (score >= 60) {
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
this.safeCallOn(instance, 'setTextSize', 1, 1);
|
|
|
|
|
this.safeCallOn(instance, 'invert', true);
|
|
|
|
|
this.safeCallOn(instance, 'bold', true);
|
|
|
|
|
instance.println(' CALLBACK RECOMMENDED ');
|
|
|
|
|
this.safeCallOn(instance, 'bold', false);
|
|
|
|
|
this.safeCallOn(instance, 'invert', false);
|
|
|
|
|
this.safeCallOn(instance, 'setTextNormal');
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignLeft');
|
|
|
|
|
instance.println('[ ] Called back');
|
|
|
|
|
instance.println('[ ] No answer');
|
|
|
|
|
instance.println('[ ] Skipped');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Footer
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
this.safeCallOn(instance, 'alignCenter');
|
|
|
|
|
instance.println(`Ref #${call.id || ''}`);
|
|
|
|
|
instance.println('Powered by Think Link AI');
|
|
|
|
|
this.safeCallOn(instance, 'drawLine');
|
|
|
|
|
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'newLine');
|
|
|
|
|
this.safeCallOn(instance, 'partialCut');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async printAbandonedCallReceipt(call, printerConfigs) {
|
|
|
|
|
if (!printerConfigs || printerConfigs.length === 0) {
|
|
|
|
|
console.log('[PrinterManager] No printers configured for abandoned call receipts');
|
|
|
|
|
return { success: false, successCount: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const callId = call && call.id ? call.id : 'unknown';
|
|
|
|
|
console.log(`[PrinterManager] Printing abandoned call #${callId} to ${printerConfigs.length} printer(s)`);
|
|
|
|
|
|
|
|
|
|
const jobs = printerConfigs.map(async (cfg, idx) => {
|
|
|
|
|
console.log(`[PrinterManager] Abandoned call print ${idx + 1}/${printerConfigs.length}: ${cfg.name}`);
|
|
|
|
|
|
|
|
|
|
if (cfg.type === 'system') {
|
|
|
|
|
const filePath = path.join(os.tmpdir(), `kitchen-agent-abandoned-${callId}-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
|
|
|
|
|
try {
|
|
|
|
|
await this.writeAbandonedCallPdfToPath(filePath, call, cfg);
|
|
|
|
|
await pdfPrinter.print(filePath, { printer: cfg.interface });
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
|
} finally {
|
|
|
|
|
try { fs.unlinkSync(filePath); } catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const instance = this.createPrinterInstanceFromConfig(cfg);
|
|
|
|
|
await this.renderAbandonedCallReceipt(instance, call, cfg);
|
|
|
|
|
|
|
|
|
|
let normalizedInterface = cfg.interface;
|
|
|
|
|
if (cfg.type === 'network') {
|
|
|
|
|
if (!normalizedInterface.startsWith('tcp://')) {
|
|
|
|
|
const hasPort = normalizedInterface.includes(':');
|
|
|
|
|
normalizedInterface = hasPort ? `tcp://${normalizedInterface}` : `tcp://${normalizedInterface}:9100`;
|
|
|
|
|
}
|
|
|
|
|
} else if (cfg.type === 'com' || cfg.type === 'serial') {
|
|
|
|
|
if (/^COM\d+$/i.test(normalizedInterface)) {
|
|
|
|
|
normalizedInterface = `\\\\.\\${normalizedInterface.toUpperCase()}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.executeForInstance(instance, normalizedInterface);
|
|
|
|
|
}
|
|
|
|
|
console.log(`[PrinterManager] Abandoned call printed to: ${cfg.name}`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const results = await Promise.allSettled(jobs);
|
|
|
|
|
const successCount = results.filter(r => r.status === 'fulfilled').length;
|
|
|
|
|
const failCount = results.length - successCount;
|
|
|
|
|
console.log(`[PrinterManager] Abandoned call #${callId}: ${successCount} succeeded, ${failCount} failed`);
|
|
|
|
|
|
|
|
|
|
return { success: successCount > 0, successCount };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async writeAbandonedCallPdfToPath(filePath, call, cfg) {
|
|
|
|
|
const doc = new PDFDocument({ size: [226, 800], margin: 10 });
|
|
|
|
|
const pageWidth = 206; // 226 - 2*10 margin
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const stream = fs.createWriteStream(filePath);
|
|
|
|
|
doc.pipe(stream);
|
|
|
|
|
|
|
|
|
|
const priority = call.callback_priority || 'low';
|
|
|
|
|
const score = Number(call.callback_score) || 0;
|
|
|
|
|
|
|
|
|
|
// Top rule
|
|
|
|
|
doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke();
|
|
|
|
|
doc.moveDown(0.4);
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
|
doc.fontSize(16).font('Helvetica-Bold').text('ABANDONED CALL', { align: 'center' });
|
|
|
|
|
doc.moveDown(0.2);
|
|
|
|
|
|
|
|
|
|
// Priority badge - draw a filled rectangle behind text for critical/high
|
|
|
|
|
if (priority === 'critical' || priority === 'high') {
|
|
|
|
|
const badgeText = priority === 'critical' ? 'CRITICAL PRIORITY' : 'HIGH PRIORITY';
|
|
|
|
|
const badgeWidth = doc.widthOfString(badgeText, { fontSize: 11 }) + 16;
|
|
|
|
|
const badgeX = (226 - badgeWidth) / 2;
|
|
|
|
|
const badgeY = doc.y;
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.rect(badgeX, badgeY, badgeWidth, 16).fill('#000');
|
|
|
|
|
doc.fontSize(11).font('Helvetica-Bold').fillColor('#fff')
|
|
|
|
|
.text(badgeText, 10, badgeY + 3, { align: 'center', width: pageWidth });
|
|
|
|
|
doc.fillColor('#000').restore();
|
|
|
|
|
doc.y = badgeY + 20;
|
|
|
|
|
} else {
|
|
|
|
|
const label = priority === 'medium' ? 'MEDIUM PRIORITY' : `Priority: ${priority.toUpperCase()}`;
|
|
|
|
|
doc.fontSize(10).font('Helvetica-Bold').text(label, { align: 'center' });
|
|
|
|
|
}
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// Divider
|
|
|
|
|
doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// Timestamp
|
|
|
|
|
const callTime = call.call_started_at ? new Date(Number(call.call_started_at) * 1000) : new Date();
|
|
|
|
|
const timeStr = callTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
|
|
|
|
const dateStr = callTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
|
|
|
|
doc.fontSize(9).font('Helvetica').text(`${dateStr} ${timeStr}`, { align: 'center' });
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// Caller section header
|
|
|
|
|
doc.fontSize(10).font('Helvetica-Bold').text('CALLER', { align: 'center' });
|
|
|
|
|
doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// Phone number prominent
|
|
|
|
|
doc.fontSize(12).font('Helvetica-Bold').text(`Phone: ${call.caller_phone_normalized || call.caller_phone || 'Unknown'}`, { align: 'left' });
|
|
|
|
|
doc.font('Helvetica');
|
|
|
|
|
if (call.caller_name) {
|
|
|
|
|
doc.fontSize(10).text(`Name: ${call.caller_name}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (call.is_known_customer) {
|
|
|
|
|
doc.moveDown(0.15);
|
|
|
|
|
const custText = `RETURNING CUSTOMER ${call.previous_order_count || 0} previous orders`;
|
|
|
|
|
const custWidth = doc.widthOfString(custText, { fontSize: 8 }) + 10;
|
|
|
|
|
const custX = 10;
|
|
|
|
|
const custY = doc.y;
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.rect(custX, custY, Math.min(custWidth, pageWidth), 13).fill('#000');
|
|
|
|
|
doc.fontSize(8).font('Helvetica-Bold').fillColor('#fff')
|
|
|
|
|
.text(custText, custX + 4, custY + 3, { width: pageWidth - 8 });
|
|
|
|
|
doc.fillColor('#000').restore();
|
|
|
|
|
doc.y = custY + 16;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
doc.fontSize(9).font('Helvetica').text(`Callback Score: ${score}/100`);
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// What happened section
|
|
|
|
|
doc.fontSize(10).font('Helvetica-Bold').text('WHAT HAPPENED', { align: 'center' });
|
|
|
|
|
doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
const stageMessages = {
|
|
|
|
|
ring_only: 'Rang but hung up before AI could answer.',
|
|
|
|
|
greeting_hangup: 'Heard the greeting then disconnected.',
|
|
|
|
|
silent_post_greeting: 'Stayed on without speaking.',
|
|
|
|
|
minimal_speech: 'Spoke briefly then disconnected.',
|
|
|
|
|
pre_intent: 'Began speaking but intent was unclear.',
|
|
|
|
|
intent_identified: 'Expressed interest in ordering but disconnected.',
|
|
|
|
|
partial_order: 'Started placing an order then disconnected.',
|
|
|
|
|
partial_appointment: 'Was booking an appointment then disconnected.',
|
|
|
|
|
pre_confirmation: 'Order was nearly complete before disconnecting.',
|
|
|
|
|
system_failure: 'A system error caused the disconnection.'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
doc.fontSize(9).font('Helvetica').text(stageMessages[call.abandonment_stage] || 'Call ended before completion.');
|
|
|
|
|
const dur = Number(call.duration_seconds || 0);
|
|
|
|
|
const durLabel = dur >= 60 ? `${Math.floor(dur / 60)}m ${dur % 60}s` : `${dur}s`;
|
|
|
|
|
doc.text(`Call duration: ${durLabel}`);
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
|
|
|
|
|
// Items mentioned
|
|
|
|
|
const items = call.items || [];
|
|
|
|
|
let parsedSnapshot = null;
|
|
|
|
|
if (items.length === 0 && call.partial_order_snapshot) {
|
|
|
|
|
try {
|
|
|
|
|
parsedSnapshot = typeof call.partial_order_snapshot === 'string'
|
|
|
|
|
? JSON.parse(call.partial_order_snapshot)
|
|
|
|
|
: call.partial_order_snapshot;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
const displayItems = items.length > 0
|
|
|
|
|
? items
|
|
|
|
|
: (parsedSnapshot && parsedSnapshot.orderData && parsedSnapshot.orderData.items) || [];
|
|
|
|
|
|
|
|
|
|
if (displayItems.length > 0) {
|
|
|
|
|
doc.fontSize(10).font('Helvetica-Bold').text('ITEMS MENTIONED', { align: 'center' });
|
|
|
|
|
doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
doc.fontSize(9).font('Helvetica');
|
|
|
|
|
for (const item of displayItems) {
|
|
|
|
|
const itemName = item.item_name || item.itemName || item.name || 'Unknown';
|
|
|
|
|
const qty = item.quantity || item.qty || 1;
|
|
|
|
|
const price = item.unit_price || item.price || null;
|
|
|
|
|
const line = price ? `${qty}x ${itemName} $${Number(price).toFixed(2)}` : `${qty}x ${itemName}`;
|
|
|
|
|
doc.text(line);
|
|
|
|
|
}
|
|
|
|
|
if (call.partial_order_value || call.estimated_order_value) {
|
|
|
|
|
const est = Number(call.partial_order_value || call.estimated_order_value);
|
|
|
|
|
if (est > 0) {
|
|
|
|
|
doc.moveDown(0.15);
|
|
|
|
|
doc.font('Helvetica-Bold').text(`Est. Value: $${est.toFixed(2)}`, { align: 'right' });
|
|
|
|
|
doc.font('Helvetica');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Callback script
|
|
|
|
|
if (call.llm_callback_script) {
|
|
|
|
|
doc.fontSize(10).font('Helvetica-Bold').text('SUGGESTED CALLBACK SCRIPT', { align: 'center' });
|
|
|
|
|
doc.moveTo(10, doc.y + 2).lineTo(216, doc.y + 2).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
doc.fontSize(9).font('Helvetica').text(call.llm_callback_script, { width: pageWidth });
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Action section
|
|
|
|
|
if (score >= 60) {
|
|
|
|
|
doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke();
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
const actionText = 'CALLBACK RECOMMENDED';
|
|
|
|
|
const actionWidth = doc.widthOfString(actionText, { fontSize: 12 }) + 20;
|
|
|
|
|
const actionX = (226 - actionWidth) / 2;
|
|
|
|
|
const actionY = doc.y;
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.rect(actionX, actionY, actionWidth, 18).fill('#000');
|
|
|
|
|
doc.fontSize(12).font('Helvetica-Bold').fillColor('#fff')
|
|
|
|
|
.text(actionText, 10, actionY + 4, { align: 'center', width: pageWidth });
|
|
|
|
|
doc.fillColor('#000').restore();
|
|
|
|
|
doc.y = actionY + 22;
|
|
|
|
|
doc.moveDown(0.2);
|
|
|
|
|
doc.fontSize(9).font('Helvetica').fillColor('#000');
|
|
|
|
|
doc.text('[ ] Called back [ ] No answer [ ] Skipped');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Footer
|
|
|
|
|
doc.moveDown(0.3);
|
|
|
|
|
doc.moveTo(10, doc.y).lineTo(216, doc.y).stroke();
|
|
|
|
|
doc.moveDown(0.2);
|
|
|
|
|
doc.fontSize(8).font('Helvetica').text(`Ref #${call.id || ''}`, { align: 'center' });
|
|
|
|
|
doc.text('Powered by Think Link AI', { align: 'center' });
|
|
|
|
|
|
|
|
|
|
doc.end();
|
|
|
|
|
stream.on('finish', resolve);
|
|
|
|
|
stream.on('error', reject);
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-23 19:02:56 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = new PrinterManager();
|
|
|
|
|
|