feat:取消系统自带软键盘设计,改为系统自用键盘组件-初步

feat:将打印分为图片打印和命令式打印-初步
This commit is contained in:
dzc committed 2026-09-20 18:24:46 +08:00
1 parent b1b67b4dc7
commit ddf2af76a5
9 files changed
+962 -60

No files matched your search

@@ -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`;
+1
View File
@@ -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 '修改人',
@@ -115,6 +115,13 @@ public class HardInvoiceEntity extends Model<HardInvoiceEntity> {
@Schema(description="打印图片")
private String pic;
/**
* 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节),供终端指令打印直写 USB。
* 历史任务和图片生成失败时为空,终端指令模式下回退 pic 图片打印。
*/
@Schema(description="打印指令流Base64")
private String printCmd;
/**
* 创建人
*/
@@ -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}。
*
* <p>与 {@link ThermalReceiptImage} 保持同一套版式语义:相同的调用顺序在图片模式产出
* PNG、在指令模式产出 ESC/POS 字节流,终端按本机设置二选一直写 USB。指令模式跳过
* 图片栅格化与传输,出纸速度显著快于图片打印。</p>
*
* <p>排版约定:80mm 纸按 48 个 ANK 字符列计算(中文字符占 2 列);中文通过 ESC/POS
* 汉字模式(FS &amp; / FS .)以 GBK 编码输出,每行打印后立即关闭汉字模式避免状态泄漏。
* 输出内容不含初始化以外的走纸与切刀命令,末尾走纸和切刀由终端按本机配置统一包装。</p>
*/
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<String> 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<String> componentItems) {
return voucherProduct(name, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 渲染兑换券商品卡片:类型标签与名称加粗同行,单价数量与小计两栏,组合内容缩进说明。
*
* <p>图片模式以描边卡片区分商品;指令模式用商品块后的空行近似同样的视觉间隔。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> 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<String> 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<String> 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<String> 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 编码、换行、汉字模式关。
*
* <p>每个样式指令在行内声明,行后仅复位放大与加粗,对齐由下一行重新声明,
* 保证任意指令流截断或拼接都不会让打印机停留在未知样式状态。</p>
*
* @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<String> wrapByWidth(String text, int maxWidth) {
List<String> 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;
}
}
@@ -0,0 +1,134 @@
package com.bsd.pjcloud.hard.api.utils;
import java.util.List;
/**
* 打印凭证版式渲染器契约,发票与兑换券的数据拼装只依赖本接口。
*
* <p>当前实现有两个:{@link ThermalReceiptImage} 渲染 576px PNG 图片供图片栅格打印;
* {@link EscPosReceiptCommand} 直接拼装 ESC/POS 文本指令供指令打印,出纸更快。
* 业务层用同一份调用序列驱动两个渲染器,保证两种输出方式的凭证内容始终一致。</p>
*
* <p>实现类的方法返回类型允许返回自身具体类型以保留链式调用(协变返回),拼接顺序
* 由调用方保证,本接口不承载状态查询。</p>
*/
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<String> 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<String> componentItems);
/**
* 渲染商品合计,形成票据金额层级。
*
* @param totalAmount 商品合计金额,单位元且不含货币符号
* @return 当前渲染器
*/
ReceiptRenderer voucherTotal(String totalAmount);
/**
* 渲染一条横向分隔线。
*
* @return 当前渲染器
*/
ReceiptRenderer divider();
}
@@ -17,8 +17,11 @@ import java.util.List;
* 面向 80mm、576px ESC/POS 打印的动态高度 PNG 绘制器。
*
* <p>商品名称按真实像素宽度换行,数量列固定右对齐,保证长名称不会与数量错配。</p>
*
* <p>实现 {@link ReceiptRenderer} 版式契约,方法返回自身具体类型保留链式调用;
* 指令打印由同包的 {@link EscPosReceiptCommand} 以相同调用序列产出 ESC/POS 字节。</p>
*/
public class ThermalReceiptImage {
public class ThermalReceiptImage implements ReceiptRenderer {
/** 80mm 打印头常见有效像素宽度。 */
public static final int PAPER_WIDTH_PIXELS = 576;
@@ -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<HardInvoiceMapper, HardI
*/
@Override
public String generateInvoice(HardOrderEntity order, String code) {
return renderInvoice(order, code).pic();
}
/**
* 渲染发票并产出图片地址与打印指令流双通道内容。
*
* @param order 已支付订单
* @param code 取件码;空时不渲染取件码区
* @return 图片地址与 Base64 指令流
*/
private GeneratedReceipt renderInvoice(HardOrderEntity order, String code) {
MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
UserVO user = loadVoucherUser(order.getUserId());
List<HardCarVo> carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
@@ -268,58 +281,61 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType()))
.map(HardTypeEnum::getDescription).orElse("设备");
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true)
.title(String.format("智慧%s发票", hardwareName))
ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true);
EscPosReceiptCommand command = new EscPosReceiptCommand();
ReceiptRenderer renderer = new CompositeRenderer(image, command);
renderer.title(String.format("智慧%s发票", hardwareName))
.keyValue("商户", mch.getName());
String walletTypeName = resolveWalletTypeName(order.getWalletId());
if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName);
appendVoucherUser(image, user);
appendInvoiceOrderBlock(image, order);
appendInvoiceProducts(image, carList, metadata, showComboItems);
if (StrUtil.isNotBlank(walletTypeName)) renderer.keyValue("钱包类型", walletTypeName);
appendVoucherUser(renderer, user);
appendInvoiceOrderBlock(renderer, order);
appendInvoiceProducts(renderer, carList, metadata, showComboItems);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
image.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
renderer.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
.keyValue("打印时间", formatDateTime(LocalDateTime.now()));
if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code);
if (StrUtil.isNotBlank(code)) renderer.divider().sectionTitle("取件码").verificationCode(code);
try {
return uploadPng("invoice_" + order.getId(), image.toPngBytes());
return new GeneratedReceipt(uploadPng("invoice_" + order.getId(), image.toPngBytes()),
command.toBase64());
} catch (IOException exception) {
throw new RuntimeException("生成发票图片失败", exception);
}
}
/**
* 按兑换券订单区版式绘制发票订单识别信息;预约单补齐预约日期与餐段。
* 按兑换券订单区版式渲染发票订单识别信息;预约单补齐预约日期与餐段。
*
* @param image 当前发票图片绘制器
* @param renderer 当前渲染器
* @param order 订单快照
*/
private static void appendInvoiceOrderBlock(ThermalReceiptImage image, HardOrderEntity order) {
image.divider().sectionTitle("订单 " + displayOrderNo(order));
private static void appendInvoiceOrderBlock(ReceiptRenderer renderer, HardOrderEntity order) {
renderer.divider().sectionTitle("订单 " + displayOrderNo(order));
if (ObjectUtil.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
image.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
renderer.keyValue("预约日期", order.getApptUseDate() == null ? "--" : order.getApptUseDate().toString())
.keyValue("餐段", resolveMealName(order.getHourType()));
}
String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType()))
.map(HardOrderTypeEnum::getDescription).orElse("--");
image.keyValue("订单类型", orderType)
renderer.keyValue("订单类型", orderType)
.keyValue("下单时间", formatDateTime(order.getCreateTime()));
}
/**
* 按兑换券商品卡片版式绘制发票商品明细,数量支持份数与称重重量。
* 按兑换券商品卡片版式渲染发票商品明细,数量支持份数与称重重量。
*
* <p>发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。</p>
*
* @param image 当前发票图片绘制器
* @param renderer 当前渲染器
* @param cars 订单商品快照
* @param metadata 按商品 ID 索引的套餐类型和组成项
* @param showComboItems 是否展示套餐组成内容
*/
private static void appendInvoiceProducts(ThermalReceiptImage image, List<HardCarVo> cars,
private static void appendInvoiceProducts(ReceiptRenderer renderer, List<HardCarVo> cars,
Map<Long, ProductMetadata> 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<HardInvoiceMapper, HardI
boolean combo = product != null && product.combo();
List<String> 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 ServiceImpl<HardInvoiceMapper, HardI
}
/**
* 生成并上传任务图片,失败时将原任务标记为图片生成失败。
* 生成并上传任务图片,同时拼装指令流;失败时将原任务标记为图片生成失败。
*/
private HardInvoiceEntity generateTaskImage(HardInvoiceEntity task) {
try {
HardOrderEntity order = orderService.getById(task.getOrderId());
if (order == null) throw new RuntimeException("订单不存在");
String image = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())
? generateVoucher(order, task.getPrintNo()) : generateInvoice(order);
if (StrUtil.isBlank(image)) throw new RuntimeException("图片上传地址为空");
GeneratedReceipt receipt = Objects.equals(task.getPrintType(), HardPrintTypeEnum.REDEMPTION_VOUCHER.getType())
? renderVoucher(order, task.getPrintNo()) : renderInvoice(order, null);
if (StrUtil.isBlank(receipt.pic())) throw new RuntimeException("图片上传地址为空");
baseMapper.update(null, Wrappers.<HardInvoiceEntity>lambdaUpdate()
.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<HardInvoiceMapper, HardI
message.setRetryVersion(ObjectUtil.defaultIfNull(task.getRetryVersion(), 0));
message.setAndroidOrderCode(order.getAndroidOrderCode());
message.setPic(task.getPic());
message.setPrintCmd(task.getPrintCmd());
R<Boolean> 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<HardInvoiceMapper, HardI
}
/**
* 生成不含核销短码的兑换券 PNG 并上传文件服务。
* 生成不含核销短码的兑换券内容并产出图片与指令流双通道输出。
*
* <p>身份资料查询失败时降级为不打印用户区,不能阻断已经完成核销的订单出票;商品金额按
* 部分退款后的剩余可履约数量重新计算,组合组成项只做说明而不重复计价。</p>
*
* @param order 已完成核销的预约订单
* @param printNo 当前订单打印序号
* @return 文件服务返回的兑换券 PNG 地址
* @return 图片地址与 Base64 指令流
*/
private String generateVoucher(HardOrderEntity order, Integer printNo) {
private GeneratedReceipt renderVoucher(HardOrderEntity order, Integer printNo) {
List<HardOrderEntity> 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<VoucherOrderProducts> 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<HardInvoiceMapper, HardI
}
/**
* 绘制原有按订单分组的兑换券版式。
* 渲染原有按订单分组的兑换券版式。
*
* @param image 当前兑换券图片绘制器
* @param renderer 当前渲染器
* @param orderProducts 按订单分组的商品快照
*/
private static void appendOrderGroupedVoucherLayout(ThermalReceiptImage image,
private static void appendOrderGroupedVoucherLayout(ReceiptRenderer renderer,
List<VoucherOrderProducts> 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<VoucherOrderProducts> 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<VoucherProductLine> products) {
private static void appendVoucherProducts(ReceiptRenderer renderer, List<VoucherProductLine> 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<HardInvoiceMapper, HardI
/**
* 按非空字段绘制兑换券用户身份资料,学生学号优先于老师学号。
*
* @param image 当前兑换券图片绘制器
* @param user UPMS 返回的用户及身份扩展;为空时不绘制用户区
* @param renderer 当前渲染器
* @param user UPMS 返回的用户及身份扩展;为空时不渲染用户区
*/
private static void appendVoucherUser(ThermalReceiptImage image, UserVO user) {
private static void appendVoucherUser(ReceiptRenderer renderer, UserVO user) {
Map<String, String> 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<HardInvoiceMapper, HardI
*/
private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
}
/**
* 一次凭证渲染的双通道输出。
*
* @param pic 上传后的凭证 PNG 地址;图片打印与指令回退均使用
* @param printCmd ESC/POS 指令流 Base64;指令打印直写 USB,历史任务允许为空
*/
private record GeneratedReceipt(String pic, String printCmd) {
}
/**
* 双通道渲染器:同一份版式调用序列同时驱动图片绘制与 ESC/POS 指令拼装,
* 保证图片模式与指令模式的凭证内容始终一致,业务拼装代码只写一份。
*/
private record CompositeRenderer(ReceiptRenderer image, ReceiptRenderer command) implements ReceiptRenderer {
@Override
public ReceiptRenderer title(String text) {
image.title(text);
command.title(text);
return this;
}
@Override
public ReceiptRenderer banner(String text) {
image.banner(text);
command.banner(text);
return this;
}
@Override
public ReceiptRenderer verificationCode(String shortCode) {
image.verificationCode(shortCode);
command.verificationCode(shortCode);
return this;
}
@Override
public ReceiptRenderer keyValue(String label, String value) {
image.keyValue(label, value);
command.keyValue(label, value);
return this;
}
@Override
public ReceiptRenderer sectionTitle(String text) {
image.sectionTitle(text);
command.sectionTitle(text);
return this;
}
@Override
public ReceiptRenderer voucherOrderSummary(int index, int total, String orderNo,
String orderDate, String mealName) {
image.voucherOrderSummary(index, total, orderNo, orderDate, mealName);
command.voucherOrderSummary(index, total, orderNo, orderDate, mealName);
return this;
}
@Override
public ReceiptRenderer item(String name, String quantity, boolean child, boolean emphasized) {
image.item(name, quantity, child, emphasized);
command.item(name, quantity, child, emphasized);
return this;
}
@Override
public ReceiptRenderer invoiceItem(String name, String quantity, String amount) {
image.invoiceItem(name, quantity, amount);
command.invoiceItem(name, quantity, amount);
return this;
}
@Override
public ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
boolean combo, List<String> 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<String> 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;
}
}
}
@@ -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<String> 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<String> 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<String> 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<String> lines = visibleLines(longAmount);
assertTrue(lines.stream().anyMatch(line -> line.contains("¥12345678901234.50")), "降级后金额必须完整保留");
}
/** 长横幅文本自动从双倍降为单倍,保证提示完整单行展示。 */
@Test
void longBannerDegradesScaleInsteadOfLosingContent() {
List<String> lines = visibleLines(decode(new EscPosReceiptCommand()
.banner("第 12 次打印 · 100 笔预约订单合并打印")));
assertTrue(lines.stream().anyMatch(line -> line.contains("第 12 次打印 · 100 笔预约订单合并打印")),
"降级后横幅文字必须完整");
}
/** 值放不下同一行的键值对应整段换行,与图片版的堆叠语义一致。 */
@Test
void keyValueStacksValueOnNextLinesWhenTooLong() {
List<String> lines = visibleLines(decode(new EscPosReceiptCommand()
.keyValue("订单", "20967800000000000012096780000000000001209678")));
assertEquals("订单:", lines.get(0), "标签应独占首行");
assertTrue(lines.get(1).startsWith("2096780"), "值应从下一行起折行展示");
}
/** 多订单摘要完整保留订单号与日期餐段,订单间输出短线分隔。 */
@Test
void voucherOrderSummaryKeepsAllFieldsWithShortSeparator() {
List<String> 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<String> 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());
}
/**
* 按换行符切分指令流并解码每行的可见文本。
*
* <p>每行结构固定为样式指令 + FS &amp; + GBK 文本 + \n + FS .,取 FS &amp; 之后到换行前的字节
* 即可还原文本;GBK 双字节编码不会出现 0x1C/0x0A,切分安全。</p>
*
* @param bytes ESC/POS 原始字节
* @return 逐行可见文本
*/
private static List<String> visibleLines(byte[] bytes) {
List<String> 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);
}
}
@@ -56,4 +56,10 @@ public class HardPrintMsg extends MqttHardBaseMsg {
// 图片地址
private String pic;
/**
* 后端拼装的凭证打印指令流(Base64 的 ESC/POS 字节);终端指令打印直写 USB,可空。
*/
@Schema(description = "打印指令流Base64")
private String printCmd;
}