diff --git a/db/20260928_hard模块定时任务迁移sys_job.txt b/db/20260928_hard模块定时任务迁移sys_job.txt
new file mode 100644
index 00000000..0e5c55d8
--- /dev/null
+++ b/db/20260928_hard模块定时任务迁移sys_job.txt
@@ -0,0 +1,219 @@
+========================================================================
+hardware 定时任务迁移 daemon-quartz - sys_job 手工添加清单 (2026-09-28)
+========================================================================
+
+说明:以下内容需要管理员在对应环境手工添加(Quartz 任务管理页或直接执行 SQL)。
+SQL 幂等,可重复执行。
+注意库名:sys_job 在 `pjcloudx_job` 库(与 qrtz_* / sys_job_log 同库)。
+
+迁移背景:
+ hardware 原本地 @Scheduled 定时任务(6 个类共 8 个方法)已全部迁移到 daemon-quartz
+ 统一调度,集群部署下避免各实例重复触发,且可在任务管理页暂停/改cron/手动执行/看执行日志。
+ 原 job 包已整体删除:纯转发型 job(咖啡机/Outbox/打印)由 HardScheduleInnerController
+ 直调领域服务;聚合支付结算补偿编排平移到 HardAggregateOrderPayCompensator;
+ 自动退款扫描编排平移进 HardAutoRefundTaskService.retryDueTasks;消费机日志清理平移进
+ HardXfjLogService.cleanupExpiredLogs。内部端点统一挂在 /hard-schedule/inner/** 下,
+ 由 daemon 的 hardScheduleTask bean(class_name=hardScheduleTask)通过 Feign 反射调用。
+ HardApp 已移除 @EnableScheduling,hardware 不再有任何本地周期调度。
+
+部署顺序(硬前提,顺序不能颠倒):
+ 1. 部署 pjcloud-hardware-biz(新增内部端点、已移除本地 @Scheduled 与 @EnableScheduling);
+ 2. 部署 pjcloud-daemon-quartz(新增 hardScheduleTask bean 与 hardware-api 依赖);
+ 3. 执行下方 8 条 sys_job INSERT;
+ 4. 重启 daemon-quartz 自动加载(或调 POST /sys-job/refresh-jobs,权限 job_sys_job_refresh_job)。
+
+空窗期说明(hardware 已部署、任务未生效期间补偿停摆的影响):
+ - 聚合支付结算:终端主动轮询仍是实时结算入口,补偿只是兜底;恢复后 1 小时内订单
+ 均会被遗留核对扫到,不丢单;
+ - 自动退款 / Outbox / 打印任务:均为持久化任务表,恢复后按到期时间补执行;
+ - 咖啡机通知:失败计数持久化(wuli_notify_count),恢复后继续重试;
+ - 消费机日志清理:每日一次,无影响。
+ 结论:分钟级空窗只延迟补偿、不丢数据;但仍应尽快按顺序完成部署。
+
+行为说明:
+ 原 fixedDelay 节奏改为固定 cron 节奏;Quartz 任务执行入口 PjcloudQuartzFactory 带
+ @DisallowConcurrentExecution,单轮未结束时下一触发自动顺延,不会重叠。
+ 集群重复触发/多节点安全性:
+ - 聚合支付结算:分布式订单锁 + 订单状态条件更新保证幂等;近期扫描在 hardware
+ 实例内还有轮询协调器退避门控,空闲时触发进来直接空转,不扫库。退避门控是实例内
+ 状态,多实例下负载均衡会把触发分摊到各实例,新订单唤醒后最迟 N×5 秒内会被扫到,
+ 实时结算仍以终端主动轮询为主、本任务只做兜底,不受影响;
+ - 自动退款:订单 Redis 锁 + 任务行锁最终串行;
+ - Outbox 投递/清理:条件更新(状态 CAS)领取,天然幂等;
+ - 打印/咖啡机/消费机日志:任务表状态流转或条件删除,重复执行安全。
+ misfire_policy 统一选 '3'(错过的触发不补偿执行,等下个周期),避免服务恢复后
+ 高频补偿任务雪崩式补跑。
+
+------------------------------------------------------------------------
+一、sys_job:聚合支付近期结算补偿(每5秒,对应原 fixedDelay=5s)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 1,
+ '聚合支付近期结算补偿', 'HARDWARE', '1', '2', NULL, 'hardScheduleTask', 'settlePendingAggregateOrders',
+ NULL, '0/5 * * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每5秒补偿近15分钟未结算的聚合支付订单;空闲时硬件侧退避门控空转'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '聚合支付近期结算补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+二、sys_job:聚合支付遗留核对与超时取消(每5分钟,对应原 fixedDelay=5min)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 2,
+ '聚合支付遗留核对与超时取消', 'HARDWARE', '2', '2', NULL, 'hardScheduleTask', 'settleStaleAggregateOrders',
+ NULL, '0 0/5 * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每5分钟核对15-60分钟遗留订单并原子取消超1小时订单'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '聚合支付遗留核对与超时取消' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+三、sys_job:自动退款到期任务补偿(每30秒,对应原 fixedDelay=30s)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 3,
+ '自动退款到期任务补偿', 'HARDWARE', '3', '2', NULL, 'hardScheduleTask', 'retryDueAutoRefundTasks',
+ NULL, '0/30 * * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每30秒扫描到期/暂停自动退款任务,订单Redis锁+任务行锁串行'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '自动退款到期任务补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+四、sys_job:咖啡机通知失败补偿(每分钟,对应原 fixedDelay=60s)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 4,
+ '咖啡机通知失败补偿', 'HARDWARE', '4', '2', NULL, 'hardScheduleTask', 'retryCoffeeNotifyFailures',
+ NULL, '0 * * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每分钟重试咖啡机支付回调/退款同步失败订单,按通知次数上限熔断'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '咖啡机通知失败补偿' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+五、sys_job:核销消息Outbox到期投递(每10秒,对应原 fixedDelay=10s)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 5,
+ '核销消息Outbox到期投递', 'HARDWARE', '5', '2', NULL, 'hardScheduleTask', 'processOutboxDueEvents',
+ NULL, '0/10 * * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每10秒扫描到期核销消息Outbox事件,条件更新领取幂等'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '核销消息Outbox到期投递' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+六、sys_job:核销消息Outbox过期清理(每日 02:30,与原本地任务一致)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 6,
+ '核销消息Outbox过期清理', 'HARDWARE', '6', '2', NULL, 'hardScheduleTask', 'cleanupOutboxExpiredEvents',
+ NULL, '0 30 2 * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每日02:30分批清理超保留期终态核销Outbox'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '核销消息Outbox过期清理' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+七、sys_job:打印任务补偿扫描(每10秒,对应原 fixedDelay=10s)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 7,
+ '打印任务补偿扫描', 'HARDWARE', '7', '2', NULL, 'hardScheduleTask', 'processPrintDueTasks',
+ NULL, '0/10 * * * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每10秒扫描待生成/待下发持久化打印任务,恢复MQTT离线期遗留'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '打印任务补偿扫描' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+八、sys_job:消费机日志过期清理(每日 00:00,与原本地任务一致)
+------------------------------------------------------------------------
+
+INSERT INTO `pjcloudx_job`.`sys_job`
+(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
+ `method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
+ `job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
+SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026092800000000000) FROM `pjcloudx_job`.`sys_job`) + 8,
+ '消费机日志过期清理', 'HARDWARE', '8', '2', NULL, 'hardScheduleTask', 'cleanupXfjLog',
+ NULL, '0 0 0 * * ?', '3', '1', '2',
+ '0', 'system', NOW(), 'system', NOW(), 1, '每日00:00删除30天前消费机日志;累计不足1万条时跳过'
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM `pjcloudx_job`.`sys_job`
+ WHERE `job_name` = '消费机日志过期清理' AND `job_group` = 'HARDWARE' AND `tenant_id` = 1
+);
+
+------------------------------------------------------------------------
+九、内部端点与任务对应关系(勿对外暴露,@Inner + @NoToken 仅服务间调用)
+------------------------------------------------------------------------
+
+hardScheduleTask.settlePendingAggregateOrders:
+ POST /hard-schedule/inner/aggregate-pay/settle-pending 聚合支付近期结算补偿
+hardScheduleTask.settleStaleAggregateOrders:
+ POST /hard-schedule/inner/aggregate-pay/settle-stale 聚合支付遗留核对与超时取消
+hardScheduleTask.retryDueAutoRefundTasks:
+ POST /hard-schedule/inner/auto-refund/retry-due 自动退款到期任务补偿
+hardScheduleTask.retryCoffeeNotifyFailures:
+ POST /hard-schedule/inner/coffee-notify/retry-failures 咖啡机通知失败补偿
+hardScheduleTask.processOutboxDueEvents:
+ POST /hard-schedule/inner/outbox/process-due 核销消息Outbox到期投递
+hardScheduleTask.cleanupOutboxExpiredEvents:
+ POST /hard-schedule/inner/outbox/cleanup 核销消息Outbox过期清理
+hardScheduleTask.processPrintDueTasks:
+ POST /hard-schedule/inner/print/process-due 打印任务补偿扫描
+hardScheduleTask.cleanupXfjLog:
+ POST /hard-schedule/inner/xfj-log/cleanup 消费机日志过期清理
+
+------------------------------------------------------------------------
+十、回滚说明
+------------------------------------------------------------------------
+ 1. 停用任务:将 8 条 sys_job 的 job_status 改为 '3'(暂停)或 '4'(删除)并重启
+ daemon-quartz(或调 POST /sys-job/refresh-jobs);
+ 2. 恢复本地调度:回滚整个迁移提交(恢复 job 包 6 个类与 HardApp 的
+ @EnableScheduling)并重新部署 hardware;
+ 3. 只想临时停某个补偿任务:单独暂停对应一条 sys_job 即可,其余任务不受影响。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java
new file mode 100644
index 00000000..afa80697
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardScheduleService.java
@@ -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 契约。
+ *
+ *
Hardware 原本地 {@code @Scheduled} 任务已全部迁移到 daemon-quartz 统一调度,本契约对应
+ * HardScheduleInnerController 暴露的内部端点;各任务内部由分布式订单锁、任务行锁、
+ * Outbox 条件更新或轮询协调器退避门控保证重复触发幂等安全。
+ */
+@FeignClient(contextId = "remoteHardScheduleService", value = ServiceNameConstants.HARDWARE_SERVICE)
+public interface RemoteHardScheduleService {
+
+ /**
+ * 高频补偿最近十五分钟内仍处于创建状态的聚合支付订单;空闲时由轮询协调器退避门控直接空转返回。
+ *
+ * @return 固定返回成功标记,本轮是否实际扫描由 Hardware 实例内协调器决定
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/aggregate-pay/settle-pending")
+ R settlePendingAggregateOrders();
+
+ /**
+ * 低频核对十五分钟至一小时内的遗留聚合支付订单,并原子取消超过一小时的订单。
+ *
+ * @return 固定返回成功标记,逐单结果由订单锁与状态条件更新保证
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/aggregate-pay/settle-stale")
+ R settleStaleAggregateOrders();
+
+ /**
+ * 扫描到期的自动退款任务并按任务租户恢复上下文后执行。
+ *
+ * @return 本轮扫描到并尝试执行的任务数量
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/auto-refund/retry-due")
+ R retryDueAutoRefundTasks();
+
+ /**
+ * 重试通知失败的咖啡机支付回调与退款同步缺口。
+ *
+ * @return 固定返回成功标记,失败订单按通知次数上限熔断
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/coffee-notify/retry-failures")
+ R retryCoffeeNotifyFailures();
+
+ /**
+ * 扫描到期的核销消息 Outbox 事件并投递,条件更新领取保证重复触发幂等。
+ *
+ * @return 本轮处理的事件数量
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/outbox/process-due")
+ R processOutboxDueEvents();
+
+ /**
+ * 分批清理超过保留期的终态核销消息 Outbox。
+ *
+ * @return 本轮清理的事件数量
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/outbox/cleanup")
+ R cleanupOutboxExpiredEvents();
+
+ /**
+ * 扫描待生成的持久化打印任务并补偿下发待打印任务。
+ *
+ * @return 本轮处理的打印任务数量
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/print/process-due")
+ R processPrintDueTasks();
+
+ /**
+ * 清理创建时间超过三十天的消费机日志,低于阈值时不执行删除。
+ *
+ * @return 本轮删除的日志行数,未达阈值时为零
+ */
+ @NoToken
+ @PostMapping("/hard-schedule/inner/xfj-log/cleanup")
+ R cleanupXfjLog();
+}
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
index c2d5e847..9e1cf4ac 100644
--- 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
@@ -175,9 +175,9 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
* 渲染兑换券商品卡片,数量为整数份数。
*/
@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);
+ public EscPosReceiptCommand voucherProduct(String name, String subName, int quantity, String unitPrice,
+ String subtotal, boolean combo, List componentItems) {
+ return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
@@ -185,12 +185,15 @@ public class EscPosReceiptCommand implements ReceiptRenderer {
*
* 与图片版卡片逐项对应:框线对应描边矩形;组合商品标签为 GS B 反色黑底(对应图片
* fillRect 黑底白字),单品标签为制表符描边小框(对应图片 drawRect 描边框);卡片内部
- * 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。
+ * 压缩行距让左右竖线行间衔接不断线,各区块间距按图片版像素值(1px≈1 点)走纸。
+ * subName 非空时在倍宽主名后输出单倍宽副行,对应图片版主名下方的小字号商品名。
*/
@Override
- public EscPosReceiptCommand voucherProduct(String name, String quantity, String unitPrice, String subtotal,
- boolean combo, List componentItems) {
+ public EscPosReceiptCommand voucherProduct(String name, String subName, String quantity, String unitPrice,
+ String subtotal, boolean combo, List 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 safeComponents = componentItems == null ? List.of() : componentItems.stream()
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
index c453d658..defd366e 100644
--- 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
@@ -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 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 componentItems);
/**
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 d8d52ca1..c5fc810e 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
@@ -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 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 componentItems) {
+ return voucherProduct(name, subName, "x" + Math.max(0, quantity), unitPrice, subtotal, combo, componentItems);
}
/**
* 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。
*
- * 数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。
+ * 数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。
+ * 主名保持大号粗体;subName 非空时在主名与价格行之间以正文字号渲染副行小字。
*
- * @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 componentItems) {
+ public ThermalReceiptImage voucherProduct(String name, String subName, String quantity, String unitPrice,
+ String subtotal, boolean combo, List 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 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);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java
index ce83ad1c..4a546467 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/HardApp.java
@@ -6,7 +6,6 @@ import com.bsd.pjcloud.common.swagger.annotation.EnableOpenApi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
-import org.springframework.scheduling.annotation.EnableScheduling;
/**
* @author archetype auto
@@ -17,8 +16,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnablePjcloudFeignClients
@EnableDiscoveryClient
@EnablePjcloudResourceServer
+// 周期调度已统一迁移到 daemon-quartz(sys_job 注册 hardScheduleTask,经内部端点触发),集群下避免各实例重复触发
@SpringBootApplication
-@EnableScheduling
public class HardApp {
public static void main(String[] args) {
SpringApplication.run(HardApp.class, args);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java
new file mode 100644
index 00000000..b2c69fcf
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/schedule/HardScheduleInnerController.java
@@ -0,0 +1,160 @@
+package com.bsd.pjcloud.hard.controller.schedule;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.feign.annotation.NoToken;
+import com.bsd.pjcloud.common.security.annotation.Inner;
+import com.bsd.pjcloud.hard.service.HardAggregateOrderPayCompensator;
+import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import com.bsd.pjcloud.hard.service.HardCoffeeService;
+import com.bsd.pjcloud.hard.service.HardInvoiceService;
+import com.bsd.pjcloud.hard.service.HardMessageOutboxService;
+import com.bsd.pjcloud.hard.service.HardXfjLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Hardware 补偿任务内部调度接口,供 daemon-quartz 定时任务通过 Feign 触发。
+ *
+ * 原本地 {@code @Scheduled} 任务已删除,执行逻辑平移到对应领域服务;本控制器只做触发转发,
+ * 集群重复触发由各任务内部的分布式锁、条件更新或退避门控保证幂等。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-schedule")
+@Tag(description = "Hardware 内部调度触发端点,仅限服务间调用", name = "硬件定时任务内部调度")
+public class HardScheduleInnerController {
+
+ /**
+ * 自动退款单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。
+ */
+ private static final int AUTO_REFUND_SCAN_BATCH_SIZE = 100;
+
+ /** 消费机聚合订单支付结算补偿编排器。 */
+ private final HardAggregateOrderPayCompensator aggregateOrderPayCompensator;
+
+ /** 自动退款任务状态机服务。 */
+ private final HardAutoRefundTaskService autoRefundTaskService;
+
+ /** 咖啡机订单服务。 */
+ private final HardCoffeeService hardCoffeeService;
+
+ /** 核销消息 Outbox 处理服务。 */
+ private final HardMessageOutboxService messageOutboxService;
+
+ /** 打印任务服务。 */
+ private final HardInvoiceService hardInvoiceService;
+
+ /** 消费机日志服务。 */
+ private final HardXfjLogService hardXfjLogService;
+
+ /**
+ * 高频补偿近期待结算聚合支付订单,必须携带内部调用标识。
+ *
+ * @return 成功标记;本轮是否真正扫描由实例内轮询协调器退避门控决定
+ */
+ @Operation(summary = "内部触发聚合支付近期结算补偿")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/aggregate-pay/settle-pending")
+ public R settlePendingAggregateOrders() {
+ aggregateOrderPayCompensator.settlePendingAggregateOrders();
+ return R.ok();
+ }
+
+ /**
+ * 低频核对遗留聚合支付订单并取消超时订单,必须携带内部调用标识。
+ *
+ * @return 成功标记;逐单结算与取消由订单锁和状态条件更新保证
+ */
+ @Operation(summary = "内部触发聚合支付遗留核对与超时取消")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/aggregate-pay/settle-stale")
+ public R settleStaleAggregateOrders() {
+ aggregateOrderPayCompensator.settleStaleAggregateOrders();
+ return R.ok();
+ }
+
+ /**
+ * 扫描到期自动退款任务并逐个恢复租户上下文执行,必须携带内部调用标识。
+ *
+ * @return 本轮扫描到并尝试执行的任务数量
+ */
+ @Operation(summary = "内部触发自动退款到期任务补偿")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/auto-refund/retry-due")
+ public R retryDueAutoRefundTasks() {
+ return R.ok(autoRefundTaskService.retryDueTasks(AUTO_REFUND_SCAN_BATCH_SIZE));
+ }
+
+ /**
+ * 重试咖啡机通知失败的支付回调与退款同步缺口,必须携带内部调用标识。
+ *
+ * @return 成功标记;失败订单按通知次数上限熔断后需人工介入
+ */
+ @Operation(summary = "内部触发咖啡机通知失败补偿")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/coffee-notify/retry-failures")
+ public R retryCoffeeNotifyFailures() {
+ hardCoffeeService.retryNotifyFailures();
+ return R.ok();
+ }
+
+ /**
+ * 扫描到期核销消息 Outbox 事件并投递,必须携带内部调用标识。
+ *
+ * @return 本轮处理的事件数量
+ */
+ @Operation(summary = "内部触发核销消息Outbox到期投递")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/outbox/process-due")
+ public R processOutboxDueEvents() {
+ return R.ok(messageOutboxService.processDueEvents());
+ }
+
+ /**
+ * 清理超过保留期的终态核销消息 Outbox,必须携带内部调用标识。
+ *
+ * @return 本轮清理的事件数量
+ */
+ @Operation(summary = "内部触发核销消息Outbox过期清理")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/outbox/cleanup")
+ public R cleanupOutboxExpiredEvents() {
+ return R.ok(messageOutboxService.cleanupExpiredEvents());
+ }
+
+ /**
+ * 扫描并补偿下发持久化打印任务,必须携带内部调用标识。
+ *
+ * @return 本轮处理的打印任务数量
+ */
+ @Operation(summary = "内部触发打印任务补偿扫描")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/print/process-due")
+ public R processPrintDueTasks() {
+ return R.ok(hardInvoiceService.processDueTasks());
+ }
+
+ /**
+ * 清理创建时间超过三十天的消费机日志,必须携带内部调用标识。
+ *
+ * @return 本轮删除的日志行数,累计数量未达阈值时为零
+ */
+ @Operation(summary = "内部触发消费机日志过期清理")
+ @Inner
+ @NoToken
+ @PostMapping("/inner/xfj-log/cleanup")
+ public R cleanupXfjLog() {
+ return R.ok(hardXfjLogService.cleanupExpiredLogs());
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java
deleted file mode 100644
index 9dd5bac5..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/DeleteLogJob.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.bsd.pjcloud.hard.api.entity.xfj.HardXfjLogEntity;
-import com.bsd.pjcloud.hard.mapper.xfj.HardXfjLogMapper;
-import jakarta.annotation.PostConstruct;
-import lombok.AllArgsConstructor;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-
-@Component
-@AllArgsConstructor
-public class DeleteLogJob {
-
- private final HardXfjLogMapper hardXfjLogMapper;
-
- // 一天删除一次
- @Scheduled(cron = "0 0 0 * * ?")
- public void deleteXfjLog() {
- LocalDateTime before = LocalDate.now().minusDays(30).atStartOfDay();
- // 删除30天前的数据
- Long count = hardXfjLogMapper.selectCount(new LambdaQueryWrapper()
- .le(HardXfjLogEntity::getCreateTime, before)
- );
- if (count >= 10000) {
- hardXfjLogMapper.delete(new LambdaQueryWrapper()
- .le(HardXfjLogEntity::getCreateTime, before)
- );
- }
- }
-
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
deleted file mode 100644
index 2a6ae931..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJob.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.bsd.pjcloud.common.data.tenant.TenantBroker;
-import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
-import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
-import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-
-/**
- * 自动退款任务补偿调度器。多实例允许同时扫描,订单 Redis 锁和任务行锁负责最终串行。
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class HardAutoRefundTaskJob {
-
- /**
- * 调度器触发间隔,单位毫秒,固定为 30 秒。
- */
- private static final long SCAN_INTERVAL_MILLIS = 30_000L;
-
- /**
- * 单轮跨租户最多扫描任务数量,避免配置和钱包服务突发压力。
- */
- private static final int SCAN_BATCH_SIZE = 100;
-
- /**
- * 自动退款任务服务。
- */
- private final HardAutoRefundTaskService taskService;
-
- /**
- * 每 30 秒扫描到期或暂停任务,并按任务持久化租户恢复 Feign 和数据库上下文。
- */
- @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
- public void retryDueTasks() {
- List tasks = taskService.listScanCandidates(SCAN_BATCH_SIZE);
- for (HardAutoRefundTaskEntity task : tasks) {
- if (task.getTenantId() == null) {
- log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId());
- continue;
- }
- try {
- TenantBroker.runAs(task.getTenantId(), tenantId ->
- taskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null));
- } catch (Exception exception) {
- log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(),
- exception.getClass().getSimpleName());
- }
- }
- }
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java
deleted file mode 100644
index decc9fac..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardCoffeeNotifyJob.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.bsd.pjcloud.hard.service.HardCoffeeService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-/**
- * 咖啡机补偿调度器: 重试通知失败的支付结果回调(callback)和退款同步缺口(refund)。
- * 单轮批量受限,失败订单按 wuli_notify_count 上限熔断,超过后需人工在管理端介入。
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class HardCoffeeNotifyJob {
-
- /**
- * 调度间隔,单位毫秒,固定为 60 秒
- */
- private static final long SCAN_INTERVAL_MILLIS = 60_000L;
-
- private final HardCoffeeService hardCoffeeService;
-
- /**
- * 定时扫描通知失败与退款同步缺口的咖啡机订单
- */
- @Scheduled(fixedDelay = SCAN_INTERVAL_MILLIS, initialDelay = SCAN_INTERVAL_MILLIS)
- public void retryNotifyFailures() {
- try {
- hardCoffeeService.retryNotifyFailures();
- } catch (Exception e) {
- log.error("咖啡机补偿调度异常", e);
- }
- }
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java
deleted file mode 100644
index 784aa8a2..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardMessageOutboxJob.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.bsd.pjcloud.hard.service.HardMessageOutboxService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-/**
- * Hardware核销消息Outbox调度器,周期触发投递恢复和保留期清理。
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class HardMessageOutboxJob {
-
- /** Hardware核销Outbox处理服务。 */
- private final HardMessageOutboxService outboxService;
-
- /**
- * 每十秒扫描一次到期事件,实际并发领取由Outbox条件更新控制。
- */
- @Scheduled(fixedDelay = 10000L, initialDelay = 15000L)
- public void processDueEvents() {
- try {
- int processed = outboxService.processDueEvents();
- if (processed > 0) {
- log.info("核销消息Outbox扫描完成,processed:{}", processed);
- }
- } catch (Exception exception) {
- log.error("核销消息Outbox扫描异常", exception);
- }
- }
-
- /**
- * 每日凌晨分批清理超过保留期的终态Outbox。
- */
- @Scheduled(cron = "0 30 2 * * ?")
- public void cleanupExpiredEvents() {
- try {
- int deleted = outboxService.cleanupExpiredEvents();
- if (deleted > 0) {
- log.info("核销消息Outbox到期清理完成,deleted:{}", deleted);
- }
- } catch (Exception exception) {
- log.error("核销消息Outbox到期清理异常", exception);
- }
- }
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java
deleted file mode 100644
index 449a6f65..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardPrintTaskJob.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.bsd.pjcloud.hard.service.HardInvoiceService;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-
-/**
- * 持久化打印任务补偿调度器,恢复进程退出或 MQTT 离线期间遗留的任务。
- */
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class HardPrintTaskJob {
-
- /** 打印任务服务。 */
- private final HardInvoiceService hardInvoiceService;
-
- /**
- * 每十秒扫描待生成任务,并按固定间隔补偿下发待打印任务。
- */
- @Scheduled(fixedDelay = 10000L, initialDelay = 20000L)
- public void processDueTasks() {
- try {
- int processed = hardInvoiceService.processDueTasks();
- if (processed > 0) log.info("打印任务补偿扫描完成,processed:{}", processed);
- } catch (Exception exception) {
- log.error("打印任务补偿扫描异常", exception);
- }
- }
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java
similarity index 80%
rename from pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java
rename to pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java
index 5d601c53..2da9af6f 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJob.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensator.java
@@ -1,4 +1,4 @@
-package com.bsd.pjcloud.hard.job;
+package com.bsd.pjcloud.hard.service;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -6,26 +6,27 @@ import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
-import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
-import com.bsd.pjcloud.hard.service.HardOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
- * 消费机聚合订单码支付业务结算补偿任务。
+ * 消费机聚合订单码支付业务结算补偿编排器(原 HardAggregateOrderPayJob 逻辑平移)。
*
- * 终端主动轮询仍是实时结算入口;本任务负责覆盖收银弹窗提前关闭、终端断网或进程退出后,
- * 第三方已经支付但 Hardware 业务订单尚未结算的场景。
+ * 终端主动轮询仍是实时结算入口;本编排器负责覆盖收银弹窗提前关闭、终端断网或进程退出后,
+ * 第三方已经支付但 Hardware 业务订单尚未结算的场景。由 daemon-quartz 经
+ * HardScheduleInnerController 内部端点触发,不再依赖本地 {@code @Scheduled}。
+ *
+ * 逐单结算必须经 {@link HardOrderService} 代理调用(目标方法带 {@code @Lock4j} 与事务),
+ * 因此编排逻辑独立成组件而不并入 HardOrderServiceImpl,避免自调用丢失代理语义。
*/
-@Component
@Slf4j
+@Component
@RequiredArgsConstructor
-public class HardAggregateOrderPayJob {
+public class HardAggregateOrderPayCompensator {
/**
* 单轮最多处理的待结算订单数,避免跨服务查单长期占用调度线程。
@@ -37,16 +38,6 @@ public class HardAggregateOrderPayJob {
*/
private static final long ACTIVE_ORDER_WINDOW_MINUTES = 15L;
- /**
- * 超出近期窗口但尚未达到自动取消时间的订单低频核对间隔,单位毫秒。
- */
- private static final long STALE_RECONCILIATION_INTERVAL_MILLIS = 300000L;
-
- /**
- * 服务启动后首次核对遗留订单的等待时间,单位毫秒,避免与启动恢复扫描同时争用资源。
- */
- private static final long STALE_INITIAL_DELAY_MILLIS = 60000L;
-
/**
* Hardware 订单服务,复用带订单锁和事务的聚合支付状态确认及结算流程。
*/
@@ -60,12 +51,10 @@ public class HardAggregateOrderPayJob {
/**
* 高频补偿最近十五分钟内仍处于创建状态且已绑定聚合会话的 Hardware 订单。
*
- * 调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文;
- * 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。方法每五秒轻量触发一次,连续空结果时
- * 真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟。
+ * daemon-quartz 每五秒触发一次内部端点调用本方法;调度线程无租户上下文时先跨租户读取候选记录,
+ * 再按记录持久化的租户 ID 恢复上下文;具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。
+ * 连续空结果时真正的数据库扫描按十秒、二十秒、四十秒退避到最长一分钟,未到扫描时间的实例直接返回。
*/
- @Scheduled(fixedDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS,
- initialDelay = HardAggregatePayPollingCoordinator.TRIGGER_INTERVAL_MILLIS)
public void settlePendingAggregateOrders() {
if (!pollingCoordinator.tryStartPolling()) {
return;
@@ -86,11 +75,11 @@ public class HardAggregateOrderPayJob {
/**
* 低频核对十五分钟至一小时内的遗留订单,并取消超过一小时的订单。
*
- * 一小时以内仍保留服务异常恢复能力;超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态
+ *
daemon-quartz 每五分钟触发一次内部端点调用本方法。一小时以内仍保留服务异常恢复能力;
+ * 超过一小时后不再调用 Thirdparty 查单,而是把仍处于创建状态
* 的 Hardware 订单原子更新为未支付取消,避免异常记录被永久轮询。第三方若在此后报告延迟支付,
* 由线下渠道核对和协商,不再自动改变 Hardware 库存及订单状态。
*/
- @Scheduled(fixedDelay = STALE_RECONCILIATION_INTERVAL_MILLIS, initialDelay = STALE_INITIAL_DELAY_MILLIS)
public void settleStaleAggregateOrders() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime activeSince = now.minusMinutes(ACTIVE_ORDER_WINDOW_MINUTES);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
index 6e6e0fa0..e462a585 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
@@ -153,4 +153,15 @@ public interface HardAutoRefundTaskService extends IService listScanCandidates(int limit);
+
+ /**
+ * daemon-quartz 定时补偿入口:跨租户扫描到期或暂停任务,并按任务持久化租户恢复上下文后逐个执行。
+ *
+ * 多实例或集群重复触发安全,订单 Redis 锁和任务行锁负责最终串行;单条执行失败只记日志,
+ * 不中断其余任务,也不使整轮补偿判定为失败。
+ *
+ * @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力
+ * @return 本轮扫描到并尝试执行的任务数量
+ */
+ int retryDueTasks(int limit);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java
index d613c4e7..ccbd5ec9 100755
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardXfjLogService.java
@@ -10,4 +10,13 @@ public interface HardXfjLogService extends IService {
Page pageList(Page page, HardXfjLogDto dto);
+ /**
+ * 清理创建时间超过三十天的消费机日志(原 DeleteLogJob 逻辑平移)。
+ *
+ * 超期行数累计不足一万条时跳过删除,避免天天执行小批量清理;由 daemon-quartz 每日零点
+ * 经 HardScheduleInnerController 内部端点触发,条件删除重复执行安全。
+ *
+ * @return 本轮删除的日志行数,未达删除阈值时为零
+ */
+ int cleanupExpiredLogs();
}
\ No newline at end of file
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 951408f3..bec7e874 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
@@ -293,7 +293,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl printNames = loadPrintProductNames(carList);
+ Map printNames = loadPrintProductNames(carList);
appendInvoiceProducts(renderer, carList, metadata, showComboItems, printNames);
if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
renderer.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
@@ -330,8 +330,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时按套餐名优先解析,
- * 套餐标签与组合内容沿用兑换券元数据和显示开关。
+ * 发票是支付凭证,小计使用下单时的商品快照总额;商品名称打印时实时解析为
+ * 套餐名主行加商品名小字副行,套餐标签与组合内容沿用兑换券元数据和显示开关。
*
* @param renderer 当前渲染器
* @param cars 订单商品快照
@@ -341,7 +341,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl cars,
Map metadata, boolean showComboItems,
- Map printNames) {
+ Map printNames) {
renderer.divider().sectionTitle("商品明细");
boolean hasProduct = false;
for (HardCarVo car : cars) {
@@ -351,8 +351,9 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems = combo && showComboItems
? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
- renderer.voucherProduct(resolvePrintName(car, printNames), invoiceQuantityText(car), money(voucherUnitPrice(car)),
- money(car.getTotalPrice()), combo, comboItems);
+ PrintProductName printName = resolvePrintName(car, printNames);
+ renderer.voucherProduct(printName.mainName(), printName.subName(), invoiceQuantityText(car),
+ money(voucherUnitPrice(car)), money(car.getTotalPrice()), combo, comboItems);
}
if (!hasProduct) renderer.item("无商品明细", "x0", false, false);
}
@@ -951,7 +952,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl printNames = loadPrintProductNames(allCars);
+ Map printNames = loadPrintProductNames(allCars);
List result = new ArrayList<>(voucherOrders.size());
for (int index = 0; index < voucherOrders.size(); index++) {
HardOrderEntity voucherOrder = voucherOrders.get(index);
@@ -964,7 +965,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl products) {
for (VoucherProductLine product : products) {
- renderer.voucherProduct(product.productName(), product.quantity(), money(product.unitPrice()),
- money(product.subtotal()), product.combo(), voucherComponentItems(product));
+ renderer.voucherProduct(printMainName(product), printSubName(product), product.quantity(),
+ money(product.unitPrice()), money(product.subtotal()), product.combo(),
+ voucherComponentItems(product));
}
}
+ /**
+ * 计算商品行主名:套餐名优先,为空时商品名沿用主行大字样式。
+ *
+ * @param product 当前兑换券商品
+ * @return 卡片主行名称
+ */
+ private static String printMainName(VoucherProductLine product) {
+ return StrUtil.isNotBlank(product.comboName()) ? product.comboName() : product.name();
+ }
+
+ /**
+ * 计算商品行副名:仅有套餐名且与商品名不同时输出商品名小字副行,相同则不重复显示。
+ *
+ * @param product 当前兑换券商品
+ * @return 卡片副行名称;无需副行时返回 null
+ */
+ private static String printSubName(VoucherProductLine product) {
+ return StrUtil.isNotBlank(product.comboName()) && !product.comboName().equals(product.name())
+ ? product.name() : null;
+ }
+
/**
* 将套餐组成快照转换为票据说明,并按套餐商品的剩余总份数放大组成数量。
*
@@ -1053,7 +1077,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl商品 ID、快照名称、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
+ * 商品 ID、套餐名、商品名、单价和套餐类型共同作为合并键,避免同一菜品改价或改名后被错误归并;
* 历史商品 ID 为空时,自然按名称、单价和套餐类型合并。数量与小计均使用各原始行求和。
*
* @param orderProducts 按订单分组的商品快照
@@ -1063,8 +1087,8 @@ public class HardInvoiceServiceImpl extends ServiceImpl merged = new LinkedHashMap<>();
for (VoucherOrderProducts group : orderProducts) {
for (VoucherProductLine product : group.products()) {
- VoucherProductKey key = new VoucherProductKey(product.productId(), product.productName(),
- product.unitPrice(), product.combo());
+ VoucherProductKey key = new VoucherProductKey(product.productId(), product.comboName(),
+ product.name(), product.unitPrice(), product.combo());
VoucherProductLine current = merged.get(key);
if (current == null) {
merged.put(key, product);
@@ -1072,7 +1096,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems = current.comboItems().isEmpty()
? product.comboItems() : current.comboItems();
- merged.put(key, new VoucherProductLine(current.productId(), current.productName(),
+ merged.put(key, new VoucherProductLine(current.productId(), current.comboName(), current.name(),
current.quantity() + product.quantity(), current.unitPrice(),
current.subtotal() + product.subtotal(), current.combo(), comboItems));
}
@@ -1210,27 +1234,27 @@ public class HardInvoiceServiceImpl extends ServiceImpl商品 ID 兼容 canteen_spu.id(线下/每日菜谱单)和 canteen_online_spu.id(App 线上预约单,
* 服务端经 linkSpuId 关联取名);非食堂商品、已删除商品或名称全为空的记录不进入结果,
* 调用方降级购物车快照名。打印是核销后的关键闭环,查询失败时记录警告并整体降级,不阻断出票。
*
* @param cars 票据涉及的商品快照
- * @return 按商品 ID 索引的打印名称;查询失败时返回空 Map
+ * @return 按商品 ID 索引的双名结构;查询失败时返回空 Map
*/
- private Map loadPrintProductNames(List cars) {
+ private Map loadPrintProductNames(List cars) {
List productIds = cars.stream().map(HardCarVo::getProductId).filter(Objects::nonNull).distinct().toList();
if (productIds.isEmpty()) return Map.of();
try {
R> result = remoteCanteenSpuService.getInnerSpuPrintNames(productIds);
if (result == null || !result.isOk() || result.getData() == null) return Map.of();
- Map names = new LinkedHashMap<>();
+ Map names = new LinkedHashMap<>();
for (SpuPrintNameVo vo : result.getData()) {
if (vo == null || vo.getProductId() == null) continue;
- String comboName = StrUtil.trimToNull(vo.getComboName());
- String printName = comboName != null ? comboName : StrUtil.trimToNull(vo.getName());
- if (printName != null) names.put(vo.getProductId(), printName);
+ PrintProductName printName = new PrintProductName(
+ StrUtil.trimToNull(vo.getComboName()), StrUtil.trimToNull(vo.getName()));
+ if (printName.mainName() != null) names.put(vo.getProductId(), printName);
}
return names;
} catch (Exception exception) {
@@ -1240,15 +1264,41 @@ public class HardInvoiceServiceImpl extends ServiceImpl printNames) {
- if (car.getProductId() == null) return car.getProductName();
- return printNames.getOrDefault(car.getProductId(), car.getProductName());
+ private static PrintProductName resolvePrintName(HardCarVo car, Map printNames) {
+ PrintProductName live = car.getProductId() == null ? null : printNames.get(car.getProductId());
+ return live != null ? live : new PrintProductName(null, StrUtil.trimToNull(car.getProductName()));
+ }
+
+ /**
+ * 打印凭证商品名称:套餐名(comboName)优先、商品名(name)兜底。
+ *
+ * 主行取套餐名,套餐名为空时商品名沿用主行大字样式;副行仅在有套餐名且与商品名不同时
+ * 输出商品名小字,两个名称完全相同则不重复显示。
+ *
+ * @param comboName 套餐名;可为空
+ * @param name 商品名称;可为空
+ */
+ private record PrintProductName(String comboName, String name) {
+
+ /**
+ * @return 卡片主行名称;两者皆空时返回 null,由渲染器兜底“未命名商品”
+ */
+ String mainName() {
+ return comboName != null ? comboName : name;
+ }
+
+ /**
+ * @return 主行下方的小字副行名称;无套餐名或与商品名相同时返回 null
+ */
+ String subName() {
+ return comboName != null && !comboName.equals(name) ? name : null;
+ }
}
/**
@@ -1484,26 +1534,28 @@ public class HardInvoiceServiceImpl extends ServiceImpl comboItems) {
+ record VoucherProductLine(Long productId, String comboName, String name, int quantity, long unitPrice,
+ long subtotal, boolean combo, List comboItems) {
}
/**
* 多订单商品合并键。
*
* @param productId 商品主键;为空时依靠其余快照字段区分
- * @param productName 商品名称快照
+ * @param comboName 套餐名;为空时依靠商品名区分
+ * @param name 商品名称
* @param unitPrice 商品单价快照,单位分
* @param combo 是否为组合商品
*/
- private record VoucherProductKey(Long productId, String productName, long unitPrice, boolean combo) {
+ private record VoucherProductKey(Long productId, String comboName, String name, long unitPrice, boolean combo) {
}
/**
@@ -1578,18 +1630,18 @@ public class HardInvoiceServiceImpl extends ServiceImpl componentItems) {
- image.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
- command.voucherProduct(name, quantity, unitPrice, subtotal, combo, componentItems);
+ public ReceiptRenderer voucherProduct(String name, String subName, int quantity, String unitPrice,
+ String subtotal, boolean combo, List componentItems) {
+ image.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
+ command.voucherProduct(name, subName, 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);
+ public ReceiptRenderer voucherProduct(String name, String subName, String quantity, String unitPrice,
+ String subtotal, boolean combo, List componentItems) {
+ image.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
+ command.voucherProduct(name, subName, quantity, unitPrice, subtotal, combo, componentItems);
return this;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java
index 11895dc8..35d1093a 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
@@ -1586,6 +1587,35 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl本方法不开启事务,逐条执行的锁与事务边界由 {@link #execute} 及其内部的
+ * {@code getSelf()} 代理调用自行管理;集群重复触发由订单 Redis 锁和任务行锁最终串行。
+ *
+ * @param limit 单轮最多扫描任务数量,避免配置和钱包服务突发压力
+ * @return 本轮扫描到并尝试执行的任务数量
+ */
+ @Override
+ public int retryDueTasks(int limit) {
+ List tasks = listScanCandidates(limit);
+ for (HardAutoRefundTaskEntity task : tasks) {
+ if (task.getTenantId() == null) {
+ log.warn("自动退款任务缺少租户ID,taskId:{}", task.getId());
+ continue;
+ }
+ try {
+ TenantBroker.runAs(task.getTenantId(), tenantId ->
+ execute(task.getId(), HardAutoRefundTriggerTypeEnum.SCHEDULED, null));
+ } catch (Exception exception) {
+ log.warn("自动退款调度执行异常,taskId:{},tenantId:{},exceptionType:{}", task.getId(), task.getTenantId(),
+ exception.getClass().getSimpleName());
+ }
+ }
+ return tasks.size();
+ }
+
/**
* 构造 Wallet 和尝试日志共同使用的稳定退款业务号。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java
index 068d4089..5bf43202 100755
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/xfj/HardXfjLogServiceImpl.java
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.service.impl.xfj;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.hard.api.dto.xfj.HardXfjLogDto;
@@ -12,6 +13,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
/**
* 消费机日志
*/
@@ -26,4 +30,24 @@ public class HardXfjLogServiceImpl extends ServiceImpl先统计超期行数,累计不足一万条时跳过删除,避免每天小批量清理;达到阈值后按同一时间条件
+ * 物理删除。统计与删除之间存在窗口,行数略有出入不影响正确性;重复触发时第二次删除自然为空。
+ *
+ * @return 本轮删除的日志行数,未达删除阈值时为零
+ */
+ @Override
+ public int cleanupExpiredLogs() {
+ LocalDateTime before = LocalDate.now().minusDays(30).atStartOfDay();
+ // 删除30天前的数据;低于阈值时直接跳过
+ Long count = count(new LambdaQueryWrapper().le(HardXfjLogEntity::getCreateTime, before));
+ if (count >= 10000) {
+ return baseMapper.delete(new LambdaQueryWrapper()
+ .le(HardXfjLogEntity::getCreateTime, before));
+ }
+ return 0;
+ }
+
+}
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
index 16047ea5..0055333b 100644
--- 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
@@ -35,7 +35,7 @@ class EscPosReceiptCommandTest {
.title("预约核销兑换券")
.banner("第 1 次打印 · 2 笔预约")
.keyValue("商户", "青鸟同文")
- .voucherProduct("A套餐-糖醋排骨", 2, "12.00", "24.00", true, List.of("糖醋排骨 x2"))
+ .voucherProduct("A套餐-糖醋排骨", null, 2, "12.00", "24.00", true, List.of("糖醋排骨 x2"))
.voucherTotal("24.00")
.toBase64();
byte[] bytes = java.util.Base64.getDecoder().decode(base64);
@@ -167,7 +167,7 @@ class EscPosReceiptCommandTest {
@Test
void voucherProductRendersBoxFrameWithReverseTypeTag() {
byte[] bytes = decode(new EscPosReceiptCommand()
- .voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
+ .voucherProduct("A套餐-糖醋排骨", null, "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2")));
List lines = visibleLines(bytes);
assertTrue(contains(bytes, 0x1D, 0x42, 1), "组合类型标签应使用 GS B 反色指令");
@@ -185,6 +185,40 @@ class EscPosReceiptCommandTest {
assertTrue(lines.stream().anyMatch(line -> line.contains("组合内容")), "组合内容标题缺失");
}
+ /** 套餐名与商品名双行:主名保持倍宽加粗,副行商品名单倍宽输出在价格行之前。 */
+ @Test
+ void voucherProductSubNameRendersPlainLineInsideBox() {
+ byte[] bytes = decode(new EscPosReceiptCommand()
+ .voucherProduct("A套餐", "A套餐-红烧肉", "x2", "12.00", "24.00", true, List.of()));
+ List lines = visibleLines(bytes);
+
+ assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "主名仍应倍宽放大");
+ assertTrue(lines.stream().anyMatch(line -> line.contains("A套餐") && line.startsWith("│")),
+ "倍宽主名应在卡片框内");
+ int mainIndex = -1;
+ int subIndex = -1;
+ for (int index = 0; index < lines.size(); index++) {
+ if (lines.get(index).contains("红烧肉")) subIndex = index;
+ if (lines.get(index).contains("单价")) mainIndex = index;
+ }
+ assertTrue(subIndex >= 0, "副行商品名必须输出");
+ assertTrue(mainIndex > subIndex, "副行应位于主名与价格行之间");
+ assertTrue(lines.get(subIndex).startsWith("│") && lines.get(subIndex).endsWith("│"),
+ "副行应在卡片框内以单倍宽展示");
+ }
+
+ /** 无套餐名时商品名沿用倍宽主名样式,不输出多余副行。 */
+ @Test
+ void voucherProductWithoutSubNameKeepsSingleScaledName() {
+ byte[] bytes = decode(new EscPosReceiptCommand()
+ .voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of()));
+ List lines = visibleLines(bytes);
+
+ assertTrue(contains(bytes, 0x1D, 0x21, 0x11), "商品名应保持倍宽主名样式");
+ assertEquals(1, lines.stream().filter(line -> line.contains("红糖发糕")).count(),
+ "无副行时商品名只出现一次");
+ }
+
/** 显示宽度与折行:中文 2 列、ASCII 1 列,单个超宽字符仍独占一行。 */
@Test
void displayWidthAndWrapFollowChineseAwareColumns() {
@@ -214,8 +248,8 @@ class EscPosReceiptCommandTest {
.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())
+ .voucherProduct("A套餐-糖醋排骨", null, "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
+ .voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of())
.keyValue("额外支付金额", "¥0.50")
.voucherTotal("18.60")
.keyValue("打印时间", "2026-09-14 12:03:05");
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
index 25b32d58..df6ab687 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/api/utils/ThermalReceiptImageTest.java
@@ -39,7 +39,7 @@ class ThermalReceiptImageTest {
.title("发票").invoiceItem("独立单品", "2", "¥12.00").toPngBytes()));
BufferedImage voucher = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("兑换券").banner("第 1 次打印")
- .voucherProduct("独立单品", 2, "6.00", "12.00", false, List.of()).toPngBytes()));
+ .voucherProduct("独立单品", null, 2, "6.00", "12.00", false, List.of()).toPngBytes()));
assertEquals(576, invoice.getWidth());
assertEquals(576, voucher.getWidth());
@@ -65,9 +65,9 @@ class ThermalReceiptImageTest {
String longComponentName = "组合内容中的超长菜品名称".repeat(10) + " x12";
ThermalReceiptImage receipt = new ThermalReceiptImage()
.title("预约核销兑换券")
- .voucherProduct(longProductName, 2, "12.00", "24.00", true,
+ .voucherProduct(longProductName, null, 2, "12.00", "24.00", true,
List.of(longComponentName, "红糖发糕 x4"))
- .voucherProduct("独立单品", 3, "2.50", "7.50", false, List.of())
+ .voucherProduct("独立单品", null, 3, "2.50", "7.50", false, List.of())
.voucherTotal("31.50");
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
@@ -84,8 +84,8 @@ class ThermalReceiptImageTest {
void invoiceProductCardAcceptsWeightQuantityText() throws Exception {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
.title("智慧食堂发票")
- .voucherProduct("精品肋排", "0.5/kg", "36.00", "18.00", false, List.of())
- .voucherProduct("A套餐-糖醋排骨", "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2"))
+ .voucherProduct("精品肋排", null, "0.5/kg", "36.00", "18.00", false, List.of())
+ .voucherProduct("A套餐-糖醋排骨", null, "x2", "12.00", "24.00", true, List.of("糖醋排骨 x2"))
.voucherTotal("42.00").toPngBytes()));
assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
@@ -113,9 +113,9 @@ class ThermalReceiptImageTest {
@Test
void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
BufferedImage combo = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
- .voucherProduct("测试商品", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+ .voucherProduct("测试商品", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
- .voucherProduct("测试商品", 1, "10.00", "10.00", false, List.of()).toPngBytes()));
+ .voucherProduct("测试商品", null, 1, "10.00", "10.00", false, List.of()).toPngBytes()));
assertTrue(countDarkPixels(combo) > countDarkPixels(single),
"组合商品的黑底标签和粗边框应产生更多深色像素");
@@ -125,15 +125,27 @@ class ThermalReceiptImageTest {
@Test
void comboProductCardOmitsEntireComponentSectionWhenItemsAreHidden() throws Exception {
BufferedImage hidden = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
- .voucherProduct("测试套餐", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+ .voucherProduct("测试套餐", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
BufferedImage visible = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
- .voucherProduct("测试套餐", 1, "10.00", "10.00", true,
+ .voucherProduct("测试套餐", null, 1, "10.00", "10.00", true,
List.of("糖醋排骨 x1", "红糖发糕 x1")).toPngBytes()));
assertTrue(hidden.getHeight() < visible.getHeight(),
"隐藏套餐组成时不应保留分隔线、组合内容标题或明细列表的空白高度");
}
+ /** 套餐名与商品名双行显示:主名保持大字,副行商品名以小字增加卡片高度。 */
+ @Test
+ void voucherProductSubNameRendersExtraSmallLine() throws Exception {
+ BufferedImage single = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("A套餐", null, 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+ BufferedImage dual = ImageIO.read(new ByteArrayInputStream(new ThermalReceiptImage()
+ .voucherProduct("A套餐", "A套餐-红烧肉", 1, "10.00", "10.00", true, List.of()).toPngBytes()));
+
+ assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, dual.getWidth());
+ assertTrue(dual.getHeight() > single.getHeight(), "副行商品名小字应增加卡片高度");
+ }
+
/** 多订单合并版式应完整绘制每笔订单摘要和一份合并商品区。 */
@Test
void mergedVoucherLayoutDrawsCompactOrderSummariesAndProducts() throws Exception {
@@ -145,7 +157,7 @@ class ThermalReceiptImageTest {
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
.divider()
.sectionTitle("合并商品明细")
- .voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
+ .voucherProduct("A套餐-糖醋排骨", null, 3, "12.00", "36.00", true, List.of())
.voucherTotal("36.00");
BufferedImage image = ImageIO.read(new ByteArrayInputStream(receipt.toPngBytes()));
@@ -179,8 +191,8 @@ class ThermalReceiptImageTest {
.keyValue("预约日期", "2026-09-07")
.keyValue("餐段", "午餐")
.sectionTitle("剩余可履约商品")
- .voucherProduct("A套餐-糖醋排骨", 1, "12.00", "12.00", true, List.of())
- .voucherProduct("红糖发糕", 1, "3.00", "3.00", false, List.of());
+ .voucherProduct("A套餐-糖醋排骨", null, 1, "12.00", "12.00", true, List.of())
+ .voucherProduct("红糖发糕", null, 1, "3.00", "3.00", false, List.of());
}
ThermalReceiptImage merged = new ThermalReceiptImage().title("预约核销兑换券")
.divider().sectionTitle("订单信息(3笔)")
@@ -188,8 +200,8 @@ class ThermalReceiptImageTest {
.voucherOrderSummary(2, 3, "2096780000000000002", "2026-09-07", "午餐")
.voucherOrderSummary(3, 3, "2096780000000000003", "2026-09-07", "午餐")
.divider().sectionTitle("合并商品明细")
- .voucherProduct("A套餐-糖醋排骨", 3, "12.00", "36.00", true, List.of())
- .voucherProduct("红糖发糕", 3, "3.00", "9.00", false, List.of());
+ .voucherProduct("A套餐-糖醋排骨", null, 3, "12.00", "36.00", true, List.of())
+ .voucherProduct("红糖发糕", null, 3, "3.00", "9.00", false, List.of());
BufferedImage groupedImage = ImageIO.read(new ByteArrayInputStream(grouped.toPngBytes()));
BufferedImage mergedImage = ImageIO.read(new ByteArrayInputStream(merged.toPngBytes()));
@@ -216,8 +228,8 @@ class ThermalReceiptImageTest {
.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())
+ .voucherProduct("A套餐-糖醋排骨", null, "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
+ .voucherProduct("红糖发糕", null, "x1", "1.60", "1.60", false, List.of())
.keyValue("额外支付金额", "¥0.50")
.voucherTotal("18.60")
.keyValue("打印时间", "2026-09-14 12:03:05");
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
deleted file mode 100644
index 3c743815..00000000
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAutoRefundTaskJobTest.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.bsd.pjcloud.hard.job;
-
-import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
-import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
-import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
-import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * 自动退款补偿调度器的批量上限和租户上下文恢复测试。
- */
-@ExtendWith(MockitoExtension.class)
-class HardAutoRefundTaskJobTest {
-
- /**
- * 自动退款任务服务 Mock。
- */
- @Mock
- private HardAutoRefundTaskService taskService;
-
- /**
- * 被测自动退款调度器。
- */
- @InjectMocks
- private HardAutoRefundTaskJob job;
-
- /**
- * 清理测试线程租户上下文,避免影响同进程其他用例。
- */
- @AfterEach
- void tearDown() {
- TenantContextHolder.setTenantId(null);
- }
-
- /**
- * 单轮应最多请求一百条任务,并在任务租户上下文中触发定时重试。
- */
- @Test
- void scheduledScanShouldRestoreTaskTenantBeforeExecution() {
- HardAutoRefundTaskEntity task = new HardAutoRefundTaskEntity();
- task.setId(10L);
- task.setTenantId(66L);
- when(taskService.listScanCandidates(100)).thenReturn(List.of(task));
-
- job.retryDueTasks();
-
- verify(taskService).listScanCandidates(100);
- verify(taskService).execute(10L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
- assertNull(TenantContextHolder.getTenantId());
- }
-}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java
similarity index 82%
rename from pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java
rename to pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java
index 0294a7e8..f72ebcca 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/job/HardAggregateOrderPayJobTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/HardAggregateOrderPayCompensatorTest.java
@@ -1,12 +1,10 @@
-package com.bsd.pjcloud.hard.job;
+package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
-import com.bsd.pjcloud.hard.service.HardAggregatePayPollingCoordinator;
-import com.bsd.pjcloud.hard.service.HardOrderService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -24,10 +22,10 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
- * Hardware 聚合订单支付补偿任务测试。
+ * Hardware 聚合订单支付结算补偿编排器测试(由原 HardAggregateOrderPayJobTest 平移)。
*/
@ExtendWith(MockitoExtension.class)
-class HardAggregateOrderPayJobTest {
+class HardAggregateOrderPayCompensatorTest {
/**
* Hardware 订单服务 Mock,用于返回历史订单并验证带锁取消入口。
@@ -42,10 +40,10 @@ class HardAggregateOrderPayJobTest {
private HardAggregatePayPollingCoordinator pollingCoordinator;
/**
- * 被测 Hardware 聚合订单支付补偿任务。
+ * 被测 Hardware 聚合订单支付结算补偿编排器。
*/
@InjectMocks
- private HardAggregateOrderPayJob job;
+ private HardAggregateOrderPayCompensator compensator;
/**
* 清理测试期间由 TenantBroker 设置的线程租户上下文。
@@ -59,7 +57,7 @@ class HardAggregateOrderPayJobTest {
* 验证超过一小时的订单只进入带锁取消,不再调用 Thirdparty 状态查询流程。
*/
@Test
- void staleJobShouldCancelOrderOlderThanOneHourWithoutQueryingPaymentStatus() {
+ void staleCompensationShouldCancelOrderOlderThanOneHourWithoutQueryingPaymentStatus() {
HardOrderEntity order = new HardOrderEntity();
order.setId(101L);
order.setTenantId(66L);
@@ -69,7 +67,7 @@ class HardAggregateOrderPayJobTest {
order.setCreateTime(LocalDateTime.now().minusHours(2));
when(hardOrderService.list(any(Wrapper.class))).thenReturn(List.of(order), List.of());
- job.settleStaleAggregateOrders();
+ compensator.settleStaleAggregateOrders();
verify(hardOrderService).cancelExpiredAggregateOrder(eq(order.getId()), any(LocalDateTime.class));
verify(hardOrderService, never()).getAggregateOrderCodePayStatus(any(), any());
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
index 09839263..703365d9 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardInvoiceServiceImplTest.java
@@ -36,12 +36,14 @@ import org.mockito.ArgumentCaptor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
@@ -426,16 +428,16 @@ class HardInvoiceServiceImplTest {
component.setName("糖醋排骨");
component.setQuantity(1);
HardInvoiceServiceImpl.VoucherProductLine firstCombo =
- new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1200L, 1200L,
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 1, 1200L, 1200L,
true, List.of(component));
HardInvoiceServiceImpl.VoucherProductLine single =
- new HardInvoiceServiceImpl.VoucherProductLine(601L, "红糖发糕", 1, 300L, 300L,
+ new HardInvoiceServiceImpl.VoucherProductLine(601L, null, "红糖发糕", 1, 300L, 300L,
false, List.of());
HardInvoiceServiceImpl.VoucherProductLine repeatedCombo =
- new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 2, 1200L, 2400L,
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 2, 1200L, 2400L,
true, List.of(component));
HardInvoiceServiceImpl.VoucherProductLine changedPrice =
- new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", 1, 1300L, 1300L,
+ new HardInvoiceServiceImpl.VoucherProductLine(501L, "A套餐", "A套餐-红烧肉", 1, 1300L, 1300L,
true, List.of(component));
HardOrderEntity firstOrder = order();
HardOrderEntity secondOrder = order();
@@ -447,8 +449,9 @@ class HardInvoiceServiceImplTest {
List merged =
HardInvoiceServiceImpl.mergeVoucherProducts(orders);
- assertEquals(List.of("A套餐", "红糖发糕", "A套餐"),
- merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::productName).toList());
+ assertEquals(Arrays.asList("A套餐", null, "A套餐"),
+ merged.stream().map(HardInvoiceServiceImpl.VoucherProductLine::comboName).toList());
+ assertEquals("红糖发糕", merged.get(1).name());
assertEquals(3, merged.get(0).quantity());
assertEquals(3600L, merged.get(0).subtotal());
assertSame(component, merged.get(0).comboItems().get(0));
@@ -456,21 +459,25 @@ class HardInvoiceServiceImplTest {
assertEquals(1300L, merged.get(2).unitPrice());
}
- /** 打印商品名称必须套餐名优先,且两侧空白被去除后才参与展示。 */
+ /** 打印商品名称必须套餐名、商品名分开保留,且两侧空白被去除后才参与展示。 */
@Test
- void printProductNamesPreferTrimmedComboNameOverName() {
+ void printProductNamesKeepTrimmedComboAndPlainName() {
HardCarVo car = new HardCarVo();
car.setProductId(501L);
when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(501L))).thenReturn(R.ok(List.of(
- SpuPrintNameVo.builder().productId(501L).comboName(" 15元套餐 ").name("15元套餐-红烧肉").build())));
+ SpuPrintNameVo.builder().productId(501L).comboName(" 15元套餐 ").name(" 15元套餐-红烧肉 ").build())));
@SuppressWarnings("unchecked")
- Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
- assertEquals(Map.of(501L, "15元套餐"), names);
+ Object printName = names.get(501L);
+ assertEquals("15元套餐", ReflectionTestUtils.invokeMethod(printName, "comboName"));
+ assertEquals("15元套餐-红烧肉", ReflectionTestUtils.invokeMethod(printName, "name"));
+ assertEquals("15元套餐", ReflectionTestUtils.invokeMethod(printName, "mainName"));
+ assertEquals("15元套餐-红烧肉", ReflectionTestUtils.invokeMethod(printName, "subName"));
}
- /** 套餐名为空串(非套餐商品保存时被清空)时必须降级商品名。 */
+ /** 套餐名为空串(非套餐商品保存时被清空)时主名必须降级商品名且不输出副行。 */
@Test
void printProductNamesFallBackToNameWhenComboBlank() {
HardCarVo car = new HardCarVo();
@@ -479,9 +486,28 @@ class HardInvoiceServiceImplTest {
SpuPrintNameVo.builder().productId(502L).comboName("").name("糖醋排骨").build())));
@SuppressWarnings("unchecked")
- Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
- assertEquals(Map.of(502L, "糖醋排骨"), names);
+ Object printName = names.get(502L);
+ assertNull(ReflectionTestUtils.invokeMethod(printName, "comboName"));
+ assertEquals("糖醋排骨", ReflectionTestUtils.invokeMethod(printName, "mainName"));
+ assertNull(ReflectionTestUtils.invokeMethod(printName, "subName"));
+ }
+
+ /** 套餐名与商品名完全相同时副行不重复显示,主名保持套餐名。 */
+ @Test
+ void printProductNamesSkipSubNameWhenIdentical() {
+ HardCarVo car = new HardCarVo();
+ car.setProductId(505L);
+ when(remoteCanteenSpuService.getInnerSpuPrintNames(List.of(505L))).thenReturn(R.ok(List.of(
+ SpuPrintNameVo.builder().productId(505L).comboName("A套餐").name("A套餐").build())));
+
+ @SuppressWarnings("unchecked")
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+
+ Object printName = names.get(505L);
+ assertEquals("A套餐", ReflectionTestUtils.invokeMethod(printName, "mainName"));
+ assertNull(ReflectionTestUtils.invokeMethod(printName, "subName"));
}
/** 商品已删除或非食堂商品时不在返回结果中,渲染层由快照名兜底。 */
@@ -493,7 +519,7 @@ class HardInvoiceServiceImplTest {
SpuPrintNameVo.builder().productId(504L).comboName("B套餐").name("B套餐-主菜").build())));
@SuppressWarnings("unchecked")
- Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
assertFalse(names.containsKey(503L));
}
@@ -507,12 +533,12 @@ class HardInvoiceServiceImplTest {
.thenThrow(new RuntimeException("canteen service unavailable"));
@SuppressWarnings("unchecked")
- Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
+ Map names = ReflectionTestUtils.invokeMethod(service, "loadPrintProductNames", List.of(car));
assertTrue(names.isEmpty());
}
- /** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名。 */
+ /** 未命中实时名称或历史购物车商品 ID 为空时,打印名必须回落到下单快照名并保持大字主行。 */
@Test
void resolvePrintNameFallsBackToSnapshot() {
HardCarVo named = new HardCarVo();
@@ -522,12 +548,16 @@ class HardInvoiceServiceImplTest {
legacy.setProductId(null);
legacy.setProductName("历史无ID商品");
- assertEquals("实时套餐名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
- named, Map.of(501L, "实时套餐名")));
- assertEquals("下单快照名", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
- named, Map.of()));
- assertEquals("历史无ID商品", ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
- legacy, Map.of()));
+ Object snapshot = ReflectionTestUtils.invokeMethod(service, "resolvePrintName", named, Map.of());
+ Object fromLive = ReflectionTestUtils.invokeMethod(service, "resolvePrintName",
+ named, Map.of(501L, snapshot));
+ Object resolvedLegacy = ReflectionTestUtils.invokeMethod(service, "resolvePrintName", legacy, Map.of());
+
+ assertSame(snapshot, fromLive);
+ assertEquals("下单快照名", ReflectionTestUtils.invokeMethod(snapshot, "mainName"));
+ assertNull(ReflectionTestUtils.invokeMethod(snapshot, "subName"));
+ assertEquals("历史无ID商品", ReflectionTestUtils.invokeMethod(resolvedLegacy, "mainName"));
+ assertNull(ReflectionTestUtils.invokeMethod(resolvedLegacy, "subName"));
}
private static HardOrderEntity order() {
diff --git a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml
index 184880c3..9b1dc84a 100644
--- a/pjcloud-visual/pjcloud-daemon-quartz/pom.xml
+++ b/pjcloud-visual/pjcloud-daemon-quartz/pom.xml
@@ -32,6 +32,12 @@
pjcloud-wallet-api
5.7.0
+
+
+ com.bsd
+ pjcloud-hardware-api
+ 5.7.0
+
com.alibaba.cloud
diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java
new file mode 100644
index 00000000..93c3c9c4
--- /dev/null
+++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/HardScheduleTask.java
@@ -0,0 +1,254 @@
+package com.bsd.pjcloud.daemon.quartz.task;
+
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.daemon.quartz.constants.PjcloudQuartzEnum;
+import com.bsd.pjcloud.hard.api.feign.RemoteHardScheduleService;
+import lombok.RequiredArgsConstructor;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+
+/**
+ * Hardware 补偿任务统一调度入口。sys_job 通过 spring bean 反射调用(job_type=2,
+ * class_name=hardScheduleTask),bean 内走 Feign 调 hardware 内部端点执行,
+ * 各任务由分布式订单锁、任务行锁、Outbox 条件更新或轮询协调器退避门控保证重复触发幂等。
+ */
+@Slf4j
+@Component("hardScheduleTask")
+@RequiredArgsConstructor
+public class HardScheduleTask {
+
+ /** hardware 内部调度 Feign 契约。 */
+ private final RemoteHardScheduleService remoteHardScheduleService;
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String settlePendingAggregateOrders() {
+ return settlePendingAggregateOrders(null);
+ }
+
+ /**
+ * 高频补偿近期待结算聚合支付订单,每 5 秒触发一次;空闲时硬件侧轮询协调器退避门控直接空转。
+ */
+ @SneakyThrows
+ public String settlePendingAggregateOrders(String para) {
+ log.info("聚合支付近期结算补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.settlePendingAggregateOrders();
+ if (!result.isOk()) {
+ log.error("聚合支付近期结算补偿失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("聚合支付近期结算补偿错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String settleStaleAggregateOrders() {
+ return settleStaleAggregateOrders(null);
+ }
+
+ /**
+ * 低频核对遗留聚合支付订单并取消超过一小时的订单,每 5 分钟触发一次。
+ */
+ @SneakyThrows
+ public String settleStaleAggregateOrders(String para) {
+ log.info("聚合支付遗留核对与超时取消开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.settleStaleAggregateOrders();
+ if (!result.isOk()) {
+ log.error("聚合支付遗留核对与超时取消失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("聚合支付遗留核对与超时取消错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String retryDueAutoRefundTasks() {
+ return retryDueAutoRefundTasks(null);
+ }
+
+ /**
+ * 扫描到期的自动退款任务并逐个恢复租户上下文执行,每 30 秒触发一次。
+ */
+ @SneakyThrows
+ public String retryDueAutoRefundTasks(String para) {
+ log.info("自动退款到期任务补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.retryDueAutoRefundTasks();
+ if (result.isOk()) {
+ if (result.getData() != null && result.getData() > 0) {
+ log.info("自动退款到期任务补偿完成,本轮尝试执行:{}条", result.getData());
+ }
+ } else {
+ log.error("自动退款到期任务补偿失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("自动退款到期任务补偿错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String retryCoffeeNotifyFailures() {
+ return retryCoffeeNotifyFailures(null);
+ }
+
+ /**
+ * 重试咖啡机通知失败的支付回调与退款同步缺口,每分钟触发一次。
+ */
+ @SneakyThrows
+ public String retryCoffeeNotifyFailures(String para) {
+ log.info("咖啡机通知失败补偿开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.retryCoffeeNotifyFailures();
+ if (!result.isOk()) {
+ log.error("咖啡机通知失败补偿失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("咖啡机通知失败补偿错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String processOutboxDueEvents() {
+ return processOutboxDueEvents(null);
+ }
+
+ /**
+ * 扫描到期核销消息 Outbox 事件并投递,每 10 秒触发一次;条件更新领取保证重复触发幂等。
+ */
+ @SneakyThrows
+ public String processOutboxDueEvents(String para) {
+ log.info("核销消息Outbox到期投递开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.processOutboxDueEvents();
+ if (result.isOk()) {
+ if (result.getData() != null && result.getData() > 0) {
+ log.info("核销消息Outbox到期投递完成,本轮处理:{}条", result.getData());
+ }
+ } else {
+ log.error("核销消息Outbox到期投递失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("核销消息Outbox到期投递错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String cleanupOutboxExpiredEvents() {
+ return cleanupOutboxExpiredEvents(null);
+ }
+
+ /**
+ * 清理超过保留期的终态核销消息 Outbox,每日 02:30 触发一次。
+ */
+ @SneakyThrows
+ public String cleanupOutboxExpiredEvents(String para) {
+ log.info("核销消息Outbox过期清理开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.cleanupOutboxExpiredEvents();
+ if (result.isOk()) {
+ if (result.getData() != null && result.getData() > 0) {
+ log.info("核销消息Outbox过期清理完成,本轮清理:{}条", result.getData());
+ }
+ } else {
+ log.error("核销消息Outbox过期清理失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("核销消息Outbox过期清理错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String processPrintDueTasks() {
+ return processPrintDueTasks(null);
+ }
+
+ /**
+ * 扫描并补偿下发持久化打印任务,每 10 秒触发一次。
+ */
+ @SneakyThrows
+ public String processPrintDueTasks(String para) {
+ log.info("打印任务补偿扫描开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.processPrintDueTasks();
+ if (result.isOk()) {
+ if (result.getData() != null && result.getData() > 0) {
+ log.info("打印任务补偿扫描完成,本轮处理:{}条", result.getData());
+ }
+ } else {
+ log.error("打印任务补偿扫描失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("打印任务补偿扫描错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+
+ /**
+ * 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
+ */
+ public String cleanupXfjLog() {
+ return cleanupXfjLog(null);
+ }
+
+ /**
+ * 清理创建时间超过三十天的消费机日志,每日 00:00 触发一次;累计未达一万条时跳过删除。
+ */
+ @SneakyThrows
+ public String cleanupXfjLog(String para) {
+ log.info("消费机日志过期清理开始:{},输入参数:{}", LocalDateTime.now(), para);
+ try {
+ R result = remoteHardScheduleService.cleanupXfjLog();
+ if (result.isOk()) {
+ if (result.getData() != null && result.getData() > 0) {
+ log.info("消费机日志过期清理完成,本轮删除:{}条", result.getData());
+ }
+ } else {
+ log.error("消费机日志过期清理失败,code:{},msg:{}", result.getCode(), result.getMsg());
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ } catch (Exception e) {
+ log.error("消费机日志过期清理错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
+ }
+ return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
+ }
+}