diff --git a/db/20260920_add_hard_invoice_print_cmd.sql b/db/20260920_add_hard_invoice_print_cmd.sql new file mode 100644 index 00000000..8ccb0cb0 --- /dev/null +++ b/db/20260920_add_hard_invoice_print_cmd.sql @@ -0,0 +1,5 @@ +-- 打印任务补充指令流列:后端在生成凭证 PNG 的同时拼装 ESC/POS 指令(Base64), +-- 终端"打印机设置-打印方式"切换为指令打印时直接写入 USB,出纸快于图片栅格打印。 +-- 列可空且不回填:历史任务与旧后端无指令,终端指令模式下自动回退 pic 图片打印。 +ALTER TABLE `pjcloudx_hardware`.`hard_invoice` + ADD COLUMN `print_cmd` mediumtext DEFAULT NULL COMMENT '后端拼装的打印指令流Base64,终端指令打印直写USB' AFTER `pic`; diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql index e5934cc3..33bf3ba0 100644 --- a/db/800pjcloudx_hardware.sql +++ b/db/800pjcloudx_hardware.sql @@ -182,6 +182,7 @@ CREATE TABLE hard_invoice printed_at datetime DEFAULT NULL COMMENT '终端确认写入成功时间', reprint_reason varchar(500) DEFAULT NULL COMMENT '人工重打或再次核销原因', pic varchar(512) DEFAULT NULL COMMENT '后端生成的打印PNG地址', + print_cmd mediumtext DEFAULT NULL COMMENT '后端拼装的打印指令流Base64,终端指令打印直写USB', create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人', create_time datetime DEFAULT NULL COMMENT '创建时间', update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人', diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java index 7b15c8dc..77a25696 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardInvoiceEntity.java @@ -115,6 +115,13 @@ public class HardInvoiceEntity extends Model { @Schema(description="打印图片") private String pic; + /** + * 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节),供终端指令打印直写 USB。 + * 历史任务和图片生成失败时为空,终端指令模式下回退 pic 图片打印。 + */ + @Schema(description="打印指令流Base64") + private String printCmd; + /** * 创建人 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java new file mode 100644 index 00000000..528431b9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java @@ -0,0 +1,372 @@ +package com.bsd.pjcloud.hard.api.utils; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * 面向 80mm 热敏打印机的 ESC/POS 文本指令拼装器,实现 {@link ReceiptRenderer}。 + * + *

与 {@link ThermalReceiptImage} 保持同一套版式语义:相同的调用顺序在图片模式产出 + * PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过 + * 图片栅格化与传输,出纸速度显著快于图片打印。

+ * + *

排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS + * 汉字模式(FS & / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。 + * 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。

+ */ +public class EscPosReceiptCommand implements ReceiptRenderer { + + /** 80mm 纸标准 ANK 字符列数,与终端 EscGenerator 的 80mm 每行列数一致。 */ + public static final int CHARS_PER_LINE = 48; + + /** ESC/POS 左对齐参数值。 */ + private static final int ALIGN_LEFT = 0; + + /** ESC/POS 居中对齐参数值。 */ + private static final int ALIGN_CENTER = 1; + + /** ESC/POS 右对齐参数值。 */ + private static final int ALIGN_RIGHT = 2; + + /** GBK 字符集;绝大多数国产热敏打印机默认按 GBK 解析中文。 */ + private static final Charset GBK = Charset.forName("GBK"); + + /** 指令字节累积缓冲;toBase64 前不做任何 IO。 */ + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(2048); + + /** + * 创建指令拼装器并输出 ESC @ 初始化,确保终端写入前打印机残留样式被复位。 + */ + public EscPosReceiptCommand() { + writeRaw(0x1B, 0x40); + } + + /** + * 渲染居中加粗放大标题;超宽时自动降为单倍宽度保证一行放下。 + */ + @Override + public EscPosReceiptCommand title(String text) { + printCenteredScaled(text, true, 2); + return this; + } + + /** + * 渲染上下横线包围的居中加粗提示,近似图片模式的描边横幅。 + */ + @Override + public EscPosReceiptCommand banner(String text) { + printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1); + printCenteredScaled(text, true, 2); + printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1); + return this; + } + + /** + * 渲染居中三倍加粗短码;超宽时降为双倍再单倍。 + */ + @Override + public EscPosReceiptCommand verificationCode(String shortCode) { + printCenteredScaled(shortCode == null || shortCode.isBlank() ? "--" : shortCode, true, 3); + return this; + } + + /** + * 渲染键值行;值放不下整行时标签独占一行,值从下一行起按整行宽度折行。 + */ + @Override + public EscPosReceiptCommand keyValue(String label, String value) { + String safeLabel = (label == null ? "" : label) + ":"; + String safeValue = value == null || value.isBlank() ? "--" : value; + int valueCapacity = CHARS_PER_LINE - displayWidth(safeLabel); + if (displayWidth(safeValue) <= valueCapacity) { + printLine(safeLabel + safeValue, ALIGN_LEFT, false, 1); + return this; + } + printLine(safeLabel, ALIGN_LEFT, false, 1); + for (String line : wrapByWidth(safeValue, CHARS_PER_LINE)) { + printLine(line, ALIGN_LEFT, false, 1); + } + return this; + } + + /** + * 渲染左对齐加粗区块小标题。 + */ + @Override + public EscPosReceiptCommand sectionTitle(String text) { + printLine(text == null ? "" : text, ALIGN_LEFT, true, 1); + return this; + } + + /** + * 渲染合并批次中单笔订单摘要:标签与订单号同行加粗,日期餐段随行,订单间短线分隔。 + */ + @Override + public EscPosReceiptCommand voucherOrderSummary(int index, int total, String orderNo, + String orderDate, String mealName) { + int safeTotal = Math.max(1, total); + int safeIndex = Math.min(Math.max(1, index), safeTotal); + String tag = "订单 " + safeIndex + "/" + safeTotal; + String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo; + int orderCapacity = CHARS_PER_LINE - displayWidth(tag) - 2; + List orderLines = wrapByWidth(safeOrderNo, Math.max(8, orderCapacity)); + printTwoColumns(tag, orderLines.get(0), true); + for (int lineIndex = 1; lineIndex < orderLines.size(); lineIndex++) { + printLine(" ".repeat(displayWidth(tag) + 2) + orderLines.get(lineIndex), ALIGN_LEFT, true, 1); + } + String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate) + + " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName); + for (String line : wrapByWidth(summary, CHARS_PER_LINE)) { + printLine(line, ALIGN_LEFT, false, 1); + } + if (safeIndex < safeTotal) { + printLine("-".repeat(32), ALIGN_LEFT, false, 1); + } + return this; + } + + /** + * 渲染商品或套餐组成项:名称左、数量右,组成项缩进两列并加“-”前缀。 + */ + @Override + public EscPosReceiptCommand item(String name, String quantity, boolean child, boolean emphasized) { + String prefix = child ? " - " : ""; + String safeName = prefix + (name == null || name.isBlank() ? "未命名商品" : name); + String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity; + printNameTrailing(safeName, safeQuantity, 12, emphasized); + return this; + } + + /** + * 渲染发票商品行:名称左、数量加金额摘要右。 + */ + @Override + public EscPosReceiptCommand invoiceItem(String name, String quantity, String amount) { + String trailing = String.format("%s %s", quantity, amount); + printNameTrailing(name == null || name.isBlank() ? "未命名商品" : name, trailing, 20, false); + return this; + } + + /** + * 渲染兑换券商品卡片,数量为整数份数。 + */ + @Override + public EscPosReceiptCommand voucherProduct(String name, int quantity, String unitPrice, String subtotal, + boolean combo, List componentItems) { + return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems); + } + + /** + * 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。 + * + *

