From ddf2af76a579eebb14872020861ebebdb94b1466 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Sun, 20 Sep 2026 18:24:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E5=8F=96=E6=B6=88=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E8=87=AA=E5=B8=A6=E8=BD=AF=E9=94=AE=E7=9B=98=E8=AE=BE=E8=AE=A1?=
=?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E7=B3=BB=E7=BB=9F=E8=87=AA=E7=94=A8?=
=?UTF-8?q?=E9=94=AE=E7=9B=98=E7=BB=84=E4=BB=B6-=E5=88=9D=E6=AD=A5=20feat:?=
=?UTF-8?q?=E5=B0=86=E6=89=93=E5=8D=B0=E5=88=86=E4=B8=BA=E5=9B=BE=E7=89=87?=
=?UTF-8?q?=E6=89=93=E5=8D=B0=E5=92=8C=E5=91=BD=E4=BB=A4=E5=BC=8F=E6=89=93?=
=?UTF-8?q?=E5=8D=B0-=E5=88=9D=E6=AD=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/20260920_add_hard_invoice_print_cmd.sql | 5 +
db/800pjcloudx_hardware.sql | 1 +
.../hard/api/entity/HardInvoiceEntity.java | 7 +
.../hard/api/utils/EscPosReceiptCommand.java | 372 ++++++++++++++++++
.../hard/api/utils/ReceiptRenderer.java | 134 +++++++
.../hard/api/utils/ThermalReceiptImage.java | 5 +-
.../service/impl/HardInvoiceServiceImpl.java | 242 +++++++++---
.../api/utils/EscPosReceiptCommandTest.java | 250 ++++++++++++
.../pjcloud/mqtt/dto/hard/HardPrintMsg.java | 6 +
9 files changed, 962 insertions(+), 60 deletions(-)
create mode 100644 db/20260920_add_hard_invoice_print_cmd.sql
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommand.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/utils/ReceiptRenderer.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/EscPosReceiptCommandTest.java
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;
}