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