图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。

+ */ + @Override + public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal, + boolean combo, List componentItems) { + String typeLabel = combo ? "[组合商品]" : "[单品]"; + String safeName = name == null || name.isBlank() ? "未命名商品" : name; + String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity; + String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice) + + " " + safeQuantity; + String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal); + + int nameCapacity = CHARS_PER_LINE - displayWidth(typeLabel) - 1; + List nameLines = wrapByWidth(safeName, nameCapacity); + printLine(typeLabel + " " + nameLines.get(0), ALIGN_LEFT, true, 1); + for (int lineIndex = 1; lineIndex < nameLines.size(); lineIndex++) { + printLine(" ".repeat(displayWidth(typeLabel) + 1) + nameLines.get(lineIndex), ALIGN_LEFT, true, 1); + } + printTwoColumns(priceText, subtotalText, false); + + List safeComponents = componentItems == null ? List.of() : componentItems.stream() + .filter(item -> item != null && !item.isBlank()).toList(); + if (combo && !safeComponents.isEmpty()) { + printLine("组合内容", ALIGN_LEFT, true, 1); + for (String component : safeComponents) { + printLine(" - " + component, ALIGN_LEFT, false, 1); + } + } + printLine("", ALIGN_LEFT, false, 1); + return this; + } + + /** + * 渲染上下横线包围的商品合计;双倍宽度放不下时自动降为单倍。 + */ + @Override + public EscPosReceiptCommand voucherTotal(String totalAmount) { + String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount; + printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1); + String label = "商品合计"; + String amount = "¥" + safeAmount; + if (displayWidth(label) + displayWidth(amount) <= CHARS_PER_LINE / 2) { + printTwoColumnsScaled(label, amount, 2); + } else { + printTwoColumns(label, amount, true); + } + printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1); + return this; + } + + /** + * 渲染整行横线分隔。 + */ + @Override + public EscPosReceiptCommand divider() { + printLine("-".repeat(CHARS_PER_LINE), ALIGN_LEFT, false, 1); + return this; + } + + /** + * 输出 Base64 编码的完整指令流,供 MQTT 与打印待办接口下发给终端直写 USB。 + * + * @return Base64 指令字符串;调用方直接持久化或下发,不需要再编码 + */ + public String toBase64() { + return java.util.Base64.getEncoder().encodeToString(buffer.toByteArray()); + } + + /** + * 输出居中放大文本;放大倍数放不下时逐级降为 2 倍再 1 倍,保证单行完整展示。 + */ + private void printCenteredScaled(String text, boolean bold, int scale) { + String safeText = text == null ? "" : text; + int actualScale = scale; + while (actualScale > 1 && displayWidth(safeText) > CHARS_PER_LINE / actualScale) { + actualScale--; + } + printLine(safeText, ALIGN_CENTER, bold, actualScale); + } + + /** + * 输出名称左、摘要右的两栏明细;名称按剩余宽度折行,摘要始终在首行右对齐。 + * + * @param name 左侧名称文本 + * @param trailing 首行右对齐摘要 + * @param trailingWidth 右侧预留列数 + * @param bold 是否加粗 + */ + private void printNameTrailing(String name, String trailing, int trailingWidth, boolean bold) { + List lines = wrapByWidth(name, CHARS_PER_LINE - trailingWidth); + printTwoColumns(lines.get(0), trailing, bold); + for (int index = 1; index < lines.size(); index++) { + printLine(lines.get(index), ALIGN_LEFT, bold, 1); + } + } + + /** + * 输出单倍宽度两栏文本;左右间距不足 1 列时右侧文本自动折到下一行右对齐。 + */ + private void printTwoColumns(String left, String right, boolean bold) { + int gap = CHARS_PER_LINE - displayWidth(left) - displayWidth(right); + if (gap >= 1) { + printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, bold, 1); + return; + } + printLine(left, ALIGN_LEFT, bold, 1); + for (String line : wrapByWidth(right, CHARS_PER_LINE)) { + printLine(line, ALIGN_RIGHT, bold, 1); + } + } + + /** + * 输出双倍宽度两栏文本;倍宽后每行容量减半,放不下时右栏折行右对齐。 + */ + private void printTwoColumnsScaled(String left, String right, int scale) { + int capacity = CHARS_PER_LINE / scale; + int gap = capacity - displayWidth(left) - displayWidth(right); + if (gap >= 1) { + printLine(left + " ".repeat(gap) + right, ALIGN_LEFT, true, scale); + return; + } + printLine(left, ALIGN_LEFT, true, scale); + for (String line : wrapByWidth(right, capacity)) { + printLine(line, ALIGN_RIGHT, true, scale); + } + } + + /** + * 输出单行文本:对齐、加粗、放大、汉字模式开、GBK 编码、换行、汉字模式关。 + * + *

每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明, + * 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。

+ * + * @param text 行文本 + * @param align ESC a 对齐参数 0 左 1 中 2 右 + * @param bold 是否加粗 + * @param scale 放大倍数 1 单倍 2 双倍 3 三倍 + */ + private void printLine(String text, int align, boolean bold, int scale) { + writeRaw(0x1B, 0x61, align); + if (bold) { + writeRaw(0x1B, 0x45, 1); + } + if (scale > 1) { + // GS ! n:高 4 位为高度倍数、低 4 位为宽度倍数,宽度决定每行容量。 + writeRaw(0x1D, 0x21, (scale << 4) | scale); + } + writeRaw(0x1C, 0x26); + writeText(text); + writeRaw('\n'); + writeRaw(0x1C, 0x2E); + if (scale > 1) { + writeRaw(0x1D, 0x21, 0x01); + } + if (bold) { + writeRaw(0x1B, 0x45, 0); + } + } + + /** + * 追加文本的 GBK 字节;GBK 为 JDK 内置字符集,不会抛出编码异常。 + */ + private void writeText(String text) { + buffer.writeBytes(text.getBytes(GBK)); + } + + /** + * 追加原始指令字节。 + */ + private void writeRaw(int... bytes) { + for (int value : bytes) { + buffer.write(value); + } + } + + /** + * 计算文本的显示列数:中文字符按 2 列、其余按 1 列,与打印机汉字模式宽度一致。 + */ + static int displayWidth(String text) { + int width = 0; + for (int index = 0; index < text.length(); index++) { + width += text.charAt(index) > 0x7F ? 2 : 1; + } + return width; + } + + /** + * 按显示列数折行;单个超宽字符仍至少独占一行,与图片绘制器的折行语义一致。 + */ + static List wrapByWidth(String text, int maxWidth) { + List lines = new ArrayList<>(); + StringBuilder line = new StringBuilder(); + int lineWidth = 0; + String safeText = text == null ? "" : text; + for (int index = 0; index < safeText.length(); index++) { + char character = safeText.charAt(index); + int charWidth = character > 0x7F ? 2 : 1; + if (lineWidth > 0 && lineWidth + charWidth > maxWidth) { + lines.add(line.toString()); + line.setLength(0); + lineWidth = 0; + } + line.append(character); + lineWidth += charWidth; + } + if (line.length() > 0 || lines.isEmpty()) lines.add(line.toString()); + return lines; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java new file mode 100644 index 00000000..c453d658 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java @@ -0,0 +1,134 @@ +package com.bsd.pjcloud.hard.api.utils; + +import java.util.List; + +/** + * 打印凭证版式渲染器契约,发票与兑换券的数据拼装只依赖本接口。 + * + *

当前实现有两个:{@link ThermalReceiptImage} 渲染 576px PNG 图片供图片栅格打印; + * {@link EscPosReceiptCommand} 直接拼装 ESC/POS 文本指令供指令打印,出纸更快。 + * 业务层用同一份调用序列驱动两个渲染器,保证两种输出方式的凭证内容始终一致。

+ * + *

实现类的方法返回类型允许返回自身具体类型以保留链式调用(协变返回),拼接顺序 + * 由调用方保证,本接口不承载状态查询。

+ */ +public interface ReceiptRenderer { + + /** + * 渲染居中大号标题,例如“智慧食堂发票”。 + * + * @param text 标题文本 + * @return 当前渲染器 + */ + ReceiptRenderer title(String text); + + /** + * 渲染醒目的整行提示,例如“第 1 次打印 · 2 笔预约”。 + * + * @param text 提示文本 + * @return 当前渲染器 + */ + ReceiptRenderer banner(String text); + + /** + * 渲染醒目的核销或取件短码。 + * + * @param shortCode 短码文本;空值由实现决定占位展示 + * @return 当前渲染器 + */ + ReceiptRenderer verificationCode(String shortCode); + + /** + * 渲染一行“字段名:字段值”信息;值过长时由实现负责折行。 + * + * @param label 字段名 + * @param value 字段值;空白由实现决定占位展示 + * @return 当前渲染器 + */ + ReceiptRenderer keyValue(String label, String value); + + /** + * 渲染区块小标题,例如“用户信息”、“商品明细”。 + * + * @param text 标题文本 + * @return 当前渲染器 + */ + ReceiptRenderer sectionTitle(String text); + + /** + * 渲染多订单合并兑换券中的单笔订单摘要。 + * + * @param index 当前订单在合并批次中的一号起始序号 + * @param total 合并批次订单总数 + * @param orderNo 对外展示的完整订单号 + * @param orderDate 预约订餐日期 + * @param mealName 餐段名称 + * @return 当前渲染器 + */ + ReceiptRenderer voucherOrderSummary(int index, int total, String orderNo, + String orderDate, String mealName); + + /** + * 渲染商品或套餐组成项,数量在首行右对齐。 + * + * @param name 商品名称 + * @param quantity 数量文本 + * @param child 是否为套餐组成项 + * @param emphasized 是否加粗强调 + * @return 当前渲染器 + */ + ReceiptRenderer item(String name, String quantity, boolean child, boolean emphasized); + + /** + * 渲染带数量和金额摘要的发票商品行。 + * + * @param name 商品名称 + * @param quantity 数量或重量文本 + * @param amount 含货币符号的商品总价 + * @return 当前渲染器 + */ + ReceiptRenderer invoiceItem(String name, String quantity, String amount); + + /** + * 渲染兑换券商品卡片,数量为整数份数。 + * + * @param name 商品快照名称 + * @param quantity 剩余可履约数量 + * @param unitPrice 商品快照单价,单位元且不含货币符号 + * @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号 + * @param combo 是否为组合商品 + * @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空 + * @return 当前渲染器 + */ + ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal, + boolean combo, List componentItems); + + /** + * 渲染兑换券商品卡片,数量为已格式化文本(份数“x2”或称重“0.5/kg”)。 + * + * @param name 商品快照名称 + * @param quantity 数量展示文本 + * @param unitPrice 商品快照单价,单位元且不含货币符号 + * @param subtotal 商品小计,单位元且不含货币符号 + * @param combo 是否为组合商品 + * @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空 + * @return 当前渲染器 + */ + ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal, + boolean combo, List componentItems); + + /** + * 渲染商品合计,形成票据金额层级。 + * + * @param totalAmount 商品合计金额,单位元且不含货币符号 + * @return 当前渲染器 + */ + ReceiptRenderer voucherTotal(String totalAmount); + + /** + * 渲染一条横向分隔线。 + * + * @return 当前渲染器 + */ + ReceiptRenderer divider(); +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java index e1650f77..d8d52ca1 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImage.java @@ -17,8 +17,11 @@ import java.util.List; * 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。 * *

商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。

+ * + *

实现 {@link ReceiptRenderer} 版式契约,方法返回自身具体类型保留链式调用; + * 指令打印由同包的 {@link EscPosReceiptCommand} 以相同调用序列产出 ESC/POS 字节。

*/ -public class ThermalReceiptImage { +public class ThermalReceiptImage implements ReceiptRenderer { /** 80mm 打印头常见有效像素宽度。 */ public static final int PAPER_WIDTH_PIXELS = 576; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java index 42eea302..8830a914 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImpl.java @@ -39,6 +39,8 @@ import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity; import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity; import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.api.utils.EscPosReceiptCommand; +import com.bsd.pjcloud.hard.api.utils.ReceiptRenderer; import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage; import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo; import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; @@ -260,6 +262,17 @@ public class HardInvoiceServiceImpl extends ServiceImpl carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of); @@ -268,58 +281,61 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。

* - * @param image 当前发票图片绘制器 + * @param renderer 当前渲染器 * @param cars 订单商品快照 * @param metadata 按商品 ID 索引的套餐类型和组成项 * @param showComboItems 是否展示套餐组成内容 */ - private static void appendInvoiceProducts(ThermalReceiptImage image, List cars, + private static void appendInvoiceProducts(ReceiptRenderer renderer, List cars, Map metadata, boolean showComboItems) { - image.divider().sectionTitle("商品明细"); + renderer.divider().sectionTitle("商品明细"); boolean hasProduct = false; for (HardCarVo car : cars) { hasProduct = true; @@ -328,10 +344,10 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems = combo && showComboItems ? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of(); - image.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)), + renderer.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)), money(car.getTotalPrice()), combo, comboItems); } - if (!hasProduct) image.item("无商品明细", "x0", false, false); + if (!hasProduct) renderer.item("无商品明细", "x0", false, false); } /** @@ -736,21 +752,22 @@ public class HardInvoiceServiceImpl extends ServiceImpllambdaUpdate() .eq(HardInvoiceEntity::getId, task.getId()) .eq(HardInvoiceEntity::getStatus, HardPrintStatusEnum.PENDING_GENERATION.getType()) .eq(HardInvoiceEntity::getRetryVersion, task.getRetryVersion()) .set(HardInvoiceEntity::getStatus, HardPrintStatusEnum.GENERATE.getType()) - .set(HardInvoiceEntity::getPic, image) + .set(HardInvoiceEntity::getPic, receipt.pic()) + .set(HardInvoiceEntity::getPrintCmd, receipt.printCmd()) .set(HardInvoiceEntity::getFailureStage, null) .set(HardInvoiceEntity::getLastError, null)); return baseMapper.selectById(task.getId()); @@ -780,6 +797,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl result = mqttEventService.sendPrint(message); if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { throw new RuntimeException(result == null ? "MQTT服务无响应" @@ -797,36 +815,40 @@ public class HardInvoiceServiceImpl extends ServiceImpl身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按 * 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。

* * @param order 已完成核销的预约订单 * @param printNo 当前订单打印序号 - * @return 文件服务返回的兑换券 PNG 地址 + * @return 图片地址与 Base64 指令流 */ - private String generateVoucher(HardOrderEntity order, Integer printNo) { + private GeneratedReceipt renderVoucher(HardOrderEntity order, Integer printNo) { List voucherOrders = loadVoucherBatchOrders(order); MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在"); UserVO user = loadVoucherUser(order.getUserId()); - ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true).title("预约核销兑换券") + ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true); + EscPosReceiptCommand command = new EscPosReceiptCommand(); + ReceiptRenderer renderer = new CompositeRenderer(image, command); + renderer.title("预约核销兑换券") .banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size())) .keyValue("商户", merchant.getName()); - appendVoucherUser(image, user); + appendVoucherUser(renderer, user); boolean showComboItems = shouldShowVoucherComboItems(); List orderProducts = loadVoucherOrderProducts(voucherOrders, showComboItems); boolean mergeMultiOrderProducts = voucherOrders.size() > 1 && shouldMergeMultiOrderVoucherProducts(); - if (mergeMultiOrderProducts) appendMergedVoucherLayout(image, orderProducts); - else appendOrderGroupedVoucherLayout(image, orderProducts); + if (mergeMultiOrderProducts) appendMergedVoucherLayout(renderer, orderProducts); + else appendOrderGroupedVoucherLayout(renderer, orderProducts); boolean hasProduct = orderProducts.stream().anyMatch(item -> !item.products().isEmpty()); long totalAmount = orderProducts.stream().flatMap(item -> item.products().stream()) .mapToLong(VoucherProductLine::subtotal).sum(); - if (!hasProduct) image.item("无可履约商品", "x0", false, false); - image.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now())); + if (!hasProduct) renderer.item("无可履约商品", "x0", false, false); + renderer.voucherTotal(money(totalAmount)).keyValue("打印时间", formatDateTime(LocalDateTime.now())); try { - return uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes()); + return new GeneratedReceipt(uploadPng("voucher_" + order.getId() + "_" + printNo, image.toPngBytes()), + command.toBase64()); } catch (IOException exception) { throw new RuntimeException("生成兑换券图片失败", exception); } @@ -912,51 +934,51 @@ public class HardInvoiceServiceImpl extends ServiceImpl orderProducts) { for (VoucherOrderProducts group : orderProducts) { HardOrderEntity order = group.order(); - image.divider().sectionTitle("订单 " + displayOrderNo(order)) + renderer.divider().sectionTitle("订单 " + displayOrderNo(order)) .keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString()) .keyValue("餐段", resolveMealName(order.getHourType())) .sectionTitle("剩余可履约商品"); - appendVoucherProducts(image, group.products()); + appendVoucherProducts(renderer, group.products()); } } /** - * 绘制多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。 + * 渲染多订单商品合并版式,先集中展示订单识别信息,再展示一份聚合商品明细。 * - * @param image 当前兑换券图片绘制器 + * @param renderer 当前渲染器 * @param orderProducts 按订单分组的商品快照 */ - private static void appendMergedVoucherLayout(ThermalReceiptImage image, + private static void appendMergedVoucherLayout(ReceiptRenderer renderer, List orderProducts) { - image.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)"); + renderer.divider().sectionTitle("订单信息(" + orderProducts.size() + "笔)"); for (int index = 0; index < orderProducts.size(); index++) { HardOrderEntity order = orderProducts.get(index).order(); - image.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order), + renderer.voucherOrderSummary(index + 1, orderProducts.size(), displayOrderNo(order), order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString(), resolveMealName(order.getHourType())); } - image.divider().sectionTitle("合并商品明细"); - appendVoucherProducts(image, mergeVoucherProducts(orderProducts)); + renderer.divider().sectionTitle("合并商品明细"); + appendVoucherProducts(renderer, mergeVoucherProducts(orderProducts)); } /** - * 绘制一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。 + * 渲染一组结构化兑换券商品,套餐组成数量随商品合计数量同步计算。 * - * @param image 当前兑换券图片绘制器 - * @param products 待绘制商品;可以是单订单明细或多订单合并结果 + * @param renderer 当前渲染器 + * @param products 待渲染商品;可以是单订单明细或多订单合并结果 */ - private static void appendVoucherProducts(ThermalReceiptImage image, List products) { + private static void appendVoucherProducts(ReceiptRenderer renderer, List products) { for (VoucherProductLine product : products) { - image.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()), + renderer.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()), money(product.subtotal()), product.combo(), voucherComponentItems(product)); } } @@ -1078,14 +1100,14 @@ public class HardInvoiceServiceImpl extends ServiceImpl fields = voucherUserFields(user); if (fields.isEmpty()) return; - image.divider().sectionTitle("用户信息"); - fields.forEach(image::keyValue); + renderer.divider().sectionTitle("用户信息"); + fields.forEach(renderer::keyValue); } /** @@ -1401,4 +1423,106 @@ public class HardInvoiceServiceImpl extends ServiceImpl componentItems) { + image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems); + command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems); + return this; + } + + @Override + public ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal, + boolean combo, List componentItems) { + image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems); + command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems); + return this; + } + + @Override + public ReceiptRenderer voucherTotal(String totalAmount) { + image.voucherTotal(totalAmount); + command.voucherTotal(totalAmount); + return this; + } + + @Override + public ReceiptRenderer divider() { + image.divider(); + command.divider(); + return this; + } + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java new file mode 100644 index 00000000..d583ef9f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java @@ -0,0 +1,250 @@ +package com.bsd.pjcloud.hard.api.utils; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 80mm 发票与兑换券 ESC/POS 指令流的结构、编码和内容完整性测试。 */ +class EscPosReceiptCommandTest { + + /** GBK 解码字符集,与生产输出编码保持一致。 */ + private static final Charset GBK = Charset.forName("GBK"); + + /** 输出必须以 ESC @ 初始化开头,保证打印机残留样式被复位。 */ + @Test + void outputStartsWithEscAtInitAndBase64RoundTrip() { + EscPosReceiptCommand command = new EscPosReceiptCommand().title("智慧食堂发票"); + byte[] bytes = java.util.Base64.getDecoder().decode(command.toBase64()); + + assertEquals(0x1B, bytes[0]); + assertEquals(0x40, bytes[1]); + assertTrue(bytes.length > 2, "指令流应包含初始化之后的内容指令"); + } + + /** 指令流不得包含切刀和走纸命令:末尾边界由终端按本机配置统一包装。 */ + @Test + void outputNeverContainsCutOrFeedPaperCommand() { + String base64 = new EscPosReceiptCommand() + .title("预约核销兑换券") + .banner("第 1 次打印 · 2 笔预约") + .keyValue("商户", "青鸟同文") + .voucherProduct("A套餐-糖醋排骨", 2, "12.00", "24.00", true, List.of("糖醋排骨 x2")) + .voucherTotal("24.00") + .toBase64(); + byte[] bytes = java.util.Base64.getDecoder().decode(base64); + + for (int index = 0; index < bytes.length - 1; index++) { + assertFalse(bytes[index] == 0x1D && bytes[index + 1] == 0x56, + "指令流不应包含 GS V 切刀命令"); + } + assertFalse(containsText(bytes, "\n\n\n"), "指令流不应含成段走纸"); + } + + /** 每行文本都被汉字模式开启与关闭包裹,截断在任意行都不会让打印机停留在汉字状态。 */ + @Test + void everyLineIsWrappedByKanjiModeOnAndOff() { + byte[] bytes = decode(new EscPosReceiptCommand() + .title("智慧食堂发票") + .keyValue("姓名", "邓樟晨") + .voucherTotal("18.60")); + + int kanjiOn = 0; + int kanjiOff = 0; + for (int index = 0; index < bytes.length - 1; index++) { + if (bytes[index] == 0x1C && bytes[index + 1] == 0x26) kanjiOn++; + if (bytes[index] == 0x1C && bytes[index + 1] == 0x2E) kanjiOff++; + } + assertTrue(kanjiOn >= 3, "每个文本行都应输出 FS & 开启汉字模式"); + assertEquals(kanjiOn, kanjiOff, "汉字模式开启次数必须与关闭次数配对"); + } + + /** 完整发票样张按行解码后应包含图片版式的全部关键内容。 */ + @Test + void invoiceSampleKeepsAllVisibleContentInDecodedLines() { + List lines = visibleLines(decode(invoiceSample())); + + assertTrue(lines.stream().anyMatch(line -> line.contains("智慧食堂发票")), "标题行缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("商户:青鸟同文")), "商户行缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("姓名:邓樟晨")), "姓名行缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("学号:1231312")), "学号行缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐-糖醋排骨")), "商品名缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("小计 ¥17.00")), "商品小计缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("商品合计")), "商品合计缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("¥18.60")), "合计金额缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("打印时间")), "打印时间缺失"); + } + + /** 商品行的数量必须与名称同行且整行宽度恰好铺满 48 列,保证右列对齐稳定。 */ + @Test + void itemLineFillsExactlyPaperWidthWithQuantityRightAligned() { + byte[] bytes = decode(new EscPosReceiptCommand().item("红糖发糕", "x3", false, false)); + List lines = visibleLines(bytes); + String productLine = lines.stream().filter(line -> line.contains("红糖发糕")).findFirst().orElse(""); + + assertTrue(productLine.endsWith("x3"), "数量应位于商品行行尾"); + assertEquals(EscPosReceiptCommand.CHARS_PER_LINE, EscPosReceiptCommand.displayWidth(productLine), + "商品行应以空格填充铺满整行宽度"); + } + + /** 超长商品名按剩余宽度折行,续行不丢字且数量仍保留在首行行尾。 */ + @Test + void longProductNameWrapsAndKeepsAllCharacters() { + String longName = "超长套餐主商品名称".repeat(6); + List lines = visibleLines(decode(new EscPosReceiptCommand().item(longName, "x12", false, false))); + + assertTrue(lines.stream().anyMatch(line -> line.endsWith("x12")), "数量应在首行行尾"); + String rejoined = String.join("", lines).replace(" ", "").replace("x12", ""); + assertTrue(rejoined.contains(longName), "折行后不得丢失商品名字符"); + } + + /** 商品合计常规金额使用双倍宽度输出;超长金额自动降为单倍保证不越界。 */ + @Test + void voucherTotalScalesShortAmountAndDegradesLongAmount() { + byte[] shortAmount = decode(new EscPosReceiptCommand().voucherTotal("28.80")); + assertTrue(contains(shortAmount, 0x1D, 0x21, 0x22), "常规合计应使用双倍宽度 GS ! 0x22"); + + byte[] longAmount = decode(new EscPosReceiptCommand().voucherTotal("12345678901234.50")); + assertFalse(contains(longAmount, 0x1D, 0x21, 0x22), "超长合计应降级为单倍宽度"); + List lines = visibleLines(longAmount); + assertTrue(lines.stream().anyMatch(line -> line.contains("¥12345678901234.50")), "降级后金额必须完整保留"); + } + + /** 长横幅文本自动从双倍降为单倍,保证提示完整单行展示。 */ + @Test + void longBannerDegradesScaleInsteadOfLosingContent() { + List lines = visibleLines(decode(new EscPosReceiptCommand() + .banner("第 12 次打印 · 100 笔预约订单合并打印"))); + + assertTrue(lines.stream().anyMatch(line -> line.contains("第 12 次打印 · 100 笔预约订单合并打印")), + "降级后横幅文字必须完整"); + } + + /** 值放不下同一行的键值对应整段换行,与图片版的堆叠语义一致。 */ + @Test + void keyValueStacksValueOnNextLinesWhenTooLong() { + List lines = visibleLines(decode(new EscPosReceiptCommand() + .keyValue("订单", "20967800000000000012096780000000000001209678"))); + + assertEquals("订单:", lines.get(0), "标签应独占首行"); + assertTrue(lines.get(1).startsWith("2096780"), "值应从下一行起折行展示"); + } + + /** 多订单摘要完整保留订单号与日期餐段,订单间输出短线分隔。 */ + @Test + void voucherOrderSummaryKeepsAllFieldsWithShortSeparator() { + List lines = visibleLines(decode(new EscPosReceiptCommand() + .voucherOrderSummary(1, 2, "2096780000000000001", "2026-09-07", "午餐") + .voucherOrderSummary(2, 2, "2096780000000000002", "2026-09-07", "午餐"))); + + assertTrue(lines.stream().anyMatch(line -> line.contains("订单 1/2")), "订单序号标签缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("2096780000000000001")), "订单号缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("订餐日期:2026-09-07")), "订餐日期缺失"); + assertTrue(lines.stream().anyMatch(line -> line.contains("餐段:午餐")), "餐段缺失"); + assertTrue(lines.stream().anyMatch(line -> "-".repeat(32).equals(line)), "订单间应有短线分隔"); + } + + /** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */ + @Test + void displayWidthAndWrapFollowChineseAwareColumns() { + assertEquals(6, EscPosReceiptCommand.displayWidth("红烧肉")); + assertEquals(4, EscPosReceiptCommand.displayWidth("x3ab")); + + List lines = EscPosReceiptCommand.wrapByWidth("红糖发糕精品肋排", 8); + assertEquals(2, lines.size(), "8 列应把 12 列文本折成两行"); + assertEquals("红糖发糕", lines.get(0)); + assertEquals("精品肋排", lines.get(1)); + } + + /** + * 构造与发票装配顺序一致的完整指令样张。 + * + * @return 已完成拼装的发票指令流 + */ + private static EscPosReceiptCommand invoiceSample() { + return new EscPosReceiptCommand() + .title("智慧食堂发票") + .keyValue("商户", "青鸟同文") + .keyValue("钱包类型", "就餐钱包") + .divider().sectionTitle("用户信息") + .keyValue("姓名", "邓樟晨") + .keyValue("学号", "1231312") + .divider().sectionTitle("订单 2096786045923254273") + .keyValue("订单类型", "线下购买") + .keyValue("下单时间", "2026-09-14 12:03:01") + .divider().sectionTitle("商品明细") + .voucherProduct("A套餐-糖醋排骨", "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1")) + .voucherProduct("红糖发糕", "x1", "1.60", "1.60", false, List.of()) + .keyValue("额外支付金额", "¥0.50") + .voucherTotal("18.60") + .keyValue("打印时间", "2026-09-14 12:03:05"); + } + + /** + * 解码 Base64 指令流为原始字节。 + * + * @param command 已完成拼装的指令流 + * @return ESC/POS 原始字节 + */ + private static byte[] decode(EscPosReceiptCommand command) { + return java.util.Base64.getDecoder().decode(command.toBase64()); + } + + /** + * 按换行符切分指令流并解码每行的可见文本。 + * + *

