// 网口打印机管理(80mm 终极美化版 · 无多余间距最终版) import net from 'net' import iconv from 'iconv-lite' // ======================== 全局工具函数 ======================== const getPrintWidth = (str) => { let width = 0 const s = String(str || '') for (let i = 0; i < s.length; i++) { const char = s.charAt(i) if (/[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char)) { width += 2 } else { width += 1 } } return width } const padLeftAlign = (str, targetWidth) => { const s = String(str || '') const currentWidth = getPrintWidth(s) const spaceNum = Math.max(0, targetWidth - currentWidth) return s + ' '.repeat(spaceNum) } const padRightAlign = (str, targetWidth) => { const s = String(str || '') const currentWidth = getPrintWidth(s) const spaceNum = Math.max(0, targetWidth - currentWidth) return ' '.repeat(spaceNum) + s } const truncateByPrintWidth = (str, maxWidth) => { const s = String(str || '') let width = 0 let result = '' for (const char of s) { const charWidth = /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char) ? 2 : 1 if (width + charWidth > maxWidth) break result += char width += charWidth } return result } const centerAlign = (str, lineWidth = 48) => { const s = String(str || '').trim(); // 去除首尾空格,避免干扰 const currentWidth = getPrintWidth(s); if (currentWidth >= lineWidth) return s; // 内容超宽时直接返回,不居中 const totalPad = lineWidth - currentWidth; const leftPad = Math.floor(totalPad / 2); const rightPad = totalPad - leftPad; // 避免奇数宽度时总长度超行宽 // 用空格填充左右,保证总宽度严格等于 lineWidth return ' '.repeat(leftPad) + s + ' '.repeat(rightPad); }; const createDivider = (lineWidth = 48, type = 'full') => { const fullChar = '═' const thinChar = '─' const char = type === 'full' ? fullChar : thinChar return char.repeat(Math.ceil(lineWidth / 2)).substring(0, lineWidth) } const setupPrinter = (socket, options = {}) => { socket.write(Buffer.from([0x1B, 0x40])) if (options.fontSize) { socket.write(Buffer.from([0x1B, 0x21, options.fontSize])) } if (options.bold) { socket.write(Buffer.from([0x1B, 0x45, options.bold ? 0x01 : 0x00])) } } // 用餐模式 堂食 dine-in 外带 take-out 外卖 take-away const dineModeFilter = t => { if (t === 'dine-in') return '堂食' if (t === 'take-out') return '外带' if (t === 'take-away') return '外卖' return t } // 支付类型 主扫 main_scan // 被扫 back_scan // 微信小程序 wechat_mini // 支付宝小程序 alipay_mini // 会员支付 vip_pay // 现金支付 cash_pay // 挂账支付 credit_pay const payTypeFilter = t => { if (t === 'main_scan') return '主扫' if (t === 'back_scan') return '被扫' if (t === 'wechat_mini') return '微信小程序' if (t === 'alipay_mini') return '支付宝小程序' if (t === 'vip_pay') return '余额支付' if (t === 'cash_pay') return '现金支付' if (t === 'credit_pay') return '挂账支付' return t } // ======================== 打印结算小票 ======================== export function printReceipt(printerIp, order) { // console.log('Printing receipt...', JSON.stringify(order)); return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (amount) => Number(amount || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(彻底修复居中问题!) ======================== const title1 = order.shop_name || ''; let title2 = `结算单 #${order.orderInfo.orderNum}` if (order.isBefore) { title2 = `${order.isBefore ? '预' : ''}结算单 #${order.orderInfo.orderNum}`; } if (order.isGuest) { title2 = `客看单 #${order.orderInfo.orderNum}`; } // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left // ======================== 订单信息(修复显示+换行问题) ======================== const tableLine = padLeftAlign('桌台号:', 8) + padLeftAlign(order.orderInfo.tableName || '无', 14) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(tableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let orderInfo = '' // 修复:打印机-用餐模式 + 就餐人数 展示逻辑 const dineModeText = dineModeFilter(order.orderInfo.dineMode) // 1. 拼接打印机+用餐模式文本,空值兜底 const printerDineText = `${order.printerName || '未知打印机'}-${dineModeText || '未知模式'}` // 2. 截断超长文本(左侧分配20宽度,保证不超限) const truncatedPrinterDine = truncateByPrintWidth(printerDineText, 20) // 3. 左侧左对齐填充20宽度(保证占满分配空间,不浪费空白) const leftPart = padLeftAlign(truncatedPrinterDine, 20) // 4. 右侧就餐人数:空值兜底为“无”,右对齐填充28宽度(20+28=48,刚好行宽) const seatNumText = order.orderInfo.seatNum || '0' const rightPart = padRightAlign(`${seatNumText}人`, 28) // 加标签更清晰,也可只显示数字 orderInfo += leftPart + rightPart + '\n' if (!order.isBefore && !order.isGuest) { orderInfo += padLeftAlign('结账时间:', 10) + padLeftAlign(order.orderInfo.paidTime || '-', 38) + '\n' } orderInfo += createDivider(lineWidth, 'thin') + '\n' // ======================== 商品表格 ======================== orderInfo += padLeftAlign('品名', 18) orderInfo += padLeftAlign('单价', 8) orderInfo += padLeftAlign('数量', 6) orderInfo += padRightAlign('小计', 16) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' // 处理商品列表(套餐主项显示金额,子项不显示金额 + 规格换行) order.carts.forEach(item => { if (item.number > 0) { // 套餐商品逻辑 if (item.proGroupInfo) { // 套餐主项:正常显示 名称、单价、数量、小计 const packageName = truncateByPrintWidth(item.name || '未知套餐', 18) orderInfo += padLeftAlign(packageName, 18) orderInfo += padLeftAlign(formatAmount(item.salePrice), 8) orderInfo += padLeftAlign(item.number || 1, 6) orderInfo += padRightAlign(formatAmount(item.totalAmount), 16) + '\n' // 套餐子项:只显示名称+规格,单价、小计全部为空 const proGroupInfo = item.proGroupInfo || [] proGroupInfo.forEach(subItem => { const subName = truncateByPrintWidth(`>${subItem.proName || '未知商品'}`, 18) orderInfo += padLeftAlign(subName, 18) orderInfo += padLeftAlign('', 8) // 子项单价空 orderInfo += padLeftAlign(subItem.number || 1, 6) orderInfo += padRightAlign('', 16) + '\n' // 子项小计空 // 子项规格换行显示 if (subItem.skuName) { const skuText = truncateByPrintWidth(` 规格:${subItem.skuName}`, 18) orderInfo += padLeftAlign(skuText, 18) orderInfo += padLeftAlign('', 8) orderInfo += padLeftAlign('', 6) orderInfo += padRightAlign('', 16) + '\n' } }) } else { // 普通商品:正常显示 + 规格换行 let productName = truncateByPrintWidth(item.name || '未知商品', 18) orderInfo += padLeftAlign(productName, 18) orderInfo += padLeftAlign(item.salePrice ? formatAmount(item.salePrice) : '', 8) orderInfo += padLeftAlign(item.number || 1, 6) orderInfo += padRightAlign(formatAmount(item.totalAmount), 16) + '\n' // 规格单独换行 if (item.skuName) { const skuLine = truncateByPrintWidth(` 规格:${item.skuName}`, 18) orderInfo += padLeftAlign(skuLine, 18) orderInfo += padLeftAlign('', 8) orderInfo += padLeftAlign('', 6) orderInfo += padRightAlign('', 16) + '\n' } } } }) orderInfo += createDivider(lineWidth, 'thin') + '\n' orderInfo += padLeftAlign('原价', 10) + padRightAlign(formatAmount(order.originAmount), 38) + '\n' orderInfo += padLeftAlign('优惠金额', 10) + padRightAlign(`-${formatAmount(order.discountAllAmount)}`, 38) + '\n' orderInfo += padLeftAlign('备注:', 6) + padLeftAlign(order.remark || '无', 38) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(orderInfo, 'gbk')) // ======================== 付款信息 ======================== if (order.isBefore) { const payableLine = padLeftAlign('应付', 10) + padRightAlign(formatAmount(order.originAmount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(payableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); } if (!order.isBefore && !order.isGuest) { const payableLine = padLeftAlign('应付', 10) + padRightAlign(formatAmount(order.orderInfo.payAmount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(payableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); const paidLine = padLeftAlign('已付', 10) + padRightAlign(formatAmount(order.orderInfo.payAmount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); socket.write(Buffer.from([0x1B, 0x45, 0x01])); socket.write(iconv.encode(paidLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); socket.write(Buffer.from([0x1B, 0x45, 0x00])); socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); } // ======================== 底部 ======================== let bottom = '' if (!order.isBefore && !order.isGuest) { bottom += padLeftAlign('支付方式:', 8) + padLeftAlign(payTypeFilter(order.orderInfo.payType) || '无', 38) + '\n' } bottom += padLeftAlign('操作员:', 8) + padLeftAlign(order.loginAccount || '无', 38) + '\n' bottom += padLeftAlign('打印时间:', 10) + padLeftAlign(new Date().toLocaleString(), 38) + '\n' bottom += padLeftAlign('订单号:', 8) + padLeftAlign(order.orderInfo.orderNo || '-', 38) + '\n' bottom += createDivider(lineWidth, 'full') + '\n' bottom += '\n\n' bottom += '\n\n' socket.write(iconv.encode(bottom, 'gbk')) socket.write(Buffer.from([0x1D, 0x56, 0x00])) setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error(err) socket.destroy() resolve('error') } }) socket.on('error', () => resolve('error')) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) } // ======================== 退款小票 ======================== export function printRefund(printerIp, order) { console.log(JSON.stringify(order)); return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (amount) => Number(amount || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(彻底修复居中问题!) ======================== const title1 = order.shop_name || ''; let title2 = '退款单'; // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left // ======================== 订单信息(修复显示+换行问题) ======================== const tableLine = padLeftAlign('桌台号:', 8) + padLeftAlign(order.orderInfo.tableName || '无', 14) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(tableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let orderInfo = '' // 修复:打印机-用餐模式 + 就餐人数 展示逻辑 const dineModeText = dineModeFilter(order.orderInfo.dineMode) // 1. 拼接打印机+用餐模式文本,空值兜底 const printerDineText = `${order.printerName || '未知打印机'}-${dineModeText || '未知模式'}` // 2. 截断超长文本(左侧分配20宽度,保证不超限) const truncatedPrinterDine = truncateByPrintWidth(printerDineText, 20) // 3. 左侧左对齐填充20宽度(保证占满分配空间,不浪费空白) const leftPart = padLeftAlign(truncatedPrinterDine, 20) // 4. 右侧就餐人数:空值兜底为“无”,右对齐填充28宽度(20+28=48,刚好行宽) const seatNumText = order.orderInfo.seatNum || '0' const rightPart = padRightAlign(`${seatNumText}人`, 28) // 加标签更清晰,也可只显示数字 if (order.carts.length > 0) { orderInfo += leftPart + rightPart + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' // ======================== 商品表格 ======================== orderInfo += padLeftAlign('品名', 18) orderInfo += padLeftAlign('单价', 8) orderInfo += padLeftAlign('数量', 6) orderInfo += padRightAlign('小计', 16) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' order.carts.forEach(item => { const name = truncateByPrintWidth(item.name || '未知商品', 18) const price = formatAmount(item.salePrice) const num = item.number || 1 const total = formatAmount(item.totalAmount) orderInfo += padLeftAlign(name, 18) orderInfo += padLeftAlign(price, 8) orderInfo += padLeftAlign(num, 6) orderInfo += padRightAlign(total, 16) + '\n' }) orderInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(orderInfo, 'gbk')) } // ======================== 付款信息 ======================== let payableLine = '' payableLine += padLeftAlign('退款总计', 10) + padRightAlign(formatAmount(order.amount), 51) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(payableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let refundInfo = '' refundInfo += padLeftAlign('退款方式:', 10) + padLeftAlign(order.refundMethod || '无', 38) + '\n' refundInfo += padLeftAlign('退款原因:', 10) + padLeftAlign(order.remark || '无', 38) + '\n' socket.write(iconv.encode(refundInfo, 'gbk')); socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); // ======================== 底部 ======================== let bottom = '' bottom += padLeftAlign('操作员:', 8) + padLeftAlign(order.loginAccount || '无', 38) + '\n' bottom += padLeftAlign('打印时间:', 10) + padLeftAlign(new Date().toLocaleString(), 38) + '\n' bottom += padLeftAlign('原订单号:', 10) + padLeftAlign(order.orderInfo.orderNo || '-', 38) + '\n' bottom += createDivider(lineWidth, 'full') + '\n' bottom += '\n\n' bottom += '\n\n' socket.write(iconv.encode(bottom, 'gbk')) socket.write(Buffer.from([0x1D, 0x56, 0x00])) setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error(err) socket.destroy() resolve('error') } }) socket.on('error', () => resolve('error')) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) } // ======================== 打印退菜单 ======================== export function printRefundDish(printerIp, order) { console.log(JSON.stringify(order)); return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (amount) => Number(amount || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(彻底修复居中问题!) ======================== const title1 = order.shop_name || ''; let title2 = '退菜单' // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left // ======================== 订单信息(修复显示+换行问题) ======================== const tableLine = padLeftAlign('桌台号:', 8) + padLeftAlign(order.orderInfo.tableName || '无', 14) + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(tableLine, 'gbk')); socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 let orderInfo = '' // 修复:打印机-用餐模式 + 就餐人数 展示逻辑 const dineModeText = dineModeFilter(order.orderInfo.dineMode) // 1. 拼接打印机+用餐模式文本,空值兜底 const printerDineText = `${order.printerName || '未知打印机'}-${dineModeText || '未知模式'}` // 2. 截断超长文本(左侧分配20宽度,保证不超限) const truncatedPrinterDine = truncateByPrintWidth(printerDineText, 20) // 3. 左侧左对齐填充20宽度(保证占满分配空间,不浪费空白) const leftPart = padLeftAlign(truncatedPrinterDine, 20) // 4. 右侧就餐人数:空值兜底为“无”,右对齐填充28宽度(20+28=48,刚好行宽) const seatNumText = order.orderInfo.seatNum || '0' const rightPart = padRightAlign(`${seatNumText}人`, 28) // 加标签更清晰,也可只显示数字 if (order.carts.length > 0) { orderInfo += leftPart + rightPart + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' // ======================== 商品表格 ======================== orderInfo += padLeftAlign('品名', 18) orderInfo += padLeftAlign('单价', 8) orderInfo += padLeftAlign('数量', 6) orderInfo += padRightAlign('小计', 16) + '\n' orderInfo += createDivider(lineWidth, 'thin') + '\n' order.carts.forEach(item => { const name = truncateByPrintWidth(item.name || '未知商品', 18) const price = formatAmount(item.salePrice) const num = item.number || 1 const total = formatAmount(item.totalAmount) orderInfo += padLeftAlign(name, 18) orderInfo += padLeftAlign(price, 8) orderInfo += padLeftAlign(num, 6) orderInfo += padRightAlign(total, 16) + '\n' }) orderInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(orderInfo, 'gbk')) } // ======================== 付款信息 ======================== // if (order.isGuest) { // let payableLine = '' // payableLine += padLeftAlign('退款总计', 10) + padRightAlign(formatAmount(order.amount), 51) + '\n' // socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 放大字号 // socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 // socket.write(iconv.encode(payableLine, 'gbk')); // socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 // socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 // } // let refundInfo = '' // refundInfo += padLeftAlign('退款方式:', 10) + padLeftAlign(order.refundMethod || '无', 38) + '\n' // refundInfo += padLeftAlign('退款原因:', 10) + padLeftAlign(order.remark || '无', 38) + '\n' // socket.write(iconv.encode(refundInfo, 'gbk')); // socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); // ======================== 底部 ======================== let bottom = '' bottom += padLeftAlign('操作员:', 8) + padLeftAlign(order.loginAccount || '无', 38) + '\n' bottom += padLeftAlign('打印时间:', 10) + padLeftAlign(new Date().toLocaleString(), 38) + '\n' bottom += padLeftAlign('原订单号:', 10) + padLeftAlign(order.orderInfo.orderNo || '-', 38) + '\n' bottom += createDivider(lineWidth, 'full') + '\n' bottom += '\n\n' bottom += '\n\n' socket.write(iconv.encode(bottom, 'gbk')) socket.write(Buffer.from([0x1D, 0x56, 0x00])) setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error(err) socket.destroy() resolve('error') } }) socket.on('error', () => resolve('error')) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) } // ======================== 打印交班小票(完整版 · 补齐分类/商品数据) ======================== export function printHandoverReceipt(printerIp, data) { return new Promise((resolve) => { const socket = new net.Socket() const lineWidth = 48 socket.setTimeout(8000) const formatAmount = (v) => Number(v || 0).toFixed(2) socket.connect(9100, printerIp, () => { try { setupPrinter(socket) // ======================== 标题区域(统一结算小票的居中修复逻辑) ======================== const title1 = data.shopName || '店铺'; const title2 = '交班小票'; // 核心修复:先写入空行清空打印机缓冲区,避免残留字符干扰居中起始位置 socket.write(iconv.encode('\n', 'gbk')); // 步骤1:重置打印机格式(避免残留格式影响) socket.write(Buffer.from([0x1B, 0x40])); // 步骤2:居中对齐标题 socket.write(Buffer.from([0x1B, 0x61, 0x01])); // 1 = center // 步骤3:设置标题1的格式(加粗+大号字体) socket.write(Buffer.from([0x1B, 0x21, 0x11])); // 字号放大(0x11 是常用放大值) socket.write(Buffer.from([0x1B, 0x45, 0x01])); // 加粗 socket.write(iconv.encode(title1 + '\n', 'gbk')); // 步骤4:恢复格式,打印标题2(常规字体) socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 恢复默认字号 socket.write(Buffer.from([0x1B, 0x45, 0x00])); // 取消加粗 socket.write(iconv.encode(title2 + '\n\n', 'gbk')); socket.write(Buffer.from([0x1B, 0x61, 0x00])); // 0 = left socket.write(iconv.encode(createDivider(lineWidth, 'thin') + '\n', 'gbk')); // ======================== 基础信息区域(拆分当班/交班时间) ======================== let basicInfo = '' // 优化:拆分当班时间和交班时间,空值兜底 basicInfo += padLeftAlign('交班时间:', 10) + padLeftAlign(data.handoverTime || '-', 36) + '\n' basicInfo += padLeftAlign('交班人:', 8) + padLeftAlign(data.staffName || '-', 36) + '\n' basicInfo += padLeftAlign('交班:', 6) + padLeftAlign(`${data.loginTime} - ${data.handoverTime}` || '-', 36) + '\n\n' // basicInfo += createDivider(lineWidth, 'thin') + '\n' // 收入明细 - 补充会员支付/充值字段 basicInfo += padLeftAlign('当班营业总额:', 10) + padLeftAlign(formatAmount(data.turnover), 36) + '\n' basicInfo += padLeftAlign('实际收款的支付方式', 10) + '\n' basicInfo += padLeftAlign('现金', 10) + padRightAlign(formatAmount(data.cash), 36) + '\n' basicInfo += padLeftAlign('微信', 10) + padRightAlign(formatAmount(data.wechat), 36) + '\n' basicInfo += padLeftAlign('支付宝', 10) + padRightAlign(formatAmount(data.alipay), 36) + '\n' basicInfo += padLeftAlign('二维码收款', 10) + padRightAlign(formatAmount(data.selfScan), 36) + '\n' basicInfo += padLeftAlign('扫码收款', 10) + padRightAlign(formatAmount(data.barScan), 36) + '\n' basicInfo += padLeftAlign('充值', 10) + padRightAlign(formatAmount(data.recharge), 36) + '\n\n' basicInfo += padLeftAlign('非实际收款的支付方式', 10) + '\n' basicInfo += padLeftAlign('挂账', 10) + padRightAlign(formatAmount(data.owed), 36) + '\n' basicInfo += padLeftAlign('余额', 10) + padRightAlign(formatAmount(data.balance), 36) + '\n' basicInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(Buffer.from([0x1B, 0x21, 0x00])); // 正常字号 socket.write(iconv.encode(basicInfo, 'gbk')) let refundInfo = '' refundInfo += padLeftAlign('退款/退菜', 10) + '\n' refundInfo += padLeftAlign('退款金额', 10) + padRightAlign(formatAmount(data.refundAmount), 36) + '\n' refundInfo += padLeftAlign('退菜数量', 10) + padRightAlign(formatAmount(data.returnDishCount), 36) + '\n' refundInfo += createDivider(lineWidth, 'thin') + '\n' socket.write(iconv.encode(refundInfo, 'gbk')) let orderInfo = '' const orderLabel = '订单(数量/订单总额)' const orderValue = `${data.orderCount}/${formatAmount(data.orderTurnover)}` const orderLabelWidth = lineWidth - getPrintWidth(orderValue) orderInfo += padLeftAlign(orderLabel, orderLabelWidth) + orderValue + '\n\n' socket.write(iconv.encode(orderInfo, 'gbk')) // ======================== 分类数据区域 ======================== let categoryInfo = '' if (data.categoryList && data.categoryList.length) { categoryInfo += centerAlign('销售数据', lineWidth) + '\n' categoryInfo += createDivider(lineWidth, 'thin') + '\n' // 分类表头 categoryInfo += padLeftAlign('商品分类', 24) + padLeftAlign('数量', 12) + padRightAlign('总计', 12) + '\n' // categoryInfo += createDivider(lineWidth, 'thin') + '\n' // 分类数据行 data.categoryList.forEach(item => { const catName = truncateByPrintWidth(item.categoryName || '未知分类', 24) const num = item.num || 0 const amount = formatAmount(item.amount) categoryInfo += padLeftAlign(catName, 24) + padLeftAlign(num, 12) + padRightAlign(amount, 12) + '\n' }) categoryInfo += createDivider(lineWidth, 'thin') + '\n' } socket.write(iconv.encode(categoryInfo, 'gbk')) // ======================== 商品数据区域 ======================== // let productInfo = '' // if (data.detailList && data.detailList.length) { // productInfo += centerAlign('商品数据', lineWidth) + '\n' // productInfo += createDivider(lineWidth, 'thin') + '\n' // // 商品表头 // productInfo += padLeftAlign('商品', 36) + padRightAlign('数量', 12) + '\n' // // productInfo += createDivider(lineWidth, 'thin') + '\n' // // 商品数据行 // data.detailList.forEach(item => { // const prodName = truncateByPrintWidth(item.productName || '未知商品', 36) // const num = item.num || 0 // productInfo += padLeftAlign(prodName, 36) + padRightAlign(num, 12) + '\n' // }) // productInfo += createDivider(lineWidth, 'thin') + '\n' // } // socket.write(iconv.encode(productInfo, 'gbk')) // ======================== 底部区域(统一结算小票风格) ======================== let bottomInfo = '' bottomInfo += padLeftAlign('打印时间:', 12) + padRightAlign(data.printTime || new Date().toLocaleString(), 36) + '\n' bottomInfo += createDivider(lineWidth, 'full') + '\n' // 增加空行,避免打印内容紧贴纸边 bottomInfo += '\n\n' bottomInfo += '\n\n' socket.write(iconv.encode(bottomInfo, 'gbk')) // 切纸指令(统一结算小票的切纸逻辑) socket.write(Buffer.from([0x1D, 0x56, 0x00])) // 统一延迟关闭连接,保证数据完整发送 setTimeout(() => { socket.end() resolve('success') }, 400) } catch (err) { console.error('打印交班小票异常:', err) socket.destroy() resolve('error') } }) // 统一错误处理(对齐结算小票的错误处理逻辑) socket.on('error', (err) => { console.error('打印机连接错误:', err) resolve('error') }) socket.on('timeout', () => resolve('timeout')) socket.on('close', () => { }) }) }