feat:打印发票修改发票样式

feat:把hard的定时任务迁移到定时任务服务
This commit is contained in:
dzc committed 2026-09-28 15:25:41 +08:00
1 parent dac1538022
commit 8b6f20f6bc
25 files changed
+1082 -409

No files matched your search

@@ -0,0 +1,90 @@
package com.bsd.pjcloud.hard.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
/**
* daemon-quartz 定时触发 Hardware 各补偿任务的内部 Feign 契约。
*
* <p>Hardware 原本地 {@code @Scheduled} 任务已全部迁移到 daemon-quartz 统一调度,本契约对应
* HardScheduleInnerController 暴露的内部端点;各任务内部由分布式订单锁、任务行锁、
* Outbox 条件更新或轮询协调器退避门控保证重复触发幂等安全。</p>
*/
@FeignClient(contextId = "remoteHardScheduleService", value = ServiceNameConstants.HARDWARE_SERVICE)
public interface RemoteHardScheduleService {
/**
* 高频补偿最近十五分钟内仍处于创建状态的聚合支付订单;空闲时由轮询协调器退避门控直接空转返回。
*
* @return 固定返回成功标记,本轮是否实际扫描由 Hardware 实例内协调器决定
*/
@NoToken
@PostMapping("/hard-schedule/inner/aggregate-pay/settle-pending")
R<Void> settlePendingAggregateOrders();
/**
* 低频核对十五分钟至一小时内的遗留聚合支付订单,并原子取消超过一小时的订单。
*
* @return 固定返回成功标记,逐单结果由订单锁与状态条件更新保证
*/
@NoToken
@PostMapping("/hard-schedule/inner/aggregate-pay/settle-stale")
R<Void> settleStaleAggregateOrders();
/**
* 扫描到期的自动退款任务并按任务租户恢复上下文后执行。
*
* @return 本轮扫描到并尝试执行的任务数量
*/
@NoToken
@PostMapping("/hard-schedule/inner/auto-refund/retry-due")
R<Integer> retryDueAutoRefundTasks();
/**
* 重试通知失败的咖啡机支付回调与退款同步缺口。
*
* @return 固定返回成功标记,失败订单按通知次数上限熔断
*/
@NoToken
@PostMapping("/hard-schedule/inner/coffee-notify/retry-failures")
R<Void> retryCoffeeNotifyFailures();
/**
* 扫描到期的核销消息 Outbox 事件并投递,条件更新领取保证重复触发幂等。
*
* @return 本轮处理的事件数量
*/
@NoToken
@PostMapping("/hard-schedule/inner/outbox/process-due")
R<Integer> processOutboxDueEvents();
/**
* 分批清理超过保留期的终态核销消息 Outbox。
*
* @return 本轮清理的事件数量
*/
@NoToken
@PostMapping("/hard-schedule/inner/outbox/cleanup")
R<Integer> cleanupOutboxExpiredEvents();
/**
* 扫描待生成的持久化打印任务并补偿下发待打印任务。
*
* @return 本轮处理的打印任务数量
*/
@NoToken
@PostMapping("/hard-schedule/inner/print/process-due")
R<Integer> processPrintDueTasks();
/**
* 清理创建时间超过三十天的消费机日志,低于阈值时不执行删除。
*
* @return 本轮删除的日志行数,未达阈值时为零
*/
@NoToken
@PostMapping("/hard-schedule/inner/xfj-log/cleanup")
R<Integer> cleanupXfjLog();
}
@@ -175,9 +175,9 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
* 渲染兑换券商品卡片,数量为整数份数。
*/
@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);
public EscPosReceiptCommand voucherProduct(String name, String subName, int quantity, String unitPrice,
String subtotal, boolean combo, List<String> componentItems) {
return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
@@ -185,12 +185,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
*
* <p>与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片
* fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部
* 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。</p>
* 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。
* subName 非空时在倍宽主名后输出单倍宽副行,对应图片版主名下方的小字号商品名。</p>
*/
@Override
public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
public EscPosReceiptCommand voucherProduct(String name, String subName, String quantity, String unitPrice,
String subtotal, boolean combo, List<String> componentItems) {
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
String safeSubName = subName == null ? null : subName.trim();
if (safeSubName != null && safeSubName.isBlank()) safeSubName = null;
String priceText = "单价 ¥" + (unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice)
+ " " + (quantity == null || quantity.isBlank() ? "x0" : quantity);
String subtotalText = "小计 ¥" + (subtotal == null || subtotal.isBlank() ? "--" : subtotal);
@@ -208,6 +211,12 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
for (String nameLine : wrapByWidth(safeName, scaledInner)) {
printScaledNameInBox(nameLine, scaledInner - displayWidth(nameLine));
}
// 副行商品名:单倍宽不加粗,对应图片版小字号,ESC/POS 无缩小字号指令故与价格行同档。
if (safeSubName != null) {
for (String subLine : wrapByWidth(" " + safeSubName, BOX_INNER_WIDTH)) {
boxLine(subLine, false);
}
}
boxLineTwoColumns(priceText, subtotalText, false);
List<String> safeComponents = componentItems == null ? List.of() : componentItems.stream()
@@ -92,7 +92,8 @@ public interface ReceiptRenderer {
/**
* 渲染兑换券商品卡片,数量为整数份数。
*
* @param name 商品快照名称
* @param name 主商品名(套餐名优先);空值由实现兜底
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
* @param quantity 剩余可履约数量
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 剩余可履约数量对应的小计,单位元且不含货币符号
@@ -100,13 +101,14 @@ public interface ReceiptRenderer {
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
* @return 当前渲染器
*/
ReceiptRenderer voucherProduct(String name, int quantity, String unitPrice, String subtotal,
ReceiptRenderer voucherProduct(String name, String subName, int quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems);
/**
* 渲染兑换券商品卡片,数量为已格式化文本(份数“x2”或称重“0.5/kg”)。
*
* @param name 商品快照名称
* @param name 主商品名(套餐名优先);空值由实现兜底
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
* @param quantity 数量展示文本
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 商品小计,单位元且不含货币符号
@@ -114,7 +116,7 @@ public interface ReceiptRenderer {
* @param componentItems 组合商品组成说明,每项应已包含对应数量;可为空
* @return 当前渲染器
*/
ReceiptRenderer voucherProduct(String name, String quantity, String unitPrice, String subtotal,
ReceiptRenderer voucherProduct(String name, String subName, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems);
/**
@@ -292,17 +292,19 @@ public class ThermalReceiptImage implements ReceiptRenderer {
* @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
* @return 当前绘制器
*/
public ThermalReceiptImage 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);
public ThermalReceiptImage voucherProduct(String name, String subName, int quantity, String unitPrice,
String subtotal, boolean combo, List<String> componentItems) {
return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。
*
* <p>数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。</p>
* <p>数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。
* 主名保持大号粗体;subName 非空时在主名与价格行之间以正文字号渲染副行小字。</p>
*
* @param name 商品快照名称;空值显示“未命名商品”
* @param name 主商品名(套餐名优先);空值显示“未命名商品”
* @param subName 主名下方的副行名称(商品名);null 或空白时不渲染副行
* @param quantity 数量展示文本,例如“x2”或“0.5/kg”
* @param unitPrice 商品快照单价,单位元且不含货币符号
* @param subtotal 商品小计,单位元且不含货币符号
@@ -310,10 +312,12 @@ public class ThermalReceiptImage implements ReceiptRenderer {
* @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
* @return 当前绘制器
*/
public ThermalReceiptImage voucherProduct(String name, String quantity, String unitPrice, String subtotal,
boolean combo, List<String> componentItems) {
public ThermalReceiptImage voucherProduct(String name, String subName, String quantity, String unitPrice,
String subtotal, boolean combo, List<String> componentItems) {
String typeLabel = combo ? "组合商品" : "单品";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
String safeSubName = subName == null ? null : subName.trim();
if (safeSubName != null && safeSubName.isBlank()) safeSubName = null;
String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice;
String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal;
@@ -335,6 +339,10 @@ public class ThermalReceiptImage implements ReceiptRenderer {
int nameLineHeight = nameMetrics.getHeight() + 4;
setFont(Font.PLAIN, priceFontSize);
FontMetrics subNameMetrics = graphics.getFontMetrics();
List<String> subNameLines = safeSubName == null ? List.of() : wrap(safeSubName, innerWidth, subNameMetrics);
int subNameLineHeight = subNameMetrics.getHeight() + 3;
FontMetrics priceMetrics = graphics.getFontMetrics();
String priceAndQuantity = "单价 ¥" + safeUnitPrice + " " + safeQuantity;
String subtotalText = "小计 ¥" + safeSubtotal;
@@ -352,7 +360,8 @@ public class ThermalReceiptImage implements ReceiptRenderer {
int componentHeight = componentLines.stream().mapToInt(List::size).sum() * componentLineHeight;
int comboSectionHeight = combo && !componentLines.isEmpty()
? 18 + componentLineHeight + componentHeight : 0;
int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight + 8
int cardHeight = PRODUCT_CARD_PADDING + tagHeight + 10 + nameLines.size() * nameLineHeight
+ subNameLines.size() * subNameLineHeight + 8
+ (splitPriceLine ? priceLineHeight * 2 : priceLineHeight) + comboSectionHeight
+ PRODUCT_CARD_PADDING;
ensureCapacity(cardHeight + PRODUCT_CARD_GAP);
@@ -382,6 +391,13 @@ public class ThermalReceiptImage implements ReceiptRenderer {
graphics.drawString(line, contentX, contentY + nameMetrics.getAscent());
contentY += nameLineHeight;
}
if (!subNameLines.isEmpty()) {
setFont(Font.PLAIN, priceFontSize);
for (String line : subNameLines) {
graphics.drawString(line, contentX, contentY + subNameMetrics.getAscent());
contentY += subNameLineHeight;
}
}
contentY += 8;
setFont(Font.PLAIN, priceFontSize);