每行结构固定为样式指令 + FS & + GBK 文本 + \n + FS .,取 FS & 之后到换行前的字节 + * 即可还原文本;GBK 双字节编码不会出现 0x1C/0x0A,切分安全。

+ * + * @param bytes ESC/POS 原始字节 + * @return 逐行可见文本 + */ + private static List visibleLines(byte[] bytes) { + List lines = new ArrayList<>(); + int index = 0; + while (index < bytes.length) { + int start = index; + while (index < bytes.length && bytes[index] != 0x0A) index++; + int textStart = start; + for (int scan = start; scan < index - 1; scan++) { + if (bytes[scan] == 0x1C && bytes[scan + 1] == 0x26) { + textStart = scan + 2; + break; + } + } + lines.add(new String(Arrays.copyOfRange(bytes, textStart, index), GBK)); + index++; + } + return lines; + } + + /** + * 判断指令流是否包含指定字节序列。 + */ + private static boolean contains(byte[] bytes, int... pattern) { + for (int index = 0; index <= bytes.length - pattern.length; index++) { + boolean matched = true; + for (int offset = 0; offset < pattern.length; offset++) { + if (bytes[index + offset] != pattern[offset]) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; + } + + /** + * 判断解码后的指令流是否包含指定文本。 + */ + private static boolean containsText(byte[] bytes, String text) { + return String.join("", visibleLines(bytes)).contains(text); + } +} diff --git a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java index c6afa09c..a7216aea 100644 --- a/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java +++ b/pjcloud-mqtt/pjcloud-mqtt-api/src/main/java/com/bsd/pjcloud/mqtt/dto/hard/HardPrintMsg.java @@ -56,4 +56,10 @@ public class HardPrintMsg extends MqttHardBaseMsg { // 图片地址 private String pic; + + /** + * 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节);终端指令打印直写 USB,可空。 + */ + @Schema(description = "打印指令流Base64") + private String printCmd; }