diff --git a/db/20260914_add_mch_auto_verify.sql b/db/20260914_add_mch_auto_verify.sql
new file mode 100644
index 000000000..aafee4172
--- /dev/null
+++ b/db/20260914_add_mch_auto_verify.sql
@@ -0,0 +1,193 @@
+-- 每日菜谱预约订单过期未核销自动核销。
+-- 幂等迁移脚本:可反复执行。每个字段/索引先查 information_schema,已存在则跳过(执行 SELECT 1 占位),
+-- 不存在才执行 DDL;部分应用过的库重跑不会报 Duplicate column / Duplicate key。
+-- 前置条件:20260822(wallet 配置列、订单列)与 20260823(订单核销时间列)的锚点列需已存在,
+-- 否则对应语句会因 AFTER 锚点缺失报错——这是有意的失败提示,请先补齐前置迁移。
+-- 任务表部分兼容 20260822 任务表语句未成功落库(scene_biz_id 缺失)的环境,会自动补齐。
+-- 权限复用 wallet_mchManage_setting、hard_hardAfterSale_view、hard_hardAfterSale_adminRefund,无需新增 sys_menu 记录。
+
+-- 【可选诊断】任务表当前列与索引,执行后可对照确认脚本将补齐的内容。
+SELECT COLUMN_NAME, COLUMN_TYPE, IFNULL(GENERATION_EXPRESSION, '') AS generation
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
+ AND TABLE_NAME = 'hard_auto_refund_task'
+ AND COLUMN_NAME IN ('task_type', 'trigger_time', 'restore_inventory', 'scene_biz_id')
+ORDER BY ORDINAL_POSITION;
+
+SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') AS index_columns
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'pjcloudx_hardware'
+ AND TABLE_NAME = 'hard_auto_refund_task'
+GROUP BY INDEX_NAME
+ORDER BY INDEX_NAME;
+
+-- 一、Wallet 配置列(三列各自独立判断)。
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_enabled` char(1) NOT NULL DEFAULT ''0'' COMMENT ''过期未核销自动核销开关,0关闭,1开启'' AFTER `expired_reserve_refund_restore_inventory`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_enabled');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_days` int(11) DEFAULT NULL COMMENT ''支付后自动核销天数,允许1至365,开关开启时必填'' AFTER `auto_verify_enabled`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_days');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config` ADD COLUMN `auto_verify_restore_inventory` char(1) NOT NULL DEFAULT ''1'' COMMENT ''自动核销是否恢复线上池库存,0不恢复,1恢复'' AFTER `auto_verify_days`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME = 'auto_verify_restore_inventory');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 二、订单快照与核销方式列(四列各自独立判断)。
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_config_id` bigint(20) DEFAULT NULL COMMENT ''支付成功时命中的自动核销配置ID快照,为空表示不参与自动核销'' AFTER `expired_refund_restore_inventory`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_config_id');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_deadline` datetime DEFAULT NULL COMMENT ''支付时间加配置天数后的自动核销触发时间'' AFTER `auto_verify_config_id`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_deadline');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `auto_verify_restore_inventory` char(1) DEFAULT NULL COMMENT ''自动核销退库存快照,0不恢复,1恢复线上池库存'' AFTER `auto_verify_deadline`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'auto_verify_restore_inventory');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_order` ADD COLUMN `verification_type` char(1) DEFAULT NULL COMMENT ''核销方式,1人工核销,2自动核销;历史已核销订单为空视为人工'' AFTER `verification_time`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'verification_type');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 三、任务表:兼容 20260822 任务表语句未落库的环境,先确保任务场景列存在(幂等 MODIFY 注释与空值语义)。
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `task_type` char(10) NOT NULL DEFAULT ''10'' COMMENT ''任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销'' AFTER `after_sale_id`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'task_type');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `trigger_time` datetime DEFAULT NULL COMMENT ''过期退款和自动核销任务按订单快照触发时间,旧售后任务为空'' AFTER `eligibility_deadline`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'trigger_time');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `restore_inventory` char(1) DEFAULT NULL COMMENT ''任务退库存快照,0不恢复,1恢复'' AFTER `trigger_time`',
+ 'SELECT 1')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'restore_inventory');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 幂等注释与空值语义修正(重复执行无副作用,兼为 20260822 未落库环境补齐)。
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期退款和自动核销任务为空',
+ MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空',
+ MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空',
+ MODIFY COLUMN `task_type` char(10) NOT NULL DEFAULT '10'
+ COMMENT '任务场景,10用户免审售后,20过期预约退款,30过期未核销自动核销',
+ MODIFY COLUMN `status` char(10) NOT NULL
+ COMMENT '任务状态,10待执行,20等待重试,30开关关闭暂停,31配置不可用暂停,40重试耗尽,50成功,60订单已终态跳过';
+
+-- scene_biz_id 生成列:缺失则新增(表达式含自动核销场景 30),已存在则 MODIFY 刷新表达式。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD COLUMN `scene_biz_id` bigint(20) GENERATED ALWAYS AS (CASE WHEN `task_type` IN (''20'', ''30'') THEN `order_id` ELSE `after_sale_id` END) STORED COMMENT ''任务场景业务唯一值,系统生成,禁止业务代码写入'' AFTER `restore_inventory`',
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` MODIFY COLUMN `scene_biz_id` bigint(20) GENERATED ALWAYS AS (CASE WHEN `task_type` IN (''20'', ''30'') THEN `order_id` ELSE `after_sale_id` END) STORED COMMENT ''任务场景业务唯一值,系统生成,禁止业务代码写入''')
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'scene_biz_id');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 新建场景唯一键前应确认本查询返回 0 行;存在重复说明历史任务数据冲突,必须先人工核对,禁止自动清理。
+SELECT tenant_id, task_type, del_flag,
+ CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END AS scene_biz_id,
+ COUNT(*) AS duplicate_count
+FROM pjcloudx_hardware.hard_auto_refund_task
+GROUP BY tenant_id, task_type, del_flag,
+ CASE WHEN task_type = '20' THEN order_id ELSE after_sale_id END
+HAVING COUNT(*) > 1;
+
+-- 旧售后单唯一键:存在才删除,给场景唯一键让位。
+SET @ddl = (SELECT IF(COUNT(*) > 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` DROP INDEX `uk_hard_auto_refund_task_after_sale`',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'uk_hard_auto_refund_task_after_sale');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 场景唯一键:缺失才创建(存在重复数据时本语句会失败,先处理上方诊断结果)。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz` (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'uk_hard_auto_refund_task_scene_biz');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 扫描索引:缺失才创建。
+SET @ddl = (SELECT IF(COUNT(*) = 0,
+ 'ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task` ADD KEY `idx_hard_auto_refund_task_scan_type` (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`)',
+ 'SELECT 1')
+ FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND INDEX_NAME = 'idx_hard_auto_refund_task_scan_type');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 四、尝试日志注释更新。幂等,任何状态都可执行。
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
+ MODIFY COLUMN `result_status` char(10) NOT NULL
+ COMMENT '尝试结果,10执行中,20成功,30失败,40因开关关闭跳过,50订单已终态跳过',
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL
+ COMMENT '售后单ID,过期退款和自动核销任务为空';
+
+-- 【验收】生成列表达式应包含 '20' 和 '30',且三张表的新列均已存在。
+SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, IFNULL(GENERATION_EXPRESSION, '') AS generation
+FROM information_schema.COLUMNS
+WHERE (TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_auto_refund_task' AND COLUMN_NAME = 'scene_biz_id')
+ OR (TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_auto_refund_config' AND COLUMN_NAME LIKE 'auto_verify_%')
+ OR (TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_order' AND COLUMN_NAME = 'verification_type');
+
+SET @ddl = NULL;
diff --git a/db/20260914_add_wallet_active_card_pool_index.sql b/db/20260914_add_wallet_active_card_pool_index.sql
new file mode 100644
index 000000000..df6f983ee
--- /dev/null
+++ b/db/20260914_add_wallet_active_card_pool_index.sql
@@ -0,0 +1,40 @@
+-- 钱包开卡表抢卡死锁治理索引。
+--
+-- 适用接口:GET /wallet-manage/create-wallet(用户批量导入触发的并发创建钱包+自动绑卡)等 createAndActiveCard 路径。
+-- 背景:抢卡 SQL 按 merchant_id = ? + user_id IS NULL 过滤并 ORDER BY card_sort LIMIT 1 FOR UPDATE;
+-- 原表仅有主键和 (user_id) 单列索引,锁定读需经索引扫描全部空闲卡再 filesort,
+-- 实际锁范围覆盖全表空闲卡直到事务提交,并与自动补卡的批量 INSERT 插入意向锁互相等待,
+-- 大批量用户导入并发建钱包时产生集中死锁。
+-- 索引效果:范围收敛为单商户空闲卡区间且天然按 card_sort 有序,FOR UPDATE 免排序、取满 LIMIT 1 即停止,
+-- 每笔事务仅锁定队首一张卡;同时加速绑卡前的空闲卡余量 COUNT。
+-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间消耗 IO,建议业务低峰期执行。
+-- 执行前建议:先备份数据库,并用文末查询核对生产库 wallet_active 实际已有索引。
+
+SET @index_ddl = IF(
+ EXISTS (
+ SELECT 1
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'wallet_active'
+ AND index_name = 'idx_wallet_active_merchant_user_sort'
+ ),
+ 'SELECT ''idx_wallet_active_merchant_user_sort already exists'' AS message',
+ 'ALTER TABLE `wallet_active` ADD INDEX `idx_wallet_active_merchant_user_sort` (`merchant_id`, `user_id`, `card_sort`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 执行后核对新增索引;确认与既有 idx_dept_id(user_id) 无同前缀冗余。
+SELECT table_name, index_name, seq_in_index, column_name
+FROM information_schema.statistics
+WHERE table_schema = DATABASE()
+ AND table_name = 'wallet_active'
+ORDER BY table_name, index_name, seq_in_index;
+
+-- 建议执行后更新统计信息,并用真实商户参数验证抢卡 SQL 计划不再 filesort:
+-- ANALYZE TABLE wallet_active;
+-- EXPLAIN SELECT id, ... FROM wallet_active
+-- WHERE del_flag = '0' AND user_id IS NULL AND wallet_id IS NULL
+-- AND merchant_id = ? AND is_active_hardware = '0' AND tenant_id = ?
+-- ORDER BY card_sort ASC LIMIT 1 FOR UPDATE;
diff --git a/db/20260914_add_wallet_recharge_import_remark.sql b/db/20260914_add_wallet_recharge_import_remark.sql
new file mode 100644
index 000000000..5f308ece3
--- /dev/null
+++ b/db/20260914_add_wallet_recharge_import_remark.sql
@@ -0,0 +1,2 @@
+-- 钱包充值 Excel 导入模板新增"备注"列:行级备注先落库到导入明细,审批通过后写入钱包流水 remark
+ALTER TABLE wallet_recharge_import ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符';
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java
new file mode 100644
index 000000000..60d0a4282
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderVerificationTypeEnum.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 预约订单核销方式。编码持久化到 hard_order.verification_type,用于区分人工核销与系统自动核销;
+ * 字段引入前已核销的历史订单该列为空,展示和统计时按人工核销处理。
+ */
+@Getter
+public enum HardOrderVerificationTypeEnum {
+
+ /** 人工核销:POS 扫码、App/管理端手动核销和批量核销等由工作人员主动触发的核销。 */
+ MANUAL("1", "人工核销"),
+
+ /** 自动核销:自动核销任务在支付后超过配置天数仍未核销时由系统静默完成。 */
+ AUTO("2", "自动核销");
+
+ /** 数据库存储的稳定核销方式编码。 */
+ private final String type;
+
+ /** 管理端展示名称。 */
+ private final String description;
+
+ /**
+ * 构造核销方式协议项。
+ *
+ * @param type 数据库存储编码
+ * @param description 展示名称
+ */
+ HardOrderVerificationTypeEnum(String type, String description) {
+ this.type = type;
+ this.description = description;
+ }
+
+ /**
+ * 按持久化编码解析核销方式。
+ *
+ * @param type 数据库存储编码
+ * @return 匹配的核销方式;空值或未知编码返回 {@code null}
+ */
+ public static HardOrderVerificationTypeEnum valueOfByType(String type) {
+ return Arrays.stream(values()).filter(item -> item.type.equals(type)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
index c643ddf4f..7067676c8 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundAttemptResultEnum.java
@@ -26,7 +26,12 @@ public enum HardAutoRefundAttemptResultEnum {
/**
* 执行前发现当前开关关闭,未调用资金接口且不消耗自动尝试额度。
*/
- SWITCH_DISABLED("40", "因开关关闭跳过");
+ SWITCH_DISABLED("40", "因开关关闭跳过"),
+
+ /**
+ * 自动核销任务执行前发现订单已人工核销、已退款或已取消,本次未执行核销且任务进入终态。
+ */
+ SKIPPED("50", "订单已终态跳过");
/**
* 数据库存储的稳定结果编码。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
index 936d68ac5..6f83ac560 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskStatusEnum.java
@@ -38,7 +38,12 @@ public enum HardAutoRefundTaskStatusEnum {
/**
* 钱包已幂等到账,售后和订单退款聚合状态均已完成更新。
*/
- SUCCESS("50", "成功");
+ SUCCESS("50", "成功"),
+
+ /**
+ * 自动核销任务执行时订单已人工核销、已退款或已取消等终态,无需再核销;任务到此结束。
+ */
+ SKIPPED("60", "订单已终态跳过");
/**
* 数据库和接口使用的稳定状态编码。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java
index fa11785b3..b96010958 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java
@@ -12,7 +12,10 @@ public enum HardAutoRefundTaskTypeEnum {
AFTER_SALE("10", "售后申请自动退款"),
/** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */
- EXPIRED_RESERVE("20", "过期预约自动退款");
+ EXPIRED_RESERVE("20", "过期预约自动退款"),
+
+ /** 每日菜谱预约订单支付后超过配置天数未核销时创建的系统自动核销任务。 */
+ AUTO_VERIFY("30", "过期未核销自动核销");
/** 数据库存储的稳定任务场景编码。 */
private final String type;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
index 6633dceeb..db2387281 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
@@ -31,7 +31,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
*/
- EXPIRED_RESCAN("50", "后台立即重新扫描", true);
+ EXPIRED_RESCAN("50", "后台立即重新扫描", true),
+
+ /**
+ * 管理端业务人员在自动核销候选列表确认后逐单立即核销;核销动作仍走自动核销链路并标记为自动核销。
+ */
+ AUTO_VERIFY_MANUAL("60", "后台逐单自动核销", false);
/**
* 数据库存储的稳定触发编码。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
index 1665a7198..9dc4f9031 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
@@ -175,6 +175,18 @@ public class HardOrderEntity extends Model {
@Schema(description = "过期预约退款退库存快照")
private String expiredRefundRestoreInventory;
+ /** 自动核销配置 ID 快照;仅支付成功且当时开关开启的每日菜谱预约订单写入,为空表示不参与自动核销。 */
+ @Schema(description = "自动核销配置ID快照")
+ private Long autoVerifyConfigId;
+
+ /** 支付时间加配置天数后的自动核销触发时间;任务按该时间到期执行。 */
+ @Schema(description = "自动核销触发时间")
+ private LocalDateTime autoVerifyDeadline;
+
+ /** 自动核销是否恢复每日菜谱线上池库存的订单快照,1恢复、0不恢复。 */
+ @Schema(description = "自动核销退库存快照")
+ private String autoVerifyRestoreInventory;
+
/**
* 订单设备类型
*/
@@ -241,6 +253,13 @@ public class HardOrderEntity extends Model {
@Schema(description = "订单核销时间")
private LocalDateTime verificationTime;
+ /**
+ * 核销方式快照,对应 {@code HardOrderVerificationTypeEnum}:1人工核销、2自动核销;
+ * 字段引入前已核销的历史订单为空,按人工核销处理;退回核销前状态时清空。
+ */
+ @Schema(description = "核销方式,1人工核销,2自动核销")
+ private String verificationType;
+
/**
* 订单取消时间(退款订单)
*/
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 8d149f3de..e1650f771 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
@@ -56,6 +56,20 @@ public class ThermalReceiptImage {
/** 下一块内容的顶部 Y 坐标。 */
private int cursorY = VERTICAL_PADDING;
+ /** 紧凑版式开关;开启后全部文字比标准版式小一号,非商品区行距和分隔线间距收紧。 */
+ private boolean compactFonts = false;
+
+ /**
+ * 切换紧凑版式,发票与兑换券启用后全部文字小一号,且非商品区的行距、分隔线与文字间距收紧。
+ *
+ * @param enabled {@code true} 时使用紧凑字号与间距
+ * @return 当前绘制器
+ */
+ public ThermalReceiptImage compactFonts(boolean enabled) {
+ this.compactFonts = enabled;
+ return this;
+ }
+
/**
* 创建 576px 宽的白底收据画布。
*/
@@ -75,8 +89,8 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage title(String text) {
- setFont(Font.BOLD, 42);
- drawCentered(text, 16);
+ setFont(Font.BOLD, compactFonts ? 31 : 42);
+ drawCentered(text, compactFonts ? 10 : 16);
return this;
}
@@ -87,12 +101,13 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage banner(String text) {
- setFont(Font.BOLD, 44);
+ setFont(Font.BOLD, compactFonts ? 31 : 44);
FontMetrics metrics = graphics.getFontMetrics();
List lines = wrap(text == null ? "" : text, contentWidth() - 32, metrics);
int lineHeight = metrics.getHeight() + 4;
- int height = lines.size() * lineHeight + 26;
- ensureCapacity(height + 20);
+ int height = lines.size() * lineHeight + (compactFonts ? 20 : 26);
+ int bottomGap = compactFonts ? 14 : 20;
+ ensureCapacity(height + bottomGap);
graphics.setStroke(new BasicStroke(3F));
graphics.drawRect(HORIZONTAL_PADDING, cursorY, contentWidth(), height);
for (int index = 0; index < lines.size(); index++) {
@@ -100,7 +115,7 @@ public class ThermalReceiptImage {
int x = HORIZONTAL_PADDING + Math.max(0, (contentWidth() - metrics.stringWidth(line)) / 2);
graphics.drawString(line, x, cursorY + 13 + index * lineHeight + metrics.getAscent());
}
- cursorY += height + 20;
+ cursorY += height + bottomGap;
return this;
}
@@ -111,8 +126,8 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage verificationCode(String shortCode) {
- setFont(Font.BOLD, 64);
- drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, 18);
+ setFont(Font.BOLD, compactFonts ? 48 : 64);
+ drawCentered(shortCode == null || shortCode.isBlank() ? "--" : shortCode, compactFonts ? 12 : 18);
return this;
}
@@ -124,7 +139,7 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage keyValue(String label, String value) {
- setFont(Font.PLAIN, 33);
+ setFont(Font.PLAIN, compactFonts ? 24 : 33);
String safeLabel = label == null ? "" : label;
String safeValue = value == null || value.isBlank() ? "--" : value;
FontMetrics metrics = graphics.getFontMetrics();
@@ -134,7 +149,8 @@ public class ThermalReceiptImage {
int valueWidth = stackValue ? contentWidth() : Math.max(120, inlineValueWidth);
List lines = wrap(safeValue, valueWidth, metrics);
int valueLineOffset = stackValue ? 1 : 0;
- int lineHeight = metrics.getHeight() + 6;
+ int lineGap = compactFonts ? 3 : 6;
+ int lineHeight = metrics.getHeight() + lineGap;
ensureCapacity((lines.size() + valueLineOffset) * lineHeight + 10);
graphics.drawString(safeLabel + ":", HORIZONTAL_PADDING, cursorY + metrics.getAscent());
for (int index = 0; index < lines.size(); index++) {
@@ -142,7 +158,7 @@ public class ThermalReceiptImage {
graphics.drawString(lines.get(index), x,
cursorY + (index + valueLineOffset) * lineHeight + metrics.getAscent());
}
- cursorY += (lines.size() + valueLineOffset) * lineHeight + 6;
+ cursorY += (lines.size() + valueLineOffset) * lineHeight + lineGap;
return this;
}
@@ -153,10 +169,10 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage sectionTitle(String text) {
- setFont(Font.BOLD, 36);
+ setFont(Font.BOLD, compactFonts ? 26 : 36);
ensureCapacity(60);
graphics.drawString(text == null ? "" : text, HORIZONTAL_PADDING, cursorY + graphics.getFontMetrics().getAscent());
- cursorY += graphics.getFontMetrics().getHeight() + 10;
+ cursorY += graphics.getFontMetrics().getHeight() + (compactFonts ? 6 : 10);
return this;
}
@@ -181,13 +197,17 @@ public class ThermalReceiptImage {
String safeOrderNo = orderNo == null || orderNo.isBlank() ? "--" : orderNo;
String summary = "订餐日期:" + (orderDate == null || orderDate.isBlank() ? "--" : orderDate)
+ " 餐段:" + (mealName == null || mealName.isBlank() ? "--" : mealName);
+ int tagFontSize = compactFonts ? 22 : 25;
+ int orderFontSize = compactFonts ? 26 : 31;
+ int summaryFontSize = compactFonts ? 24 : 29;
+ int headerGap = compactFonts ? 6 : 10;
- setFont(Font.BOLD, 25);
+ setFont(Font.BOLD, tagFontSize);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagWidth = tagMetrics.stringWidth(tag) + 20;
int tagHeight = tagMetrics.getHeight() + 6;
- setFont(Font.BOLD, 31);
+ setFont(Font.BOLD, orderFontSize);
FontMetrics orderMetrics = graphics.getFontMetrics();
int orderX = HORIZONTAL_PADDING + tagWidth + 14;
int orderWidth = PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - orderX;
@@ -195,30 +215,30 @@ public class ThermalReceiptImage {
int orderLineHeight = orderMetrics.getHeight() + 4;
int headerHeight = Math.max(tagHeight, orderLines.size() * orderLineHeight);
- setFont(Font.PLAIN, 29);
+ setFont(Font.PLAIN, summaryFontSize);
FontMetrics summaryMetrics = graphics.getFontMetrics();
List summaryLines = wrap(summary, contentWidth(), summaryMetrics);
int summaryLineHeight = summaryMetrics.getHeight() + 4;
- int separatorHeight = safeIndex < safeTotal ? 18 : 8;
- int blockHeight = headerHeight + 10 + summaryLines.size() * summaryLineHeight + separatorHeight;
+ int separatorHeight = safeIndex < safeTotal ? (compactFonts ? 12 : 18) : (compactFonts ? 6 : 8);
+ int blockHeight = headerHeight + headerGap + summaryLines.size() * summaryLineHeight + separatorHeight;
ensureCapacity(blockHeight);
int blockTop = cursorY;
graphics.setColor(Color.BLACK);
- setFont(Font.BOLD, 25);
+ setFont(Font.BOLD, tagFontSize);
graphics.fillRect(HORIZONTAL_PADDING, blockTop, tagWidth, tagHeight);
graphics.setColor(Color.WHITE);
graphics.drawString(tag, HORIZONTAL_PADDING + 10, blockTop + 3 + tagMetrics.getAscent());
graphics.setColor(Color.BLACK);
- setFont(Font.BOLD, 31);
+ setFont(Font.BOLD, orderFontSize);
for (int lineIndex = 0; lineIndex < orderLines.size(); lineIndex++) {
graphics.drawString(orderLines.get(lineIndex), orderX,
blockTop + lineIndex * orderLineHeight + orderMetrics.getAscent());
}
- int summaryTop = blockTop + headerHeight + 10;
- setFont(Font.PLAIN, 29);
+ int summaryTop = blockTop + headerHeight + headerGap;
+ setFont(Font.PLAIN, summaryFontSize);
for (int lineIndex = 0; lineIndex < summaryLines.size(); lineIndex++) {
graphics.drawString(summaryLines.get(lineIndex), HORIZONTAL_PADDING,
summaryTop + lineIndex * summaryLineHeight + summaryMetrics.getAscent());
@@ -259,10 +279,7 @@ public class ThermalReceiptImage {
}
/**
- * 绘制兑换券商品卡片,使用文字标签和边框粗细同时区分组合商品与单品。
- *
- * 商品名称和组合内容均按真实像素宽度完整换行;价格区固定展示单价、数量和小计。
- * 组合内容只解释套餐构成,不参与当前卡片金额计算。
+ * 绘制兑换券商品卡片,数量为整数份数。
*
* @param name 商品快照名称;空值显示“未命名商品”
* @param quantity 部分退款后的剩余可履约数量
@@ -274,33 +291,55 @@ public class ThermalReceiptImage {
*/
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);
+ }
+
+ /**
+ * 绘制兑换券商品卡片,数量为已格式化文本,供兑换券与发票共用同一卡片版式。
+ *
+ * 数量文本由调用方按业务格式化,例如按份数的“x2”或称重的“0.5/kg”,绘制时原样展示。
+ *
+ * @param name 商品快照名称;空值显示“未命名商品”
+ * @param quantity 数量展示文本,例如“x2”或“0.5/kg”
+ * @param unitPrice 商品快照单价,单位元且不含货币符号
+ * @param subtotal 商品小计,单位元且不含货币符号
+ * @param combo 是否为组合商品
+ * @param componentItems 组合商品组成说明,每项应已包含对应数量;单品或无组成时可为空
+ * @return 当前绘制器
+ */
+ public ThermalReceiptImage voucherProduct(String name, String quantity, String unitPrice, String subtotal,
+ boolean combo, List componentItems) {
String typeLabel = combo ? "组合商品" : "单品";
String safeName = name == null || name.isBlank() ? "未命名商品" : name;
+ String safeQuantity = quantity == null || quantity.isBlank() ? "x0" : quantity;
String safeUnitPrice = unitPrice == null || unitPrice.isBlank() ? "--" : unitPrice;
String safeSubtotal = subtotal == null || subtotal.isBlank() ? "--" : subtotal;
List safeComponents = componentItems == null ? List.of() : componentItems.stream()
.filter(item -> item != null && !item.isBlank()).toList();
int innerWidth = contentWidth() - PRODUCT_CARD_PADDING * 2;
+ int tagFontSize = compactFonts ? 25 : 28;
+ int nameFontSize = compactFonts ? 36 : 40;
+ int priceFontSize = compactFonts ? 26 : 29;
- setFont(Font.BOLD, 28);
+ setFont(Font.BOLD, tagFontSize);
FontMetrics tagMetrics = graphics.getFontMetrics();
int tagHeight = tagMetrics.getHeight() + 6;
int tagWidth = tagMetrics.stringWidth(typeLabel) + 20;
- setFont(Font.BOLD, 40);
+ setFont(Font.BOLD, nameFontSize);
FontMetrics nameMetrics = graphics.getFontMetrics();
List nameLines = wrap(safeName, innerWidth, nameMetrics);
int nameLineHeight = nameMetrics.getHeight() + 4;
- setFont(Font.PLAIN, 29);
+ setFont(Font.PLAIN, priceFontSize);
FontMetrics priceMetrics = graphics.getFontMetrics();
- String priceAndQuantity = "单价 ¥" + safeUnitPrice + " x" + Math.max(0, quantity);
+ String priceAndQuantity = "单价 ¥" + safeUnitPrice + " " + safeQuantity;
String subtotalText = "小计 ¥" + safeSubtotal;
boolean splitPriceLine = priceMetrics.stringWidth(priceAndQuantity)
+ priceMetrics.stringWidth(subtotalText) + 24 > innerWidth;
int priceLineHeight = priceMetrics.getHeight() + 4;
- setFont(Font.PLAIN, 29);
+ setFont(Font.PLAIN, priceFontSize);
FontMetrics componentMetrics = graphics.getFontMetrics();
int componentLineHeight = componentMetrics.getHeight() + 3;
List> componentLines = new ArrayList<>();
@@ -322,7 +361,7 @@ public class ThermalReceiptImage {
graphics.setStroke(new BasicStroke(combo ? 4F : 2F));
graphics.drawRect(HORIZONTAL_PADDING, cardTop, contentWidth(), cardHeight);
- setFont(Font.BOLD, 28);
+ setFont(Font.BOLD, tagFontSize);
if (combo) {
graphics.fillRect(contentX, contentY, tagWidth, tagHeight);
graphics.setColor(Color.WHITE);
@@ -335,14 +374,14 @@ public class ThermalReceiptImage {
}
contentY += tagHeight + 10;
- setFont(Font.BOLD, 40);
+ setFont(Font.BOLD, nameFontSize);
for (String line : nameLines) {
graphics.drawString(line, contentX, contentY + nameMetrics.getAscent());
contentY += nameLineHeight;
}
contentY += 8;
- setFont(Font.PLAIN, 29);
+ setFont(Font.PLAIN, priceFontSize);
graphics.drawString(priceAndQuantity, contentX, contentY + priceMetrics.getAscent());
if (splitPriceLine) {
contentY += priceLineHeight;
@@ -359,10 +398,10 @@ public class ThermalReceiptImage {
graphics.drawLine(contentX, contentY,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - PRODUCT_CARD_PADDING, contentY);
contentY += 10;
- setFont(Font.BOLD, 29);
+ setFont(Font.BOLD, priceFontSize);
graphics.drawString("组合内容", contentX, contentY + componentMetrics.getAscent());
contentY += componentLineHeight;
- setFont(Font.PLAIN, 29);
+ setFont(Font.PLAIN, priceFontSize);
for (List lines : componentLines) {
for (String line : lines) {
graphics.drawString(line, contentX + CHILD_INDENT,
@@ -385,20 +424,21 @@ public class ThermalReceiptImage {
String safeAmount = totalAmount == null || totalAmount.isBlank() ? "--" : totalAmount;
String label = "商品合计";
String amount = "¥" + safeAmount;
- setFont(Font.BOLD, 42);
+ setFont(Font.BOLD, compactFonts ? 31 : 42);
FontMetrics metrics = graphics.getFontMetrics();
- int blockHeight = metrics.getHeight() + 30;
- ensureCapacity(blockHeight + 14);
+ int blockHeight = metrics.getHeight() + (compactFonts ? 24 : 30);
+ int bottomGap = compactFonts ? 10 : 14;
+ ensureCapacity(blockHeight + bottomGap);
graphics.setColor(Color.BLACK);
graphics.setStroke(new BasicStroke(3F));
graphics.drawLine(HORIZONTAL_PADDING, cursorY,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY);
- int baseline = cursorY + 15 + metrics.getAscent();
+ int baseline = cursorY + (compactFonts ? 12 : 15) + metrics.getAscent();
graphics.drawString(label, HORIZONTAL_PADDING, baseline);
graphics.drawString(amount, PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING - metrics.stringWidth(amount), baseline);
graphics.drawLine(HORIZONTAL_PADDING, cursorY + blockHeight,
PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + blockHeight);
- cursorY += blockHeight + 14;
+ cursorY += blockHeight + bottomGap;
return this;
}
@@ -414,7 +454,8 @@ public class ThermalReceiptImage {
*/
private ThermalReceiptImage item(String name, String trailing, boolean child, boolean emphasized,
int trailingWidth) {
- setFont(emphasized ? Font.BOLD : Font.PLAIN, child ? 30 : 34);
+ int fontSize = (child ? 30 : 34) - (compactFonts ? 3 : 0);
+ setFont(emphasized ? Font.BOLD : Font.PLAIN, fontSize);
FontMetrics metrics = graphics.getFontMetrics();
int indent = child ? CHILD_INDENT : 0;
String prefix = child ? "- " : "";
@@ -439,11 +480,12 @@ public class ThermalReceiptImage {
* @return 当前绘制器
*/
public ThermalReceiptImage divider() {
- ensureCapacity(28);
+ int topGap = compactFonts ? 6 : 10;
+ ensureCapacity(topGap + 18);
graphics.setStroke(new BasicStroke(2F));
- graphics.drawLine(HORIZONTAL_PADDING, cursorY + 10,
- PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + 10);
- cursorY += 28;
+ graphics.drawLine(HORIZONTAL_PADDING, cursorY + topGap,
+ PAPER_WIDTH_PIXELS - HORIZONTAL_PADDING, cursorY + topGap);
+ cursorY += compactFonts ? 20 : 28;
return this;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java
new file mode 100644
index 000000000..3d49943ca
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoVerifyRescanResultVo.java
@@ -0,0 +1,28 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 商户自动核销立即重扫结果,供管理端展示本轮快照刷新和任务补建数量。
+ */
+@Data
+@Schema(description = "自动核销立即重扫结果")
+public class HardAutoVerifyRescanResultVo {
+
+ /** 本轮读取的已支付、未核销且支付时间已超过配置天数的每日菜谱订单数量。 */
+ @Schema(description = "扫描订单数量")
+ private Integer scannedOrderCount;
+
+ /** 本轮首次补建的自动核销任务数量。 */
+ @Schema(description = "新建任务数量")
+ private Integer createdTaskCount;
+
+ /** 本轮按当前天数和退库存配置刷新触发时间的未尝试任务数量。 */
+ @Schema(description = "刷新任务数量")
+ private Integer refreshedTaskCount;
+
+ /** 因订单已终态、存在在途售后或任务已产生尝试而未同步的订单数量。 */
+ @Schema(description = "跳过订单数量")
+ private Integer skippedOrderCount;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java
new file mode 100644
index 000000000..6bfdb8956
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/WardMenuMapDayVo.java
@@ -0,0 +1,23 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 预约菜谱日历地图的单日聚合:按用餐日统计未退款预约订单的核销情况。
+ */
+@Data
+@Schema(description = "预约菜谱日历地图单日聚合")
+public class WardMenuMapDayVo {
+
+ @Schema(description = "用餐日")
+ private LocalDate apptUseDate;
+
+ @Schema(description = "待核销预约单数(订单状态 5)")
+ private Integer pendingCount;
+
+ @Schema(description = "已核销预约单数(订单状态 20)")
+ private Integer verifiedCount;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
index fa5149d9f..d8120017b 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
@@ -10,10 +10,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -49,6 +51,11 @@ public class HardAutoRefundTaskController {
*/
private final HardAutoRefundTaskService taskService;
+ /**
+ * 自动核销服务,负责商户维度的历史订单重扫补建任务。
+ */
+ private final HardAutoVerifyService hardAutoVerifyService;
+
/**
* 售后服务,用于 App 查询任务前复用订单查看授权。
*/
@@ -126,6 +133,51 @@ public class HardAutoRefundTaskController {
return R.ok(true);
}
+ /**
+ * 按商户详情页传入的精确商户 ID 查询自动核销候选任务:包含尚未到期任务,订单仍处于待核销状态。
+ *
+ * @param page 分页参数
+ * @param dto 候选查询条件,其中商户 ID 必填
+ * @return 当前商户的待自动核销订单分页
+ */
+ @GetMapping("/auto-verify-candidates")
+ @Operation(summary = "查询待自动核销订单")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
+ public R> autoVerifyCandidates(@ParameterObject Page page, @ParameterObject HardAutoRefundTaskPageDto dto) {
+ validateMerchantScope(dto.getMchId());
+ // 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。
+ dto.setMchIds(null);
+ return R.ok(taskService.pageVerifyCandidates(page, dto));
+ }
+
+ /**
+ * 按商户当前自动核销配置立即重扫历史待核销订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。
+ *
+ * @param mchId 商户详情页传入的精确商户 ID
+ * @return 本轮扫描、任务同步和跳过数量
+ */
+ @PostMapping("/auto-verify-rescan")
+ @Operation(summary = "立即重新扫描自动核销订单")
+ @Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R autoVerifyRescan(@RequestParam Long mchId) {
+ validateMerchantScope(mchId);
+ return R.ok(hardAutoVerifyService.rescanAutoVerifyOrders(mchId, SecurityUtils.getUser().getId()));
+ }
+
+ /**
+ * 管理端确认后对自动核销候选任务逐单立即核销;核销方式仍标记为自动核销,且不允许到期前限制。
+ */
+ @PostMapping("/{id}/auto-verify-now")
+ @Operation(summary = "自动核销候选订单立即核销")
+ @Idempotent(expireTime = 5, info = "正在核销,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R autoVerifyNow(@PathVariable Long id) {
+ validateTaskScope(id);
+ hardAutoVerifyService.manualAutoVerify(id, SecurityUtils.getUser().getId());
+ return R.ok(true);
+ }
+
/**
* 查询指定任务全部只读尝试日志。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index 6b37a8541..8a593a958 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -45,6 +45,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO;
+import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
@@ -70,6 +71,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
/**
@@ -175,6 +177,55 @@ public class HardOrderController {
return R.ok(orders);
}
+ @Operation(summary = "app查询预约菜谱日历地图", description = "按日期范围聚合本人或被监护人每天的预约核销情况,整单退款的订单不展示")
+ @ApiCryptoAes
+ @GetMapping("/app/ward-menu-map")
+ public R> wardMenuMap(@RequestParam(required = false) List dinerUserIds,
+ @RequestParam String startDate,
+ @RequestParam String endDate) {
+ HardOrderDto dto = new HardOrderDto();
+ PjcloudUser user = SecurityUtils.getUser();
+ dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds)
+ ? List.of(user.getId())
+ : hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
+ dto.setIsFindCarList(false);
+ dto.setIsFindApptList(true);
+ dto.setApptIdOrApptRuleIdNotNull(true);
+ dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
+ // 只统计待核销(5)与已核销(20);退款中(10)、已取消(30)和未支付订单不进入地图。
+ dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
+ dto.setApptUseDateTimeList(new LocalDateTime[]{
+ LocalDate.parse(startDate).atStartOfDay(),
+ LocalDate.parse(endDate).atTime(23, 59, 59),
+ });
+ return R.ok(hardwareOrderService.wardMenuMap(dto));
+ }
+
+ @Operation(summary = "app查询预约菜谱某日订单明细", description = "查询某天本人或被监护人的预约订单明细,整单退款的订单不返回")
+ @ApiCryptoAes
+ @GetMapping("/app/ward-menu-day-orders")
+ public R> wardMenuDayOrders(@RequestParam(required = false) List dinerUserIds,
+ @RequestParam String date) {
+ HardOrderDto dto = new HardOrderDto();
+ PjcloudUser user = SecurityUtils.getUser();
+ dto.setUserIds(ObjectUtil.isEmpty(dinerUserIds)
+ ? List.of(user.getId())
+ : hardwareOrderService.validateReserveDinerUserIds(user.getId(), dinerUserIds));
+ dto.setIsFindCarList(true);
+ dto.setIsFindApptList(true);
+ dto.setApptIdOrApptRuleIdNotNull(true);
+ dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
+ // 与日历地图同口径:只返回待核销(5)与已核销(20),整单退款在服务层过滤。
+ dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
+ dto.setApptUseDateTimeList(new LocalDateTime[]{
+ LocalDate.parse(date).atStartOfDay(),
+ LocalDate.parse(date).atTime(23, 59, 59),
+ });
+ List orders = hardwareOrderService.wardMenuDayOrders(dto);
+ hardwareOrderService.applyAppPermissions(orders, user.getId());
+ return R.ok(orders);
+ }
+
@Operation(summary = "app查询订单列表(查询食堂、商城)", description = "app查询订单列表(查询食堂、商城)")
@ApiCryptoAes
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
index 56e69579a..3f4481cd9 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
@@ -67,6 +67,30 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageVerifyCandidates(Page page,
+ @Param("dto") HardAutoRefundTaskPageDto dto,
+ @Param("now") LocalDateTime now);
+
+ /**
+ * 查询指定商户仍处于已支付未核销、支付时间早于截止时间且没有处理中售后的每日菜谱钱包订单,
+ * 供自动核销立即重扫补建任务。部分退款完成的订单保留资格,不在此排除。
+ *
+ * @param mchId 精确商户 ID
+ * @param payTimeDeadline 支付时间截止点,通常为当前时间减去配置天数
+ * @return 按订单主键升序排列的可重扫订单
+ */
+ List listAutoVerifyRescanOrders(@Param("mchId") Long mchId,
+ @Param("payTimeDeadline") LocalDateTime payTimeDeadline);
+
/**
* 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
*
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 3c5695af3..6e6e0fa00 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
@@ -36,6 +36,14 @@ public interface HardAutoRefundTaskService extends IService pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto);
+ /**
+ * 分页查询商户的自动核销候选任务(含待执行、等待重试和暂停状态),供管理端跟踪核销进度。
+ *
+ * @param page 分页参数
+ * @param dto 服务端已注入商户数据范围的筛选条件
+ * @return 自动核销任务分页
+ */
+ Page pageVerifyCandidates(Page page, HardAutoRefundTaskPageDto dto);
+
/**
* 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java
new file mode 100644
index 000000000..f8fa8e09c
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoVerifyService.java
@@ -0,0 +1,48 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardAutoVerifyRescanResultVo;
+
+/**
+ * 过期未核销自动核销服务。负责任务尝试的业务执行(静默核销与可选退库存)和历史订单重扫补建任务;
+ * 任务状态机、调度扫描和重试仍由 {@link HardAutoRefundTaskService} 统一驱动。
+ */
+public interface HardAutoVerifyService {
+
+ /**
+ * 在任务状态机事务内执行一次自动核销:复用人工核销的订单行锁、履约校验、二维码消费和状态条件更新,
+ * 核销方式标记为自动;任务快照要求退库存时按剩余有效数量回补每日菜谱线上池。
+ * 本方法不创建售后单、不发生资金动作、不发推送与打印任务。
+ *
+ * @param task 已锁定且通过配置与订单预检的自动核销任务
+ */
+ void completeAutoVerify(HardAutoRefundTaskEntity task);
+
+ /**
+ * completeAutoVerify 的触发时间开关版本:后台逐单立即核销允许提前关闭尚未到期订单,
+ * 此时跳过触发时间校验;自动调度仍必须校验,防止调度竞态提前核销。
+ *
+ * @param task 已锁定的自动核销任务
+ * @param enforceTriggerTime 是否强制校验任务触发时间已到期
+ */
+ void completeAutoVerify(HardAutoRefundTaskEntity task, boolean enforceTriggerTime);
+
+ /**
+ * 管理端在自动核销候选列表逐单立即核销:核销动作仍走自动核销链路并标记为自动核销,
+ * 不校验触发时间(允许提前关闭尚未到期订单),也不受当前自动核销开关限制。
+ *
+ * @param taskId 自动核销任务 ID
+ * @param operatorUserId 当前后台操作用户 ID
+ */
+ void manualAutoVerify(Long taskId, Long operatorUserId);
+
+ /**
+ * 按商户当前自动核销配置重扫历史已支付未核销订单,补建或刷新未尝试任务;
+ * 已到期任务立即进入核销状态机执行。部分退款完成的订单仍可自动核销剩余商品。
+ *
+ * @param mchId 精确商户 ID
+ * @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计
+ * @return 本轮扫描、任务同步和跳过数量
+ */
+ HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index 9724fb02c..6f7decb8d 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -33,6 +33,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
+import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
@@ -196,6 +197,16 @@ public interface HardOrderService extends IService {
*/
List validateReserveDinerUserIds(Long payerUserId, List dinerUserIds);
+ /**
+ * app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
+ */
+ List wardMenuMap(HardOrderDto dto);
+
+ /**
+ * app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单。
+ */
+ List wardMenuDayOrders(HardOrderDto dto);
+
/**
* app创建食堂外卖订单
*/
@@ -318,6 +329,16 @@ public interface HardOrderService extends IService {
*/
Boolean handCancelCode(HandCancelCodeDto dto);
+ /**
+ * 自动核销任务专用核销入口:复用人工核销的订单行锁、履约校验、二维码消费与状态条件更新,
+ * 核销方式标记为自动核销;整个过程静默执行,不发取餐广播、不建兑换券打印任务、不发核销成功推送。
+ * 只应由自动核销任务在支付后超期未核销时调用,不对外部接口暴露。
+ *
+ * @param orderId 待自动核销的每日菜谱预约订单主键
+ * @return 核销完成时返回 {@code true}
+ */
+ Boolean autoVerifyReserveOrder(Long orderId);
+
/**
* 在同一个本地事务中核销一组预约订单,并在全部成功后创建一个合并兑换券任务。
*
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 5e3eba401..42eea3025 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
@@ -4,7 +4,6 @@ import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -39,7 +38,6 @@ import com.bsd.pjcloud.hard.api.dto.HardPrintResultDto;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.entity.HardPrinterBindingEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
-import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.utils.ThermalReceiptImage;
import com.bsd.pjcloud.hard.api.vo.HardInvoiceVo;
@@ -256,45 +254,33 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票与兑换券保持一致的用户信息、订单识别、商品卡片和商品合计版式;发票另保留支付信息字段,
+ * 商品小计使用下单支付快照,不随部分退款变化。
*/
@Override
public String generateInvoice(HardOrderEntity order, String code) {
- List carList = carService.list(new LambdaQueryWrapper()
- .eq(HardCarEntity::getParentCarId, order.getParentCarId()));
- String username = "外部用户-未知用户";
- String userPhone = "外部用户-未知手机号";
- R userResult = userService.getInnerByUserId(order.getUserId());
- UserInfo user = userResult == null ? null : userResult.getData();
- if (user != null && user.getSysUser() != null) {
- username = user.getSysUser().getName();
- userPhone = user.getSysUser().getPhone();
- }
MchManageVo mch = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
- String orderType = Optional.ofNullable(HardOrderTypeEnum.valueOfByType(order.getOrderType()))
- .map(HardOrderTypeEnum::getDescription).orElse("--");
+ UserVO user = loadVoucherUser(order.getUserId());
+ List carList = Optional.ofNullable(carService.getByOrderId(order.getId())).orElseGet(List::of);
+ boolean showComboItems = shouldShowVoucherComboItems();
+ Map metadata = loadProductMetadata(carList, showComboItems);
String hardwareName = Optional.ofNullable(HardTypeEnum.valueOfByType(order.getSysType()))
.map(HardTypeEnum::getDescription).orElse("设备");
- String walletTypeName = resolveWalletTypeName(order.getWalletId());
- ThermalReceiptImage image = new ThermalReceiptImage().title(String.format("智慧%s发票", hardwareName))
- .divider().keyValue("订单号", displayOrderNo(order)).keyValue("订单类型", orderType)
- .keyValue("用户", StrUtil.blankToDefault(username, "--"))
- .keyValue("手机号", StrUtil.blankToDefault(userPhone, "--"));
+ ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true)
+ .title(String.format("智慧%s发票", hardwareName))
+ .keyValue("商户", mch.getName());
+ String walletTypeName = resolveWalletTypeName(order.getWalletId());
if (StrUtil.isNotBlank(walletTypeName)) image.keyValue("钱包类型", walletTypeName);
- image.keyValue("商户", mch.getName()).keyValue("部门", mch.getDeptName())
- .keyValue("下单时间", formatDateTime(order.getCreateTime()));
- if (order.getPayTime() != null) image.keyValue("支付时间", formatDateTime(order.getPayTime()));
- if (ObjectUtil.isNotEmpty(carList)) {
- image.divider().sectionTitle("商品明细");
- carList.forEach(car -> image.invoiceItem(car.getProductName(), productQuantity(car),
- "¥" + money(car.getTotalPrice())));
- if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
- image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
- }
- image.keyValue("小计", "¥" + money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
- .divider();
+ appendVoucherUser(image, user);
+ appendInvoiceOrderBlock(image, order);
+ appendInvoiceProducts(image, carList, metadata, showComboItems);
+ if (order.getExtraPrice() != null && order.getExtraPrice() != 0) {
+ image.keyValue("额外支付金额", "¥" + money(order.getExtraPrice().longValue()));
}
- image.keyValue("打印时间", formatDateTime(LocalDateTime.now()));
+ image.voucherTotal(money(order.getTotalPrice() == null ? null : order.getTotalPrice().longValue()))
+ .keyValue("打印时间", formatDateTime(LocalDateTime.now()));
if (StrUtil.isNotBlank(code)) image.divider().sectionTitle("取件码").verificationCode(code);
try {
return uploadPng("invoice_" + order.getId(), image.toPngBytes());
@@ -303,6 +289,75 @@ public class HardInvoiceServiceImpl extends ServiceImpl发票是支付凭证,小计使用下单时的商品快照总额;套餐标签与组合内容沿用兑换券元数据和显示开关。
+ *
+ * @param image 当前发票图片绘制器
+ * @param cars 订单商品快照
+ * @param metadata 按商品 ID 索引的套餐类型和组成项
+ * @param showComboItems 是否展示套餐组成内容
+ */
+ private static void appendInvoiceProducts(ThermalReceiptImage image, List cars,
+ Map metadata, boolean showComboItems) {
+ image.divider().sectionTitle("商品明细");
+ boolean hasProduct = false;
+ for (HardCarVo car : cars) {
+ hasProduct = true;
+ // 历史购物车允许商品 ID 为空,空 ID 直接按单品降级。
+ ProductMetadata product = car.getProductId() == null ? null : metadata.get(car.getProductId());
+ boolean combo = product != null && product.combo();
+ List comboItems = combo && showComboItems
+ ? componentItemTexts(product.items(), invoiceComboQuantity(car)) : List.of();
+ image.voucherProduct(car.getProductName(), invoiceQuantityText(car), money(voucherUnitPrice(car)),
+ money(car.getTotalPrice()), combo, comboItems);
+ }
+ if (!hasProduct) image.item("无商品明细", "x0", false, false);
+ }
+
+ /**
+ * 计算发票商品数量展示文本:按份数商品显示 xN,称重商品显示快照重量。
+ *
+ * @param car 订单商品快照
+ * @return 商品数量文本
+ */
+ private static String invoiceQuantityText(HardCarVo car) {
+ if (car.getProductCount() != null && car.getProductCount() != 0) return "x" + car.getProductCount();
+ if (car.getProductWeight() != null && car.getProductWeight().compareTo(BigDecimal.ZERO) != 0) {
+ return car.getProductWeight() + "/kg";
+ }
+ return "x0";
+ }
+
+ /**
+ * 套餐组成数量的放大基数:套餐必为份数商品,称重行按零处理。
+ *
+ * @param car 订单商品快照
+ * @return 组成数量放大基数
+ */
+ private static int invoiceComboQuantity(HardCarVo car) {
+ return Math.max(0, ObjectUtil.defaultIfNull(car.getProductCount(), 0));
+ }
+
/**
* {@inheritDoc}
*/
@@ -755,7 +810,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl voucherOrders = loadVoucherBatchOrders(order);
MchManageVo merchant = requireData(remoteMchService.getById(order.getMerchantId()), "商户不存在");
UserVO user = loadVoucherUser(order.getUserId());
- ThermalReceiptImage image = new ThermalReceiptImage().title("预约核销兑换券")
+ ThermalReceiptImage image = new ThermalReceiptImage().compactFonts(true).title("预约核销兑换券")
.banner(String.format("第 %d 次打印 · %d 笔预约", ObjectUtil.defaultIfNull(printNo, 1), voucherOrders.size()))
.keyValue("商户", merchant.getName());
appendVoucherUser(image, user);
@@ -914,11 +969,22 @@ public class HardInvoiceServiceImpl extends ServiceImpl voucherComponentItems(VoucherProductLine product) {
if (!product.combo() || product.comboItems().isEmpty()) return List.of();
- return product.comboItems().stream().filter(Objects::nonNull)
+ return componentItemTexts(product.comboItems(), product.quantity());
+ }
+
+ /**
+ * 将套餐组成快照转换为票据说明文本,并按商品合计数量放大组成数量;兑换券与发票共用。
+ *
+ * @param items 套餐组成快照
+ * @param quantity 当前商品的合计数量放大基数
+ * @return 已包含总数量且按组成排序稳定排列的说明文本
+ */
+ static List componentItemTexts(List items, int quantity) {
+ return items.stream().filter(Objects::nonNull)
.sorted(Comparator.comparing(item ->
ObjectUtil.defaultIfNull(item.getSortOrder(), Integer.MAX_VALUE)))
.map(item -> StrUtil.blankToDefault(item.getName(), "未命名组成商品") + " x"
- + (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * product.quantity())
+ + (long) ObjectUtil.defaultIfNull(item.getQuantity(), 1) * Math.max(0, quantity))
.toList();
}
@@ -1272,17 +1338,6 @@ public class HardInvoiceServiceImpl extends ServiceImpl hardAfterSaleServiceProvider;
+ /**
+ * 自动核销服务延迟提供器,用于打破任务状态机、核销服务与订单服务之间的循环依赖。
+ */
+ private final ObjectProvider hardAutoVerifyServiceProvider;
+
/**
* Wallet 自动退款配置远程服务,用于受理和每次尝试前复核当前开关。
*/
@@ -252,6 +262,43 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl()
+ .eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
+ .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
+ if (existing != null) {
+ return existing;
+ }
+ HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
+ .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .orderId(order.getId()).mchId(order.getMerchantId())
+ .configId(order.getAutoVerifyConfigId())
+ .triggerTime(order.getAutoVerifyDeadline())
+ .restoreInventory(order.getAutoVerifyRestoreInventory())
+ .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .autoAttemptCount(0).totalAttemptCount(0)
+ .nextRetryTime(order.getAutoVerifyDeadline())
+ .build();
+ if (baseMapper.insert(task) != 1) {
+ throw new RuntimeException("创建自动核销任务失败");
+ }
+ return task;
+ }
+
/**
* 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。
*
@@ -397,6 +444,12 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) {
+ task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
+ task.setNextRetryTime(null);
+ baseMapper.updateById(task);
+ return;
+ }
+ completeAttempt(task, triggerType, operatorUserId, true);
+ return;
+ }
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
@@ -475,6 +563,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageVerifyCandidates(Page page, HardAutoRefundTaskPageDto dto) {
+ LocalDateTime now = LocalDateTime.now();
+ Page entityPage = baseMapper.pageVerifyCandidates(page, dto, now);
+ List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
+ fillOrderData(records);
+ fillUserData(records);
+ fillMerchantData(records);
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(records);
+ return result;
+ }
+
/**
* 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。
* 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。
@@ -1289,18 +1515,23 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl()
.and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.PENDING.getStatus())
- .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType,
- HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
- .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType,
- HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ // 售后任务无触发时间即扫;过期退款与自动核销任务必须到达快照触发时间后才可执行。
+ .and(nested -> nested.notIn(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
+ HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .or(triggered -> triggered.in(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
+ HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))
.or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
.le(HardAutoRefundTaskEntity::getNextRetryTime, now)
- .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
- HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
- .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
- HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .and(triggered -> triggered.notIn(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
+ HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .or(triggeredType -> triggeredType.in(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType(),
+ HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
.le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
@@ -1331,6 +1562,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl cars = hardCarService.getByOrderId(orderId);
+ if (ObjectUtil.isEmpty(cars)) {
+ throw new RuntimeException("自动核销订单缺少购物车,无法恢复库存");
+ }
+ List restoreCars = cars.stream()
+ .filter(car -> car != null && car.getProductId() != null)
+ .map(car -> HardCarEntity.builder()
+ .productId(car.getProductId())
+ .productCount(resolveEffectiveProductCount(car))
+ .build())
+ .filter(car -> car.getProductCount() != null && car.getProductCount() > 0)
+ .toList();
+ if (restoreCars.isEmpty()) {
+ throw new RuntimeException("自动核销订单没有可恢复库存的商品");
+ }
+ String inventoryBizId = "HARD_AUTO_VERIFY_ORDER:".concat(String.valueOf(orderId));
+ try {
+ hardOrderService.fastenerInventory(order, restoreCars, YesNoEnum.NO, null, inventoryBizId);
+ } catch (Exception exception) {
+ // 历史订单的当日菜谱可能已下架或清理,退库存属于尽力而为的修正动作(人工核销从不退库存);
+ // 失败只告警不阻断核销,订单仍按计划关闭,库存差异由运营在菜谱侧核对补偿。
+ log.warn("{}自动核销退库存失败,已降级跳过,orderId:{},exceptionType:{},message:{}",
+ TAG, orderId, exception.getClass().getSimpleName(), exception.getMessage(), exception);
+ }
+ }
+
+ /**
+ * 解析购物车行的剩余有效数量;历史数据缺少退款统计字段时回退为原始购买数量。
+ *
+ * @param car 订单购物车展示对象
+ * @return 扣除已完成退款后的剩余份数
+ */
+ private int resolveEffectiveProductCount(HardCarVo car) {
+ Integer count = car.getEffectiveProductCount() == null ? car.getProductCount() : car.getEffectiveProductCount();
+ return count == null ? 0 : Math.max(0, count);
+ }
+
+ /**
+ * 按商户当前自动核销配置重扫历史订单,补建或刷新未尝试任务;已到期任务立即进入核销状态机。
+ * 与过期退款不同:部分退款完成的订单仍可自动核销剩余商品,因此只排除处理中售后,不排除已完成售后。
+ *
+ * @param mchId 精确商户 ID
+ * @param operatorUserId 发起重扫的后台用户 ID,用于核销尝试审计
+ * @return 本轮扫描、任务同步和跳过数量
+ */
+ @Override
+ public HardAutoVerifyRescanResultVo rescanAutoVerifyOrders(Long mchId, Long operatorUserId) {
+ if (mchId == null) {
+ throw new RuntimeException("商户不能为空");
+ }
+ MchAutoRefundConfigVo config = loadCurrentConfig(mchId);
+ if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId)
+ || !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) {
+ throw new RuntimeException("当前商户未配置自动核销规则");
+ }
+ int verifyDays = config.getAutoVerifyDays() == null ? 0 : config.getAutoVerifyDays();
+ if (verifyDays < MIN_AUTO_VERIFY_DAYS || verifyDays > MAX_AUTO_VERIFY_DAYS) {
+ throw new RuntimeException("支付后自动核销天数必须在1到365天之间");
+ }
+ boolean enabled = Objects.equals(config.getAutoVerifyEnabled(), YesNoEnum.YES.getCode());
+ String restoreInventory = Objects.equals(config.getAutoVerifyRestoreInventory(), YesNoEnum.YES.getCode())
+ ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
+ // 只有支付时间已超过配置天数的订单才具备重扫意义,其余订单由支付时创建的任务接管。
+ LocalDateTime payTimeDeadline = LocalDateTime.now().minusDays(verifyDays);
+ List orders = autoRefundTaskMapper.listAutoVerifyRescanOrders(mchId, payTimeDeadline);
+ HardAutoVerifyRescanResultVo result = new HardAutoVerifyRescanResultVo();
+ result.setScannedOrderCount(orders == null ? 0 : orders.size());
+ result.setCreatedTaskCount(0);
+ result.setRefreshedTaskCount(0);
+ result.setSkippedOrderCount(0);
+ if (orders == null || orders.isEmpty()) {
+ return result;
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ for (HardOrderEntity order : orders) {
+ boolean taskSynced = false;
+ try {
+ if (order.getPayTime() == null) {
+ result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
+ log.warn("{}跳过缺少支付时间的订单,orderId:{}", TAG, order.getId());
+ continue;
+ }
+ LocalDateTime triggerTime = order.getPayTime().plusDays(verifyDays);
+ LockInfo syncLock = lockTemplate.lock(buildOrderLockKey(order.getId()),
+ ORDER_LOCK_EXPIRE_MILLIS, ORDER_LOCK_WAIT_MILLIS);
+ if (syncLock == null) {
+ result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
+ continue;
+ }
+ int syncResult;
+ try {
+ syncResult = getSelf().syncAutoVerifyRescanTask(order, config, triggerTime, restoreInventory, enabled);
+ } finally {
+ try {
+ lockTemplate.releaseLock(syncLock);
+ } catch (Exception exception) {
+ log.warn("{}释放重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(),
+ exception.getClass().getSimpleName());
+ }
+ }
+ if (syncResult == RESCAN_SYNC_CREATED) {
+ result.setCreatedTaskCount(result.getCreatedTaskCount() + 1);
+ } else if (syncResult == RESCAN_SYNC_REFRESHED) {
+ result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1);
+ } else {
+ result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
+ continue;
+ }
+ taskSynced = true;
+ if (enabled && !triggerTime.isAfter(now)) {
+ HardAutoRefundTaskEntity task = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper()
+ .eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
+ .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
+ if (task != null && List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
+ HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus())) {
+ hardAutoRefundTaskService.execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN,
+ operatorUserId);
+ }
+ }
+ } catch (Exception exception) {
+ if (!taskSynced) {
+ result.setSkippedOrderCount(result.getSkippedOrderCount() + 1);
+ }
+ log.warn("{}重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(),
+ exception.getClass().getSimpleName());
+ }
+ }
+ return result;
+ }
+
+ /**
+ * 同步单个历史订单的自动核销任务。已成功、已跳过或已产生尝试的任务不允许被新配置覆盖,
+ * 避免重扫把已经核销或人工介入过的订单再次排队;部分退款完成的订单保留核销资格。
+ *
+ * @param order 历史已支付未核销订单
+ * @param config 当前商户自动核销配置
+ * @param triggerTime 按当前天数以订单支付时间重新计算的触发时间
+ * @param restoreInventory 当前退库存快照
+ * @param enabled 当前自动核销开关
+ * @return 新建、刷新或跳过结果编码
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public int syncAutoVerifyRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config,
+ LocalDateTime triggerTime, String restoreInventory, boolean enabled) {
+ if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) {
+ return RESCAN_SYNC_SKIPPED;
+ }
+ HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId());
+ if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId())
+ || !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
+ || !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())
+ || currentOrder.getPayTime() == null) {
+ return RESCAN_SYNC_SKIPPED;
+ }
+ // 只排除处理中售后;已完成的部分退款不剥夺剩余商品的自动核销资格。
+ Long pendingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper()
+ .eq(HardAfterSaleEntity::getOrderId, currentOrder.getId())
+ .eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus()));
+ if (pendingAfterSaleCount != null && pendingAfterSaleCount > 0) {
+ return RESCAN_SYNC_SKIPPED;
+ }
+ HardAutoRefundTaskEntity existing = autoRefundTaskMapper.selectOne(new LambdaQueryWrapper()
+ .eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId())
+ .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType()));
+ if (existing != null) {
+ boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
+ HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(),
+ HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus(),
+ HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus()).contains(existing.getStatus());
+ if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId())
+ || existing.getAutoAttemptCount() != null && existing.getAutoAttemptCount() > 0) {
+ return RESCAN_SYNC_SKIPPED;
+ }
+ existing.setConfigId(config.getId());
+ existing.setTriggerTime(triggerTime);
+ existing.setRestoreInventory(restoreInventory);
+ existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
+ : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
+ existing.setNextRetryTime(enabled ? triggerTime : null);
+ existing.setLastError(null);
+ if (autoRefundTaskMapper.updateById(existing) != 1) {
+ throw new RuntimeException("刷新自动核销任务失败");
+ }
+ refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory);
+ return RESCAN_SYNC_REFRESHED;
+ }
+ HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
+ .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId())
+ .configId(config.getId()).triggerTime(triggerTime)
+ .restoreInventory(restoreInventory)
+ .status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus()
+ : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus())
+ .autoAttemptCount(0).totalAttemptCount(0)
+ .nextRetryTime(enabled ? triggerTime : null)
+ .build();
+ if (autoRefundTaskMapper.insert(task) != 1) {
+ throw new RuntimeException("创建自动核销任务失败");
+ }
+ refreshOrderAutoVerifySnapshot(currentOrder.getId(), config.getId(), triggerTime, restoreInventory);
+ return RESCAN_SYNC_CREATED;
+ }
+
+ /**
+ * 重扫补建或刷新任务时同步写入订单自动核销快照,保持订单数据与任务触发口径一致;
+ * 已存在更新快照的订单也会被覆盖为当前配置,因为任务尚未产生任何尝试。
+ *
+ * @param orderId 订单 ID
+ * @param configId 当前配置 ID
+ * @param triggerTime 重算后的触发时间
+ * @param restoreInventory 当前退库存快照
+ */
+ private void refreshOrderAutoVerifySnapshot(Long orderId, Long configId, LocalDateTime triggerTime,
+ String restoreInventory) {
+ HardOrderEntity orderUpdate = HardOrderEntity.builder()
+ .id(orderId)
+ .autoVerifyConfigId(configId)
+ .autoVerifyDeadline(triggerTime)
+ .autoVerifyRestoreInventory(restoreInventory)
+ .build();
+ if (hardOrderMapper.updateById(orderUpdate) != 1) {
+ throw new RuntimeException("刷新自动核销订单快照失败");
+ }
+ }
+
+ /**
+ * 查询商户当前自动核销配置;配置服务异常按未配置处理,由调用方提示业务错误。
+ *
+ * @param mchId 商户 ID
+ * @return 当前配置;未配置或响应异常时返回 {@code null}
+ */
+ private MchAutoRefundConfigVo loadCurrentConfig(Long mchId) {
+ R result;
+ try {
+ result = remoteConfigService.getExact(mchId, SUPPORTED_ORDER_TYPE);
+ } catch (Exception exception) {
+ log.warn("{}查询配置失败,mchId:{},exceptionType:{}", TAG, mchId, exception.getClass().getSimpleName());
+ return null;
+ }
+ if (result == null || !result.isOk()) {
+ return null;
+ }
+ return result.getData();
+ }
+
+ /**
+ * 构造与退款、售后共用的订单 Redis 锁键,保证自动核销重扫与任务执行、人工操作互斥。
+ *
+ * @param orderId 订单 ID
+ * @return 订单级分布式锁键
+ */
+ private String buildOrderLockKey(Long orderId) {
+ return ORDER_LOCK_PREFIX.concat(String.valueOf(orderId));
+ }
+
+ /**
+ * 获取当前 Spring 代理,保证类内调用的事务方法经过事务拦截器。
+ *
+ * @return 当前核销服务代理
+ */
+ private HardAutoVerifyServiceImpl getSelf() {
+ return SpringUtil.getBean(getClass());
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 04d410625..888b47acc 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -56,6 +56,7 @@ import com.bsd.pjcloud.hard.api.constant.HardThirdMiStatusEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderVerificationTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.xfj.HardXfjTypeEnum;
@@ -90,6 +91,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.WardMenuMapDayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
@@ -191,6 +193,12 @@ public class HardOrderServiceImpl extends ServiceImpl(distinctIds);
}
+ /**
+ * app 预约菜谱日历地图:按用餐日聚合未退款预约订单的待核销/已核销数量。
+ * 整单退完(退款金额为正且不低于实付金额)的订单不再计入。
+ */
+ @Override
+ public List wardMenuMap(HardOrderDto dto) {
+ List orders = scopeList(dto, null);
+ Map dayMap = new TreeMap<>();
+ for (HardOrderVo order : orders) {
+ if (order.getApptUseDate() == null || isFullyRefundedOrder(order)) {
+ continue;
+ }
+ WardMenuMapDayVo day = dayMap.computeIfAbsent(order.getApptUseDate(), date -> {
+ WardMenuMapDayVo item = new WardMenuMapDayVo();
+ item.setApptUseDate(date);
+ item.setPendingCount(0);
+ item.setVerifiedCount(0);
+ return item;
+ });
+ if (HardOrderStatusEnum.PAY.getType().equals(order.getOrderStatus())) {
+ day.setPendingCount(day.getPendingCount() + 1);
+ } else if (HardOrderStatusEnum.CANCEL.getType().equals(order.getOrderStatus())) {
+ day.setVerifiedCount(day.getVerifiedCount() + 1);
+ }
+ }
+ return new ArrayList<>(dayMap.values());
+ }
+
+ /**
+ * app 预约菜谱某日明细:返回当天未整单退款的待核销/已核销预约订单,与日历地图同口径。
+ */
+ @Override
+ public List wardMenuDayOrders(HardOrderDto dto) {
+ return scopeList(dto, null).stream()
+ .filter(order -> !isFullyRefundedOrder(order))
+ .toList();
+ }
+
+ /**
+ * 判断订单是否已整单退完:退款金额为正且不低于实付金额时视为整单退款,菜谱地图不再展示。
+ */
+ private boolean isFullyRefundedOrder(HardOrderVo order) {
+ Integer payPrice = order.getPayPrice();
+ Integer refundPrice = order.getRefundPrice();
+ return refundPrice != null && payPrice != null && refundPrice > 0 && refundPrice >= payPrice;
+ }
+
/**
* 按商户配置校验代订关系和用餐人身份,服务端必须再次校验以防绕过 App 限制。
*
@@ -2704,6 +2761,21 @@ public class HardOrderServiceImpl extends ServiceImpl= MIN_AUTO_VERIFY_DAYS
+ && autoRefundConfig.getAutoVerifyDays() <= MAX_AUTO_VERIFY_DAYS
+ && autoRefundConfig.getId() != null) {
+ autoVerifyConfigId = autoRefundConfig.getId();
+ autoVerifyDeadline = payTime.plusDays(autoRefundConfig.getAutoVerifyDays());
+ autoVerifyRestoreInventory = Objects.equals(autoRefundConfig.getAutoVerifyRestoreInventory(),
+ YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode();
+ }
HardOrderEntity orderUpdate = HardOrderEntity.builder()
.id(order.getId())
.payTime(payTime)
@@ -2716,6 +2788,9 @@ public class HardOrderServiceImpl extends ServiceImpl()
@@ -3983,7 +4109,10 @@ public class HardOrderServiceImpl extends ServiceImpl result = remoteCodeUseService.isCancelCode(orderId);
+ return result != null && result.getCode() == 0 && Boolean.TRUE.equals(result.getData());
+ } catch (Exception exception) {
+ log.warn("|{}|查询预约二维码状态失败,按未消费处理,orderId:{}", TAG, orderId);
+ return false;
+ }
+ }
+
+ /**
+ * 自动核销静默路径只消费 Wallet 预约二维码,不触发取餐广播等附属动作;
+ * 二维码业务号解析规则与 {@link #cancelCode} 保持一致,消费失败同样抛出核销失败异常。
+ *
+ * @param order 已持有数据库行锁的预约订单
+ */
+ private void consumeReservationQrCode(HardOrderEntity order) {
+ HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType());
+ if (payType == null) {
+ throw new I18nBizException(ErrorCodes.HARD_ACT_QR_BAD);
+ }
+ Long id = order.getId();
+ if (HardPayTypeEnum.isNotWalletPay.contains(payType) && ObjectUtil.isNotEmpty(order.getOrderSourceTypeId())) {
+ id = order.getOrderSourceTypeId();
+ }
+ R result = remoteCodeUseService.cancelCode(id);
+ if (ObjectUtil.isEmpty(result) || result.getCode() != 0 || !result.getData()) {
+ throw new I18nBizException(ErrorCodes.HARD_ACT_QR_BAD);
+ }
+ }
+
/**
* 原子批量核销超市、食堂预约订单。
*
@@ -4054,12 +4225,14 @@ public class HardOrderServiceImpl extends ServiceImpl()
.eq(HardOrderEntity::getId, order.getId())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType()));
if (updated == 0) throw new I18nBizException(ErrorCodes.HARD_VERIFY_STATUS_CHANGED);
order.setVerificationTime(verificationTime);
+ order.setVerificationType(HardOrderVerificationTypeEnum.MANUAL.getType());
order.setHardId(sourceHardId);
order.setOrderStatus(HardOrderStatusEnum.CANCEL.getType());
if (ObjectUtil.equals(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
@@ -4251,6 +4424,7 @@ public class HardOrderServiceImpl extends ServiceImpl()
.set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.PAY.getType())
.set(HardOrderEntity::getVerificationTime, null)
+ .set(HardOrderEntity::getVerificationType, null)
.set(HardOrderEntity::getHardId, null)
.set(HardOrderEntity::getAndroidOrderCode, null)
.eq(HardOrderEntity::getId, order.getId())
@@ -4284,6 +4458,10 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList,
YesNoEnum isReduce, Long afterSaleId, String inventoryBizId,
boolean validateOnly) {
- if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) {
+ // 恢复库存必须携带幂等隔离键:售后场景用售后单 ID,自动核销等非售后场景由调用方传入稳定业务号。
+ if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null && StrUtil.isBlank(inventoryBizId)) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_AFTERSALE_MISSING);
}
List carList = hardCarService.getByParentCarId(order.getParentCarId());
@@ -4643,30 +4822,28 @@ public class HardOrderServiceImpl extends ServiceImpl inventorySnapshot = new LinkedHashMap<>();
+ Map dailyChanges;
+ Map basicChanges = Map.of();
- if (dailyMenu) {
+ if (markedDailyMenu) {
+ dailyChanges = stockChanges;
if (mealDate == null || hourType == null) {
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
}
// 库存恢复可能在自动退款定时任务等无用户凭证的上下文执行,必须走 @NoToken 内部菜谱接口;
// getCanteenList 是 AD 终端登录态接口,任务线程调用会被 424 拒绝。
- R> menuResult = remoteDailyMenuService.listForOrderValidate(
- mealDate.toString(), hourType, order.getMerchantId());
- if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
- throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
- : ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
- }
- Map menuBySpu = menuResult.getData().stream()
- .filter(Objects::nonNull)
- .filter(row -> row.getSpuId() != null)
- .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
+ Map menuBySpu = loadDailyMenuForInventorySplit(
+ mealDate, hourType, order.getMerchantId(), false);
for (Map.Entry entry : stockChanges.entrySet()) {
DailyMenuAppVO row = menuBySpu.get(entry.getKey());
if (row == null) {
@@ -4680,34 +4857,55 @@ public class HardOrderServiceImpl extends ServiceImpl> productResult = remoteCanteenSpuService
- .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet()));
- if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
- if (productResult == null) {
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
- }
- if (StrUtil.isNotBlank(productResult.getMsg())) {
- throw new RuntimeException(productResult.getMsg());
- }
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
- }
- Map productMap = productResult.getData().stream()
- .filter(Objects::nonNull)
- .filter(product -> product.getId() != null)
- .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
+ // 设备购买当日菜谱:按订单日期餐段在售情况分流,日期或餐段缺失时全部按基础 SPU 库存处理。
+ Map menuBySpu = loadDailyMenuForInventorySplit(
+ mealDate, hourType, order.getMerchantId(), true);
+ dailyChanges = new LinkedHashMap<>();
+ Map unresolvedChanges = new LinkedHashMap<>();
for (Map.Entry entry : stockChanges.entrySet()) {
- CanteenSpuEntity product = productMap.get(entry.getKey());
- if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ DailyMenuAppVO row = menuBySpu.get(entry.getKey());
+ if (row == null) {
+ unresolvedChanges.put(entry.getKey(), entry.getValue());
+ continue;
}
- int productType = product.getSpuType() == null ? 1 : product.getSpuType();
- if (productType != 1 && productType != 2) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
+ int channelRemain = onlineChannel ? dailyOnlineRemain(row) : dailyOfflineRemain(row);
+ if (entry.getValue() < 0 && channelRemain < Math.abs(entry.getValue())) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName());
}
- if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
+ inventorySnapshot.put(entry.getKey(), channelRemain);
+ dailyChanges.put(entry.getKey(), entry.getValue());
+ }
+ basicChanges = unresolvedChanges;
+ if (!basicChanges.isEmpty()) {
+ R> productResult = remoteCanteenSpuService
+ .getInnerSpuListByIds(new ArrayList<>(basicChanges.keySet()));
+ if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
+ if (productResult == null) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
+ }
+ if (StrUtil.isNotBlank(productResult.getMsg())) {
+ throw new RuntimeException(productResult.getMsg());
+ }
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
+ }
+ Map productMap = productResult.getData().stream()
+ .filter(Objects::nonNull)
+ .filter(product -> product.getId() != null)
+ .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
+ for (Map.Entry entry : basicChanges.entrySet()) {
+ CanteenSpuEntity product = productMap.get(entry.getKey());
+ if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ }
+ int productType = product.getSpuType() == null ? 1 : product.getSpuType();
+ if (productType != 1 && productType != 2) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
+ }
+ if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
+ }
+ inventorySnapshot.put(entry.getKey(), product.getStock());
}
- inventorySnapshot.put(entry.getKey(), product.getStock());
}
}
if (validateOnly) return;
@@ -4717,19 +4915,48 @@ public class HardOrderServiceImpl extends ServiceImpl loadDailyMenuForInventorySplit(LocalDate mealDate, Integer hourType,
+ Long merchantId, boolean skipWhenUnknown) {
+ if (mealDate == null || hourType == null) {
+ if (skipWhenUnknown) {
+ return Map.of();
+ }
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ }
+ // 必须走 @NoToken 内部菜谱接口:设备支付回调和自动退款任务都没有用户凭证,登录态接口会 424。
+ R> menuResult = remoteDailyMenuService.listForOrderValidate(
+ mealDate.toString(), hourType, merchantId);
+ if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
+ throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
+ : ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
+ }
+ return menuResult.getData().stream()
+ .filter(Objects::nonNull)
+ .filter(row -> row.getSpuId() != null)
+ .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
+ }
+
/**
* 当前 Hardware 本地事务回滚时反向撤销已经尝试的食堂基础 SPU 库存操作,
* 覆盖远程成功但本地提交失败的窗口。
@@ -5197,6 +5424,11 @@ public class HardOrderServiceImpl extends ServiceImpl
+
+
+
+
+
+
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index ec63ae1d5..5af00bd24 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -40,6 +40,9 @@
a.expired_refund_usage_end_time,
a.expired_refund_deadline,
a.expired_refund_restore_inventory,
+ a.auto_verify_config_id,
+ a.auto_verify_deadline,
+ a.auto_verify_restore_inventory,
a.sys_type,
a.total_price,
a.extra_price,
@@ -51,6 +54,7 @@
a.refund_time,
a.refund_status,
a.verification_time,
+ a.verification_type,
a.apply_cancel_time,
a.unfollow_time,
a.android_order_code,
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 cce119758..25b32d587 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
@@ -79,6 +79,36 @@ class ThermalReceiptImageTest {
"单价、小计和合计金额应保留右侧数字区域");
}
+ /** 发票复用兑换券卡片版式时,称重数量文本应原样展示并保持卡片结构。 */
+ @Test
+ 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"))
+ .voucherTotal("42.00").toPngBytes()));
+
+ assertEquals(ThermalReceiptImage.PAPER_WIDTH_PIXELS, image.getWidth());
+ assertTrue(hasDarkPixel(image, 20, 36, 0, image.getHeight()), "商品卡片左边框应清晰可见");
+ assertTrue(hasDarkPixel(image, 430, image.getWidth(), 0, image.getHeight()),
+ "称重数量、单价、小计和合计金额应保留右侧数字区域");
+ }
+
+ /** 紧凑字号整体缩小全部版式:相同内容下票据更矮,且商品卡片内容仍完整保留。 */
+ @Test
+ void compactFontsShrinkAllAreasButKeepProductCardsIntact() throws Exception {
+ ThermalReceiptImage normal = invoiceSample(false);
+ ThermalReceiptImage compact = invoiceSample(true);
+
+ BufferedImage normalImage = ImageIO.read(new ByteArrayInputStream(normal.toPngBytes()));
+ BufferedImage compactImage = ImageIO.read(new ByteArrayInputStream(compact.toPngBytes()));
+
+ assertTrue(compactImage.getHeight() < normalImage.getHeight(),
+ "紧凑字号应压缩非商品区高度");
+ assertTrue(hasDarkPixel(compactImage, 24, 552, 150, compactImage.getHeight()),
+ "商品卡片内容应完整保留在紧凑票据中");
+ }
+
/** 组合商品的黑底标签和粗边框应比同内容单品形成更强的黑白视觉层级。 */
@Test
void comboProductCardHasStrongerVisualWeightThanSingleProductCard() throws Exception {
@@ -168,6 +198,31 @@ class ThermalReceiptImageTest {
"合并版式不应重复绘制每笔订单的整套商品卡片");
}
+ /**
+ * 构造与发票装配顺序一致的样张。
+ *
+ * @param compact 是否启用非商品区紧凑字号
+ * @return 已完成绘制的发票样张绘制器
+ */
+ private static ThermalReceiptImage invoiceSample(boolean compact) {
+ return new ThermalReceiptImage().compactFonts(compact)
+ .title("智慧食堂发票")
+ .keyValue("商户", "青鸟同文")
+ .keyValue("钱包类型", "就餐钱包")
+ .divider().sectionTitle("用户信息")
+ .keyValue("姓名", "邓樟晨")
+ .keyValue("学号", "1231312")
+ .divider().sectionTitle("订单 2096786045923254273")
+ .keyValue("订单类型", "线下购买")
+ .keyValue("下单时间", "2026-09-14 12:03:01")
+ .divider().sectionTitle("商品明细")
+ .voucherProduct("A套餐-糖醋排骨", "x1", "17.00", "17.00", true, List.of("糖醋排骨 x1", "米饭 x1"))
+ .voucherProduct("红糖发糕", "x1", "1.60", "1.60", false, List.of())
+ .keyValue("额外支付金额", "¥0.50")
+ .voucherTotal("18.60")
+ .keyValue("打印时间", "2026-09-14 12:03:05");
+ }
+
private static boolean hasDarkPixel(BufferedImage image, int fromX, int toX, int fromY, int toY) {
for (int y = fromY; y < toY; y++) {
for (int x = fromX; x < toX; x++) {
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
index 4c5a34135..4f02020eb 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImplTest.java
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.hard.service.impl.order;
+import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
@@ -10,6 +11,7 @@ import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
@@ -18,6 +20,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
+import com.bsd.pjcloud.hard.service.HardAutoVerifyService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -40,9 +43,13 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -62,6 +69,12 @@ class HardAutoRefundTaskServiceImplTest {
/** 售后服务延迟提供器 Mock。 */
@Mock
private ObjectProvider afterSaleServiceProvider;
+ /** 自动核销服务延迟提供器 Mock。 */
+ @Mock
+ private ObjectProvider autoVerifyServiceProvider;
+ /** 自动核销服务 Mock,验证状态机委托核销动作时的参数。 */
+ @Mock
+ private HardAutoVerifyService autoVerifyService;
/** Wallet 自动退款配置远程服务 Mock。 */
@Mock
private RemoteMchAutoRefundConfigService configService;
@@ -92,10 +105,13 @@ class HardAutoRefundTaskServiceImplTest {
@BeforeEach
void setUp() {
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper,
- afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate);
+ afterSaleServiceProvider, autoVerifyServiceProvider, configService, walletRechargeService,
+ mchService, userService, lockTemplate);
ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
+ lenient().when(attemptLogMapper.updateById(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
+ lenient().when(autoVerifyServiceProvider.getObject()).thenReturn(autoVerifyService);
}
/**
@@ -279,6 +295,193 @@ class HardAutoRefundTaskServiceImplTest {
.autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build();
}
+ /** 订单已人工核销或退款时,自动核销任务应直接进入跳过终态并写跳过日志。 */
+ @Test
+ void autoVerifyTaskShouldSkipWhenOrderTerminal() {
+ HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+ when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
+ when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
+ .id(3L).orderStatus(HardOrderStatusEnum.CANCEL.getType()).build());
+
+ service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
+
+ assertEquals(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus(), task.getStatus());
+ assertNull(task.getNextRetryTime());
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HardAutoRefundAttemptLogEntity.class);
+ verify(attemptLogMapper).insert(captor.capture());
+ assertEquals("50", captor.getValue().getResultStatus());
+ verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject();
+ }
+
+ /** 订单售后处理中时应延后 24 小时重试,且不消耗自动尝试额度、不调用核销服务。 */
+ @Test
+ void autoVerifyTaskShouldDeferWhenRefundPending() {
+ HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+ when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(true)));
+ when(orderMapper.selectByIdForUpdate(3L)).thenReturn(HardOrderEntity.builder()
+ .id(3L)
+ .orderStatus(HardOrderStatusEnum.PAY.getType())
+ .refundStatus(HardRefundStatusEnum.APPLY.getStatus())
+ .build());
+ LocalDateTime before = LocalDateTime.now();
+
+ service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
+
+ assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
+ assertEquals(0, task.getAutoAttemptCount());
+ assertTrue(!task.getNextRetryTime().isBefore(before.plusHours(24))
+ && !task.getNextRetryTime().isAfter(LocalDateTime.now().plusHours(24)));
+ verify(autoVerifyServiceProvider, org.mockito.Mockito.never()).getObject();
+ }
+
+ /** 自动核销开关关闭时应沿用退款任务的暂停语义,任务保持开关关闭暂停等待重新开启。 */
+ @Test
+ void autoVerifyTaskShouldPauseWhenSwitchDisabled() {
+ HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+ when(configService.getExact(4L, "2")).thenReturn(R.ok(verifyConfig(false)));
+
+ service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.SCHEDULED, null);
+
+ assertEquals(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), task.getStatus());
+ verify(orderMapper, org.mockito.Mockito.never()).selectByIdForUpdate(any());
+ }
+
+ /** 触发时间未到或任务已跳过终态时,canExecute 都不允许自动核销任务进入执行。 */
+ @Test
+ void autoVerifyTaskShouldRespectTriggerTimeAndSkippedStatus() {
+ HardAutoRefundTaskEntity futureTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ futureTask.setTriggerTime(LocalDateTime.now().plusDays(1));
+ Boolean futureAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", futureTask,
+ HardAutoRefundTriggerTypeEnum.SCHEDULED);
+ HardAutoRefundTaskEntity dueTask = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ Boolean dueAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", dueTask,
+ HardAutoRefundTriggerTypeEnum.SCHEDULED);
+ HardAutoRefundTaskEntity skippedTask = verifyTask(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus());
+ Boolean skippedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", skippedTask,
+ HardAutoRefundTriggerTypeEnum.SCHEDULED);
+
+ assertEquals(Boolean.FALSE, futureAllowed);
+ assertEquals(Boolean.TRUE, dueAllowed);
+ assertEquals(Boolean.FALSE, skippedAllowed);
+ }
+
+ /** 同一订单已存在自动核销任务时保持幂等复用,不再重复插入。 */
+ @Test
+ void createAutoVerifyTaskShouldReuseExistingTask() {
+ HardAutoRefundTaskEntity existing = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ when(taskMapper.selectOne(any())).thenReturn(existing);
+ HardOrderEntity order = HardOrderEntity.builder().id(3L).merchantId(4L)
+ .orderType(HardOrderTypeEnum.RESERVE.getType())
+ .orderStatus(HardOrderStatusEnum.PAY.getType())
+ .payType(HardPayTypeEnum.WALLET_DEFAULT.getType())
+ .autoVerifyConfigId(5L)
+ .autoVerifyDeadline(LocalDateTime.now().plusDays(7))
+ .build();
+
+ HardAutoRefundTaskEntity result = service.createAutoVerifyTask(order);
+
+ assertEquals(existing, result);
+ verify(taskMapper, org.mockito.Mockito.never()).insert(any(HardAutoRefundTaskEntity.class));
+ }
+
+ /** 后台逐单立即核销应跳过配置与开关复核,直接以手动触发来源执行且不消耗自动尝试额度。 */
+ @Test
+ void autoVerifyManualShouldBypassConfigAndSwitch() {
+ HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ task.setTriggerTime(LocalDateTime.now().plusDays(2));
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+
+ service.executeAttemptInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L);
+
+ assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus());
+ assertEquals(0, task.getAutoAttemptCount());
+ verify(configService, org.mockito.Mockito.never()).getExact(any(), any());
+ verify(autoVerifyService).completeAutoVerify(task, false);
+ }
+
+ /** 后台逐单立即核销允许提前关闭未到期订单,准入状态与候选列表一致;终态和重试耗尽任务不可手动核销。 */
+ @Test
+ void autoVerifyManualShouldOnlyAcceptCandidateStatuses() {
+ HardAutoRefundTaskEntity futurePending = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ futurePending.setTriggerTime(LocalDateTime.now().plusDays(2));
+ Boolean futureAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", futurePending,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+ HardAutoRefundTaskEntity paused = verifyTask(HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus());
+ paused.setTriggerTime(LocalDateTime.now().plusDays(2));
+ Boolean pausedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", paused,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+ HardAutoRefundTaskEntity waiting = verifyTask(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus());
+ Boolean waitingAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", waiting,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+ HardAutoRefundTaskEntity configPaused = verifyTask(HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus());
+ Boolean configPausedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", configPaused,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+ HardAutoRefundTaskEntity skipped = verifyTask(HardAutoRefundTaskStatusEnum.SKIPPED.getStatus());
+ Boolean skippedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", skipped,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+ HardAutoRefundTaskEntity exhausted = verifyTask(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
+ Boolean exhaustedAllowed = ReflectionTestUtils.invokeMethod(service, "canExecute", exhausted,
+ HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL);
+
+ assertEquals(Boolean.TRUE, futureAllowed);
+ assertEquals(Boolean.TRUE, pausedAllowed);
+ assertEquals(Boolean.TRUE, waitingAllowed);
+ assertEquals(Boolean.TRUE, configPausedAllowed);
+ assertEquals(Boolean.FALSE, skippedAllowed);
+ assertEquals(Boolean.FALSE, exhaustedAllowed);
+ }
+
+ /** 手动立即核销失败不消耗自动次数(autoAttempts=0),退避下标必须钳制,历史缺陷曾因此取 [-1] 越界。 */
+ @Test
+ void manualVerifyFailureShouldClampRetryDelayIndex() {
+ HardAutoRefundTaskEntity task = verifyTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
+ when(taskMapper.selectByIdForUpdate(1L)).thenReturn(task);
+ LocalDateTime before = LocalDateTime.now();
+
+ service.recordFailureInTransaction(1L, HardAutoRefundTriggerTypeEnum.AUTO_VERIFY_MANUAL, 99L, "恢复库存失败");
+
+ assertEquals(HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus(), task.getStatus());
+ assertEquals(1, task.getTotalAttemptCount());
+ assertEquals(0, task.getAutoAttemptCount());
+ assertEquals("恢复库存失败", task.getLastError());
+ assertTrue(!task.getNextRetryTime().isBefore(before.plusMinutes(1))
+ && !task.getNextRetryTime().isAfter(LocalDateTime.now().plusMinutes(1)));
+ }
+
+ /**
+ * 构造已到触发时间的自动核销任务。
+ *
+ * @param status 当前任务状态
+ * @return 可供执行分支使用的任务
+ */
+ private HardAutoRefundTaskEntity verifyTask(String status) {
+ return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L)
+ .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .triggerTime(LocalDateTime.now().minusMinutes(1)).status(status)
+ .restoreInventory(YesNoEnum.NO.getCode())
+ .autoAttemptCount(0).totalAttemptCount(0).build();
+ }
+
+ /**
+ * 构造自动核销已开启且身份匹配的任务配置。
+ *
+ * @param enabled 自动核销开关
+ * @return 与任务快照一致的当前配置
+ */
+ private MchAutoRefundConfigVo verifyConfig(boolean enabled) {
+ MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
+ config.setId(5L);
+ config.setMchId(4L);
+ config.setOrderType("2");
+ config.setAutoVerifyEnabled(enabled ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
+ config.setAutoVerifyDays(7);
+ config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode());
+ return config;
+ }
+
/**
* 构造已到期的每日菜谱过期退款任务。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java
new file mode 100644
index 000000000..82bd9ee0f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoVerifyServiceImplTest.java
@@ -0,0 +1,231 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.baomidou.lock.LockTemplate;
+import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
+import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
+import com.bsd.pjcloud.hard.service.HardCarService;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
+import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 过期未核销自动核销执行与重扫配置测试:静默核销、按剩余数量退库存和非法配置拦截。
+ */
+@ExtendWith(MockitoExtension.class)
+class HardAutoVerifyServiceImplTest {
+
+ /** 自动退款任务 Mapper Mock,重扫流程使用。 */
+ @Mock
+ private HardAutoRefundTaskMapper autoRefundTaskMapper;
+ /** 订单 Mapper Mock。 */
+ @Mock
+ private HardOrderMapper hardOrderMapper;
+ /** 售后 Mapper Mock。 */
+ @Mock
+ private HardAfterSaleMapper afterSaleMapper;
+ /** 购物车服务 Mock,提供剩余有效份数。 */
+ @Mock
+ private HardCarService hardCarService;
+ /** 订单服务 Mock,提供静默核销和库存恢复入口。 */
+ @Mock
+ private HardOrderService hardOrderService;
+ /** 自动退款任务服务 Mock,重扫后驱动到期任务执行。 */
+ @Mock
+ private HardAutoRefundTaskService hardAutoRefundTaskService;
+ /** Wallet 配置远程服务 Mock。 */
+ @Mock
+ private RemoteMchAutoRefundConfigService remoteConfigService;
+ /** 订单分布式锁模板 Mock。 */
+ @Mock
+ private LockTemplate lockTemplate;
+ /** 被测自动核销服务。 */
+ private HardAutoVerifyServiceImpl service;
+
+ /**
+ * 按构造器注入全部依赖。
+ */
+ @BeforeEach
+ void setUp() {
+ service = new HardAutoVerifyServiceImpl(autoRefundTaskMapper, hardOrderMapper, afterSaleMapper,
+ hardCarService, hardOrderService, hardAutoRefundTaskService, remoteConfigService, lockTemplate);
+ }
+
+ /** 触发时间未到的任务不允许提前核销,防止调度竞态绕过商户配置的时限。 */
+ @Test
+ void completeAutoVerifyShouldRejectFutureTrigger() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().plusDays(1));
+
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task));
+
+ assertEquals("自动核销尚未到达触发时间", exception.getMessage());
+ verify(hardOrderService, never()).autoVerifyReserveOrder(any());
+ }
+
+ /** 退库存开关开启时按剩余有效份数回补线上池:已退款份数不重复回补,幂等业务号按订单固定。 */
+ @Test
+ void completeAutoVerifyShouldRestoreInventoryWithEffectiveCounts() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
+ HardOrderEntity order = HardOrderEntity.builder().id(1L).build();
+ when(hardOrderMapper.selectById(1L)).thenReturn(order);
+ HardCarVo partiallyRefunded = new HardCarVo();
+ partiallyRefunded.setProductId(11L);
+ partiallyRefunded.setProductCount(3);
+ partiallyRefunded.setEffectiveProductCount(2);
+ HardCarVo fullyRefunded = new HardCarVo();
+ fullyRefunded.setProductId(12L);
+ fullyRefunded.setProductCount(2);
+ fullyRefunded.setEffectiveProductCount(0);
+ when(hardCarService.getByOrderId(1L)).thenReturn(List.of(partiallyRefunded, fullyRefunded));
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+
+ service.completeAutoVerify(task);
+
+ verify(hardOrderService).autoVerifyReserveOrder(1L);
+ verify(hardOrderService).fastenerInventory(eq(order), captor.capture(), eq(YesNoEnum.NO), isNull(),
+ eq("HARD_AUTO_VERIFY_ORDER:1"));
+ assertEquals(1, captor.getValue().size());
+ assertEquals(11L, captor.getValue().get(0).getProductId());
+ assertEquals(2, captor.getValue().get(0).getProductCount());
+ }
+
+ /** 退库存开关关闭时只静默核销,不触发任何库存恢复。 */
+ @Test
+ void completeAutoVerifyShouldSkipInventoryWhenDisabled() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().minusMinutes(1));
+
+ service.completeAutoVerify(task);
+
+ verify(hardOrderService).autoVerifyReserveOrder(1L);
+ verify(hardOrderService, never()).fastenerInventory(any(), anyList(), any(), any(), anyString());
+ }
+
+ /** 订单商品全部退款后无可恢复库存,核销事务应失败回滚并交由任务重试与人工排查。 */
+ @Test
+ void completeAutoVerifyShouldFailWhenNoRestorableProduct() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
+ when(hardOrderMapper.selectById(1L)).thenReturn(HardOrderEntity.builder().id(1L).build());
+ HardCarVo fullyRefunded = new HardCarVo();
+ fullyRefunded.setProductId(12L);
+ fullyRefunded.setProductCount(2);
+ fullyRefunded.setEffectiveProductCount(0);
+ when(hardCarService.getByOrderId(1L)).thenReturn(List.of(fullyRefunded));
+
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> service.completeAutoVerify(task));
+
+ assertEquals("自动核销订单没有可恢复库存的商品", exception.getMessage());
+ }
+
+ /** 天数配置非法时重扫直接拒绝,不触碰任何订单和任务。 */
+ @Test
+ void rescanShouldRejectInvalidDays() {
+ when(remoteConfigService.getExact(4L, "2")).thenReturn(R.ok(config(0)));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.rescanAutoVerifyOrders(4L, 99L));
+
+ assertEquals("支付后自动核销天数必须在1到365天之间", exception.getMessage());
+ }
+
+ /** 商户未配置自动核销时重扫拒绝执行,提示先完成配置。 */
+ @Test
+ void rescanShouldRejectMissingConfig() {
+ when(remoteConfigService.getExact(4L, "2")).thenReturn(null);
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.rescanAutoVerifyOrders(4L, 99L));
+
+ assertEquals("当前商户未配置自动核销规则", exception.getMessage());
+ }
+
+ /** 后台逐单立即核销不校验触发时间,允许提前关闭尚未到期的订单。 */
+ @Test
+ void completeAutoVerifyWithManualTriggerShouldSkipTriggerTimeCheck() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.NO.getCode(), LocalDateTime.now().plusDays(2));
+
+ service.completeAutoVerify(task, false);
+
+ verify(hardOrderService).autoVerifyReserveOrder(1L);
+ }
+
+ /** 退库存失败降级:历史订单菜谱已不可考时只告警跳过,核销本身仍完成。 */
+ @Test
+ void completeAutoVerifyShouldDegradeInventoryRestoreFailure() {
+ HardAutoRefundTaskEntity task = task(YesNoEnum.YES.getCode(), LocalDateTime.now().minusMinutes(1));
+ when(hardOrderMapper.selectById(1L)).thenReturn(HardOrderEntity.builder().id(1L).build());
+ HardCarVo remaining = new HardCarVo();
+ remaining.setProductId(11L);
+ remaining.setProductCount(2);
+ remaining.setEffectiveProductCount(2);
+ when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
+ doThrow(new RuntimeException("菜谱已下架"))
+ .when(hardOrderService).fastenerInventory(any(), anyList(), any(), any(), anyString());
+
+ service.completeAutoVerify(task);
+
+ verify(hardOrderService).autoVerifyReserveOrder(1L);
+ }
+
+ /**
+ * 构造已到触发时间的自动核销任务。
+ *
+ * @param restoreInventory 退库存快照
+ * @param triggerTime 触发时间
+ * @return 待执行任务
+ */
+ private HardAutoRefundTaskEntity task(String restoreInventory, LocalDateTime triggerTime) {
+ return HardAutoRefundTaskEntity.builder().id(9L).orderId(1L).mchId(4L).configId(5L)
+ .taskType(HardAutoRefundTaskTypeEnum.AUTO_VERIFY.getType())
+ .triggerTime(triggerTime).status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .restoreInventory(restoreInventory)
+ .autoAttemptCount(0).totalAttemptCount(0).build();
+ }
+
+ /**
+ * 构造商户自动核销配置。
+ *
+ * @param days 支付后自动核销天数
+ * @return 已开启开关的配置
+ */
+ private MchAutoRefundConfigVo config(int days) {
+ MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
+ config.setId(5L);
+ config.setMchId(4L);
+ config.setOrderType("2");
+ config.setAutoVerifyEnabled(YesNoEnum.YES.getCode());
+ config.setAutoVerifyDays(days);
+ config.setAutoVerifyRestoreInventory(YesNoEnum.YES.getCode());
+ return config;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplCreateOrderPrintTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplCreateOrderPrintTest.java
new file mode 100644
index 000000000..706f0dbfb
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplCreateOrderPrintTest.java
@@ -0,0 +1,107 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.service.HardBroadcastService;
+import com.bsd.pjcloud.hard.service.HardInvoiceService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.mockito.Answers.CALLS_REAL_METHODS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 机器下单后置钩子的发票打印测试:超市与食堂下单均打印发票,食堂预约单额外发送订餐广播。
+ */
+class HardOrderServiceImplCreateOrderPrintTest {
+
+ /** 订单 Mapper,用于返回下单后重新读取的订单快照。 */
+ private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
+
+ /** 打印任务服务,用于验证下单后创建的发票打印任务。 */
+ private final HardInvoiceService hardInvoiceService = mock(HardInvoiceService.class);
+
+ /** 取餐广播服务,用于验证食堂预约单仍发送订餐消息。 */
+ private final HardBroadcastService hardBroadcastService = mock(HardBroadcastService.class);
+
+ /** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */
+ private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
+
+ /**
+ * 为下单后置钩子注入实际访问的依赖。
+ */
+ @BeforeEach
+ void setUp() {
+ when(hardInvoiceService.printByOrder(any())).thenReturn(true);
+ ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
+ ReflectionTestUtils.setField(service, "hardInvoiceService", hardInvoiceService);
+ ReflectionTestUtils.setField(service, "hardBroadcastService", hardBroadcastService);
+ }
+
+ /** 食堂即时消费单下单后创建发票打印任务,不发送订餐广播。 */
+ @Test
+ void canteenImmediateOrderPrintsInvoice() {
+ HardOrderEntity order = order(HardTypeEnum.CANTEEN, HardOrderTypeEnum.BELOW);
+
+ service.createOrderAfterSendMqMsg(1L, null);
+
+ verify(hardInvoiceService).printByOrder(order);
+ verify(hardBroadcastService, never()).addReserveOrderTask(any(), any());
+ }
+
+ /** 食堂预约单下单后既发送订餐广播,也创建发票打印任务;兑换券仍在核销时打印。 */
+ @Test
+ void canteenReserveOrderPrintsInvoiceAndBroadcasts() {
+ HardOrderEntity order = order(HardTypeEnum.CANTEEN, HardOrderTypeEnum.RESERVE);
+
+ service.createOrderAfterSendMqMsg(1L, null);
+
+ verify(hardBroadcastService).addReserveOrderTask(order, null);
+ verify(hardInvoiceService).printByOrder(order);
+ }
+
+ /** 超市订单下单后打印发票的既有行为保持不变。 */
+ @Test
+ void supermarketOrderPrintsInvoice() {
+ HardOrderEntity order = order(HardTypeEnum.SUPERMARKET, HardOrderTypeEnum.BELOW);
+
+ service.createOrderAfterSendMqMsg(1L, null);
+
+ verify(hardInvoiceService).printByOrder(order);
+ verify(hardBroadcastService, never()).addReserveOrderTask(any(), any());
+ }
+
+ /** App 自助下单没有机器上下文,不进入打印分支。 */
+ @Test
+ void selfAppOrderSkipsPrinting() {
+ order(HardTypeEnum.SELF_APP, HardOrderTypeEnum.RESERVE);
+
+ service.createOrderAfterSendMqMsg(1L, null);
+
+ verify(hardInvoiceService, never()).printByOrder(any());
+ }
+
+ /**
+ * 构造下单后置钩子使用的订单快照。
+ *
+ * @param sysType 下单设备类型
+ * @param orderType 订单业务类型
+ * @return 固定主键的订单
+ */
+ private HardOrderEntity order(HardTypeEnum sysType, HardOrderTypeEnum orderType) {
+ HardOrderEntity order = HardOrderEntity.builder()
+ .id(1L)
+ .sysType(sysType.getType())
+ .orderType(orderType.getType())
+ .build();
+ when(hardOrderMapper.selectById(1L)).thenReturn(order);
+ return order;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java
index cd865a940..09b96beaa 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplInventoryTest.java
@@ -188,25 +188,89 @@ class HardOrderServiceImplInventoryTest {
}
/**
- * 验证设备购买链路(/hard-api/ad/create-order 产生的 BELOW+CANTEEN_SPU 购物车)扣基础 canteen_spu.stock:
- * 请求不携带每日菜谱分池通道,也不查询每日菜谱接口——每日菜谱页面的线上/线下剩余不会变化。
+ * 验证设备购买链路(/hard/hard-api/ad/create-order 的 BELOW+CANTEEN_SPU 购物车)对当日菜谱在册商品
+ * 扣每日菜谱 OFFLINE 池:请求带 stockChannel=OFFLINE、日期餐段和线下剩余快照,不再扣 canteen_spu.stock。
*/
@Test
- void deviceCreateOrderDeductsBasicSpuStockWithoutDailyMenuPools() {
+ void deviceCreateOrderDeductsDailyMenuOfflinePoolWhenOnMenu() {
prepareCart(1);
+ when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
+ .thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
+ when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
+
+ HardOrderEntity order = deviceDailyMenuOrder();
+ assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
+ verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
+ CanteenSpuUpdateStockReqDTO request = captor.getValue();
+ assertEquals("OFFLINE", request.getStockChannel());
+ assertEquals("APPLY", request.getOperation());
+ assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
+ assertEquals(300L, request.getMerchantId());
+ CanteenSpuUpdateStockReqDTO.Item item = request.getItems().get(0);
+ assertEquals(11L, item.getId());
+ assertEquals(-1, item.getIncrCount());
+ assertEquals(4, item.getBeforeStock());
+ assertEquals(LocalDate.of(2026, 9, 13), item.getMealDate());
+ assertEquals(2, item.getHourType());
+ verify(remoteCanteenSpuService, never()).getInnerSpuListByIds(any());
+ }
+
+ /**
+ * 验证设备购买不在当日菜谱的商品时仍扣基础 canteen_spu.stock:请求不携带分池通道。
+ */
+ @Test
+ void deviceCreateOrderDeductsBasicSpuStockWhenNotOnMenu() {
+ prepareCart(1);
+ when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
+ .thenReturn(R.ok(List.of()));
CanteenSpuEntity product = productWithStock(9996);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
- assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null, null));
+ HardOrderEntity order = deviceDailyMenuOrder();
+ assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertNull(request.getStockChannel());
assertEquals(-1, request.getItems().get(0).getIncrCount());
- verify(remoteDailyMenuService, never()).listForOrderValidate(any(), any(), any());
- verify(remoteDailyMenuService, never()).getCanteenList(any(), any(), any(), any());
+ assertEquals(9996, request.getItems().get(0).getBeforeStock());
+ }
+
+ /**
+ * 验证混合购物车按商品分流:在册商品扣每日菜谱 OFFLINE 池,不在册商品扣基础 canteen_spu.stock,
+ * 两次幂等调用共用同一业务号(食堂端状态键按渠道隔离,不会互相命中)。
+ */
+ @Test
+ void deviceCreateOrderSplitsMixedCartBetweenPools() {
+ prepareTwoProductCart(1, 2);
+ when(remoteDailyMenuService.listForOrderValidate(any(), any(), any()))
+ .thenReturn(R.ok(List.of(dailyMenuRow(8, 4))));
+ CanteenSpuEntity product = productWithStock(5);
+ product.setId(12L);
+ when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(12L))).thenReturn(R.ok(List.of(product)));
+ when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
+
+ HardOrderEntity order = deviceDailyMenuOrder();
+ assertDoesNotThrow(() -> service.fastenerInventory(order, null, YesNoEnum.YES, null, null));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
+ verify(remoteCanteenSpuService, org.mockito.Mockito.times(2)).updateSkuStockIdempotent(captor.capture());
+ CanteenSpuUpdateStockReqDTO dailyRequest = captor.getAllValues().stream()
+ .filter(r -> "OFFLINE".equals(r.getStockChannel())).findFirst().orElseThrow();
+ CanteenSpuUpdateStockReqDTO basicRequest = captor.getAllValues().stream()
+ .filter(r -> r.getStockChannel() == null).findFirst().orElseThrow();
+ assertEquals("HARD_DAILY_MENU_ORDER:100", dailyRequest.getBizId());
+ assertEquals(11L, dailyRequest.getItems().get(0).getId());
+ assertEquals(-1, dailyRequest.getItems().get(0).getIncrCount());
+ assertEquals(4, dailyRequest.getItems().get(0).getBeforeStock());
+ assertEquals("HARD_DAILY_MENU_ORDER:100", basicRequest.getBizId());
+ assertEquals(12L, basicRequest.getItems().get(0).getId());
+ assertEquals(-2, basicRequest.getItems().get(0).getIncrCount());
+ assertEquals(5, basicRequest.getItems().get(0).getBeforeStock());
}
/**
@@ -318,6 +382,31 @@ class HardOrderServiceImplInventoryTest {
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car));
}
+ /**
+ * 准备双商品购物车:第一个商品可出现在当日菜谱,第二个不在菜谱,用于混合分流验证。
+ *
+ * @param firstCount 第一个商品份数
+ * @param secondCount 第二个商品份数
+ */
+ private void prepareTwoProductCart(int firstCount, int secondCount) {
+ HardParentCarEntity parentCar = HardParentCarEntity.builder()
+ .id(200L)
+ .productSource(HardProductSourceEnum.CANTEEN_SPU.getType())
+ .build();
+ HardCarEntity first = HardCarEntity.builder()
+ .parentCarId(200L)
+ .productId(11L)
+ .productCount(firstCount)
+ .build();
+ HardCarEntity second = HardCarEntity.builder()
+ .parentCarId(200L)
+ .productId(12L)
+ .productCount(secondCount)
+ .build();
+ when(hardCarService.getParentCarById(200L)).thenReturn(parentCar);
+ when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(first, second));
+ }
+
/**
* 构造指定线上/线下剩余的每日菜谱行。
*
@@ -365,6 +454,24 @@ class HardOrderServiceImplInventoryTest {
.build();
}
+ /**
+ * 构造带日期餐段的设备购买订单:createOrder 会写入 apptUseDate=今天、hourType=请求餐段,
+ * 是 /hard/hard-api/ad/create-order 购买每日菜谱时的真实形态。
+ *
+ * @return 关联 CANTEEN_SPU 购物车、固定日期餐段和商户的 BELOW 订单
+ */
+ private HardOrderEntity deviceDailyMenuOrder() {
+ return HardOrderEntity.builder()
+ .id(100L)
+ .sysType(HardTypeEnum.CANTEEN.getType())
+ .orderType(HardOrderTypeEnum.BELOW.getType())
+ .parentCarId(200L)
+ .merchantId(300L)
+ .apptUseDate(LocalDate.of(2026, 9, 13))
+ .hourType("2")
+ .build();
+ }
+
/**
* 构造线上预定提交的每日菜谱订单。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
index 9bee161d5..d38c1174a 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplVerificationTest.java
@@ -8,6 +8,8 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderVerificationTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
@@ -24,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
@@ -256,6 +259,74 @@ class HardOrderServiceImplVerificationTest {
assertNull(order.getExpiredRefundDeadline());
}
+ /**
+ * 自动核销入口静默完成核销:核销方式写自动、不查询用户、不建兑换券打印任务、不写取餐消息。
+ */
+ @Test
+ void autoVerifyReserveOrderSilentlyVerifiesWithAutoType() {
+ HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
+ order.setPayType(HardPayTypeEnum.WALLET_DEFAULT.getType());
+ HardCarVo remaining = new HardCarVo();
+ remaining.setProductCount(2);
+ remaining.setEffectiveProductCount(2);
+ when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
+ when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
+ when(remoteCodeUseService.isCancelCode(1L)).thenReturn(R.ok(false));
+ when(remoteCodeUseService.cancelCode(1L)).thenReturn(R.ok(true));
+ when(hardOrderMapper.update(any(HardOrderEntity.class), any(Wrapper.class))).thenReturn(1);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(HardOrderEntity.class);
+
+ Boolean result = service.autoVerifyReserveOrder(1L);
+
+ assertTrue(Boolean.TRUE.equals(result));
+ verify(hardOrderMapper).update(captor.capture(), any(Wrapper.class));
+ assertEquals(HardOrderStatusEnum.CANCEL.getType(), captor.getValue().getOrderStatus());
+ assertEquals(HardOrderVerificationTypeEnum.AUTO.getType(), captor.getValue().getVerificationType());
+ assertEquals(-1L, captor.getValue().getHardId());
+ // 静默核销不得触发人工核销的附属动作:无兑换券打印任务、无取餐成功消息。
+ verify(hardInvoiceService, never()).createRedemptionVoucherTask(any(), anyLong());
+ verify(hardMessageOutboxMapper, never()).insert(any(HardMessageOutboxEntity.class));
+ }
+
+ /**
+ * 自动核销入口对已消费二维码容错:此前失败的尝试可能已在 Wallet 扣码但补偿未恢复,
+ * 静默路径跳过消费直接关闭订单,不再被防重校验拒绝。
+ */
+ @Test
+ void autoVerifyReserveOrderShouldTolerateConsumedQr() {
+ HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY);
+ order.setPayType(HardPayTypeEnum.WALLET_DEFAULT.getType());
+ HardCarVo remaining = new HardCarVo();
+ remaining.setProductCount(2);
+ remaining.setEffectiveProductCount(2);
+ when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
+ when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
+ when(remoteCodeUseService.isCancelCode(1L)).thenReturn(R.ok(true));
+ when(hardOrderMapper.update(any(HardOrderEntity.class), any(Wrapper.class))).thenReturn(1);
+
+ Boolean result = service.autoVerifyReserveOrder(1L);
+
+ assertTrue(Boolean.TRUE.equals(result));
+ verify(remoteCodeUseService, never()).cancelCode(anyLong());
+ verify(hardInvoiceService, never()).createRedemptionVoucherTask(any(), anyLong());
+ }
+
+ /**
+ * 自动核销订单不允许退回核销前:任务已终态,回退会让订单永远滞留待核销状态。
+ */
+ @Test
+ void rollbackReserveVerificationRejectsAutoVerifiedOrder() {
+ HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL);
+ order.setVerificationType(HardOrderVerificationTypeEnum.AUTO.getType());
+ when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.rollbackReserveVerification(1L));
+
+ assertEquals("自动核销订单不支持退回核销前状态", exception.getMessage());
+ verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
+ }
+
/**
* 构造旧消费机通用核销接口请求。
*
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java
index 98bed8b11..a05b3960d 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysUserFaceVo.java
@@ -1,19 +1,29 @@
package com.bsd.pjcloud.admin.api.vo;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.entity.SysUserFaceEntity;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import lombok.Data;
+import java.util.List;
+
@Data
-public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface {
+public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterface, BaseDeptInterface, BaseUserIdentityInterface {
/**
* 人脸图片
*/
private String faceImgs;
+ /**
+ * 当前人脸在该用户全部未删除人脸中的序号,按记录 id 升序从 1 开始,供前端下载命名。
+ */
+ private Integer faceIndex;
+
/**
* 用户相关
*/
@@ -23,6 +33,41 @@ public class SysUserFaceVo extends SysUserFaceEntity implements BaseUserInterfac
private String userAvatar;
+ /**
+ * 用户标准姓名字段,供统一用户信息组件展示;与历史 username 字段并存。
+ */
+ private String name;
+
+ /**
+ * 用户标准手机号字段,供统一用户信息与身份组件展示;与历史 userMobile 字段并存。
+ */
+ private String phone;
+
+ /**
+ * 用户标准头像字段,供统一用户信息组件展示;与历史 userAvatar 字段并存。
+ */
+ private String avatar;
+
+ /**
+ * 用户性别:0未知 1男 2女
+ */
+ private Integer gender;
+
+ /**
+ * 用户身份协议编码集合;没有身份时为空集合或空值。
+ */
+ private List identityTypes;
+
+ /**
+ * 学生身份扩展资料;没有学生身份时为空。
+ */
+ private SysUserStudentDto studentInfo;
+
+ /**
+ * 老师身份扩展资料;没有老师身份时为空。
+ */
+ private SysUserTeacherDto teacherInfo;
+
/**
* 部门相关
*/
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
index c1cd91c5c..a26b30328 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserFaceController.java
@@ -22,7 +22,9 @@ import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceSyncSnapshotVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalQualityReportVo;
+import com.bsd.pjcloud.admin.service.SysUserFaceExportService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
@@ -64,6 +66,8 @@ public class SysUserFaceController {
private final SysUserFaceService sysUserFaceService;
+ private final SysUserFaceExportService sysUserFaceExportService;
+
/* ************************************* start 回调 start ************************************* */
@Inner(value = false)
@PostMapping("/apply-callback")
@@ -289,6 +293,21 @@ public class SysUserFaceController {
return R.ok(sysUserFaceService.scopePageList(page, dto, dataScope));
}
+ /**
+ * 按当前筛选条件和数据权限提交人脸图片 ZIP 异步导出任务。
+ *
+ * @param dto 人脸审核列表当前筛选条件
+ * @return 供前端轮询的初始进度,成功终态包含压缩包下载地址
+ */
+ @Operation(summary = "人脸图片分批导出", description = "按筛选条件分批打包人脸图片为 ZIP,异步执行并逐张上报进度")
+ @SysLog("人脸图片导出")
+ @PostMapping("/export")
+ public R exportFaces(@RequestBody SysUserFaceDto dto) {
+ DataScope dataScope = new DataScope();
+ dataScope.setScopeDeptName("userDeptId");
+ return R.ok(sysUserFaceExportService.submitExport(dto, dataScope), "人脸图片导出任务已提交");
+ }
+
@Operation(summary = "通过id查询", description = "通过id查询")
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('userFace_sysUserFace_view')")
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceExportService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceExportService.java
new file mode 100644
index 000000000..413dab496
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceExportService.java
@@ -0,0 +1,23 @@
+package com.bsd.pjcloud.admin.service;
+
+import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+
+/**
+ * 人脸图片批量导出业务服务。
+ * 按人脸审核列表筛选条件分批打包人脸图片为 ZIP 压缩包,异步执行并逐张上报进度。
+ */
+public interface SysUserFaceExportService {
+
+ /**
+ * 提交人脸图片 ZIP 导出任务。
+ * 请求线程只冻结筛选条件并创建任务;异步线程恢复租户和用户上下文后按数据权限分批查询、
+ * 从文件中心逐张读取图片写入压缩包,完成后上传文件中心并返回下载地址。
+ *
+ * @param dto 人脸审核列表当前筛选条件
+ * @param dataScope 当前登录人的部门数据权限
+ * @return 已创建的异步任务初始进度
+ */
+ AsyncTaskProgressVO submitExport(SysUserFaceDto dto, DataScope dataScope);
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
index 335d912e4..f929a65cf 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserFaceService.java
@@ -110,6 +110,13 @@ public interface SysUserFaceService extends IService {
Page scopePageList(Page page, SysUserFaceDto dto, DataScope dataScope);
+ /**
+ * 批量回填人脸记录在该用户全部未删除人脸中的序号,按记录 id 升序从 1 开始。
+ *
+ * @param records 已查询到基础数据的人脸记录列表
+ */
+ void fillFaceIndex(List records);
+
Page pageJoin(Page page, SysUserFaceDto dto);
Page allPageJoin(Page page, SysUserFaceDto dto);
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java
index 84defee78..cd5dde1a5 100755
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity;
import com.bsd.pjcloud.admin.api.vo.UserVO;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import java.util.List;
@@ -46,12 +47,12 @@ public interface SysUserIdentityService extends IService
*
* @param userVos 已查询到基础数据的用户展示对象列表
*/
- void fillUserIdentityTypes(List userVos);
+ void fillUserIdentityTypes(List extends BaseUserIdentityInterface> userVos);
/**
* 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。
*
* @param userVos 已查询到基础数据的用户展示对象列表
*/
- void fillUserIdentities(List userVos);
+ void fillUserIdentities(List extends BaseUserIdentityInterface> userVos);
}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceExportServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceExportServiceImpl.java
new file mode 100644
index 000000000..f345aabcc
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceExportServiceImpl.java
@@ -0,0 +1,428 @@
+package com.bsd.pjcloud.admin.service.impl;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.SecureUtil;
+import com.amazonaws.services.s3.model.S3Object;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
+import com.bsd.pjcloud.admin.api.entity.SysFile;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
+import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
+import com.bsd.pjcloud.admin.service.SysUserFaceExportService;
+import com.bsd.pjcloud.admin.service.SysUserFaceService;
+import com.bsd.pjcloud.admin.service.SysFileService;
+import com.bsd.pjcloud.admin.service.SysUserIdentityService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
+import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.file.core.FileProperties;
+import com.bsd.pjcloud.common.file.core.FileTemplate;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * 人脸图片批量导出服务实现。
+ * 同步请求只冻结筛选条件并创建任务;异步线程按数据权限分批查询人脸记录,逐张从文件中心读取
+ * 图片流式写入临时 ZIP,完成后上传文件中心并返回下载地址,全程内存占用与单批数据量成正比。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SysUserFaceExportServiceImpl implements SysUserFaceExportService {
+
+ /** 人脸图片导出日志标签,用于定位分批打包和文件保存异常。 */
+ private static final String LOG_TAG = "人脸图片导出";
+
+ /** 人脸图片导出任务的业务 Redis Key 命名空间。 */
+ private static final String FACE_EXPORT_REDIS_KEY = "user-face:export";
+
+ /** 人脸图片导出任务展示名称。 */
+ private static final String FACE_EXPORT_TASK_NAME = "人脸图片导出";
+
+ /** 总数查询完成前创建任务使用的占位总数,异步查询后立即替换为真实人脸数。 */
+ private static final int FACE_EXPORT_INITIAL_TOTAL = 1;
+
+ /** 单批查询的人脸记录数,控制数据库分页与图片读取的瞬时压力。 */
+ private static final int FACE_EXPORT_BATCH_SIZE = 200;
+
+ /** 文件中心使用的 ZIP MIME 类型。 */
+ private static final String ZIP_CONTENT_TYPE = "application/zip";
+
+ /** 文件中心导出文件业务目录。 */
+ private static final String FACE_EXPORT_FILE_DIR = "face-export";
+
+ /** 文件中心导出文件分类,用于后台文件管理区分系统生成附件。 */
+ private static final String FACE_EXPORT_FILE_TYPE = "USER_FACE_EXPORT";
+
+ /** 业务导出文件名时间格式,避免同一用户连续导出时名称难以区分。 */
+ private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /** 人脸 URL 中文件中心对象名的查询参数前缀。 */
+ private static final String FILE_NAME_PARAM = "fileName=";
+
+ /** 人脸映射不到文件中心记录或读取失败时,ZIP 内使用的兜底扩展名。 */
+ private static final String DEFAULT_IMAGE_EXT = "jpg";
+
+ /** 人脸查询 Mapper,用于应用列表筛选和当前登录人的数据权限。 */
+ private final SysUserFaceMapper sysUserFaceMapper;
+
+ /** 人脸业务服务,用于回填“姓名-班级-序号”命名所需的人脸序号。 */
+ private final SysUserFaceService sysUserFaceService;
+
+ /** 用户身份服务,用于批量补齐学生班级等身份资料。 */
+ private final SysUserIdentityService sysUserIdentityService;
+
+ /** 文件管理服务,用于按对象名批量定位人脸图片的文件中心记录并登记导出压缩包。 */
+ private final SysFileService sysFileService;
+
+ /** 文件存储模板,用于把导出压缩包流式上传到项目统一对象存储。 */
+ private final FileTemplate fileTemplate;
+
+ /** 文件存储配置,提供导出压缩包使用的默认 bucket。 */
+ private final FileProperties fileProperties;
+
+ /** 通用异步任务执行器,用于恢复租户和安全上下文并上报导出进度。 */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 提交人脸图片 ZIP 导出任务。
+ *
+ * @param dto 人脸审核列表当前筛选条件
+ * @param dataScope 当前登录人的部门数据权限
+ * @return 已创建的异步任务初始进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitExport(SysUserFaceDto dto, DataScope dataScope) {
+ SysUserFaceDto querySnapshot = copyQuery(dto);
+ return asyncTaskExecutorService.submitWithResult(FACE_EXPORT_REDIS_KEY, FACE_EXPORT_TASK_NAME,
+ FACE_EXPORT_INITIAL_TOTAL, reporter -> {
+ long total = countFaces(querySnapshot, dataScope);
+ if (total <= 0) {
+ throw new IllegalStateException("没有符合当前条件的人脸");
+ }
+ reporter.updateTotal((int) Math.min(total, Integer.MAX_VALUE), "共 " + total + " 张人脸,正在分批打包");
+ ExportSummary summary = exportFaces(querySnapshot, dataScope, total, reporter);
+
+ String resultFileName = "人脸图片_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".zip";
+ String resultFileUrl = uploadZip(summary.zipFile(), resultFileName);
+ reporter.advance("压缩包已生成,共打包 " + summary.packedCount() + " 张,跳过 " + summary.skippedCount() + " 张无效图片");
+ return AsyncTaskResult.file(resultFileUrl, resultFileName);
+ });
+ }
+
+ /**
+ * 人脸图片分批导出的结果摘要。
+ *
+ * @param zipFile 已写入全部图片的临时压缩包
+ * @param packedCount 成功写入压缩包的图片数量
+ * @param skippedCount 映射不到文件或读取失败而跳过的图片数量
+ */
+ private record ExportSummary(Path zipFile, int packedCount, int skippedCount) {
+ }
+
+ /**
+ * 按筛选条件分批查询人脸并逐张写入临时 ZIP 压缩包。
+ *
+ * @param querySnapshot 已冻结的列表筛选条件
+ * @param dataScope 当前登录人的部门数据权限
+ * @param total 总数查询得到的真实人脸数量
+ * @param reporter 异步进度报告器;成功与跳过的图片均计数,保证进度能推进到完成
+ * @return 临时压缩包与打包统计
+ */
+ private ExportSummary exportFaces(SysUserFaceDto querySnapshot, DataScope dataScope, long total,
+ AsyncTaskProgressReporter reporter) {
+ Path zipFile;
+ try {
+ zipFile = Files.createTempFile("face-export-", ".zip");
+ } catch (IOException exception) {
+ throw new IllegalStateException("创建人脸导出临时文件失败", exception);
+ }
+
+ int packedCount = 0;
+ int skippedCount = 0;
+ long processed = 0;
+ Set usedEntryNames = new HashSet<>();
+ try (ZipOutputStream zipStream = new ZipOutputStream(Files.newOutputStream(zipFile), StandardCharsets.UTF_8)) {
+ for (long current = 1; processed < total; current++) {
+ Page page = new Page<>(current, FACE_EXPORT_BATCH_SIZE, false);
+ List records = sysUserFaceMapper.scopePageList(page, querySnapshot, dataScope).getRecords();
+ if (records == null || records.isEmpty()) {
+ break;
+ }
+ sysUserIdentityService.fillUserIdentities(records);
+ sysUserFaceService.fillFaceIndex(records);
+ Map fileByFileName = loadFileRecords(records);
+
+ for (SysUserFaceVo record : records) {
+ processed++;
+ if (writeFaceEntry(zipStream, record, fileByFileName, usedEntryNames)) {
+ packedCount++;
+ } else {
+ skippedCount++;
+ }
+ reporter.advance("正在打包第 " + processed + " / " + total + " 张人脸图片");
+ }
+ }
+ zipStream.finish();
+ } catch (IOException exception) {
+ deleteQuietly(zipFile);
+ throw new IllegalStateException("写入人脸导出压缩包失败", exception);
+ } catch (RuntimeException exception) {
+ deleteQuietly(zipFile);
+ throw exception;
+ }
+ if (packedCount == 0) {
+ deleteQuietly(zipFile);
+ throw new IllegalStateException("人脸图片均无法从文件中心读取,导出已取消");
+ }
+ return new ExportSummary(zipFile, packedCount, skippedCount);
+ }
+
+ /**
+ * 把单条人脸记录的图片写入压缩包。
+ *
+ * @param zipStream 压缩包输出流
+ * @param record 人脸记录,已带用户姓名、学生班级和该用户人脸序号
+ * @param fileByFileName 文件中心记录索引,键为对象名
+ * @param usedEntryNames 已使用的压缩包条目名,用于跨用户同名时去重
+ * @return 成功写入返回 {@code true};图片缺失或读取失败返回 {@code false} 并由调用方计数
+ */
+ private boolean writeFaceEntry(ZipOutputStream zipStream, SysUserFaceVo record, Map fileByFileName,
+ Set usedEntryNames) {
+ String storedFileName = resolveStoredFileName(record.getFaceImg());
+ SysFile fileRecord = storedFileName == null ? null : fileByFileName.get(storedFileName);
+ if (fileRecord == null) {
+ log.warn("|{}|人脸记录 {} 的图片 {} 未找到文件中心记录,已跳过", LOG_TAG, record.getId(), storedFileName);
+ return false;
+ }
+ String entryName = uniqueEntryName(buildEntryName(record, storedFileName), usedEntryNames);
+ try (S3Object s3Object = fileTemplate.getObject(fileRecord.getBucketName(), fileRecord.getDir(),
+ fileRecord.getFileName());
+ InputStream content = s3Object.getObjectContent()) {
+ zipStream.putNextEntry(new ZipEntry(entryName));
+ IoUtil.copy(content, zipStream);
+ zipStream.closeEntry();
+ return true;
+ } catch (Exception exception) {
+ log.error("|{}|人脸记录 {} 的图片 {} 读取失败,已跳过", LOG_TAG, record.getId(), storedFileName, exception);
+ return false;
+ }
+ }
+
+ /**
+ * 组装与单张下载一致的“姓名-部门-班级-序号”压缩包条目名。
+ * 用户无部门或非学生身份时省略对应分段,保持文件名紧凑。
+ *
+ * @param record 已补齐用户资料与人脸序号的人脸记录
+ * @param storedFileName 文件中心对象名,用于保留图片原始扩展名
+ * @return 可直接写入 ZIP 的条目名
+ */
+ private String buildEntryName(SysUserFaceVo record, String storedFileName) {
+ String name = sanitizeEntrySegment(record.getName() != null ? record.getName() : record.getUsername());
+ if (name == null) {
+ name = "用户" + record.getUserId();
+ }
+ String deptName = sanitizeEntrySegment(record.getDeptName());
+ String className = sanitizeEntrySegment(record.getStudentInfo() == null ? null : record.getStudentInfo().getClassName());
+ int sequence = record.getFaceIndex() == null || record.getFaceIndex() < 1 ? 1 : record.getFaceIndex();
+ String ext = StrUtil.blankToDefault(StrUtil.subAfter(storedFileName, ".", true), DEFAULT_IMAGE_EXT).toLowerCase();
+ StringBuilder baseName = new StringBuilder(name);
+ if (deptName != null) {
+ baseName.append("-").append(deptName);
+ }
+ if (className != null) {
+ baseName.append("-").append(className);
+ }
+ baseName.append("-").append(sequence);
+ return baseName.append(".").append(ext).toString();
+ }
+
+ /**
+ * 清理条目名分段中的文件系统非法字符。
+ *
+ * @param segment 姓名或班级原始文本
+ * @return 清理后的文本;清理后为空时返回 {@code null}
+ */
+ private String sanitizeEntrySegment(String segment) {
+ String sanitized = segment == null ? null : segment.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "").trim();
+ return StrUtil.isBlank(sanitized) ? null : sanitized;
+ }
+
+ /**
+ * 保证压缩包条目名全局唯一;不同用户姓名与班级相同时追加序号后缀。
+ *
+ * @param baseName 按命名规则组装的条目名
+ * @param usedEntryNames 已使用条目名集合,方法内会登记最终结果
+ * @return 未被使用的唯一条目名
+ */
+ private String uniqueEntryName(String baseName, Set usedEntryNames) {
+ String candidate = baseName;
+ int suffix = 2;
+ while (!usedEntryNames.add(candidate)) {
+ candidate = baseName + "(" + suffix++ + ")";
+ }
+ return candidate;
+ }
+
+ /**
+ * 从人脸图片地址解析文件中心对象名。
+ * 人脸上传返回的地址形如 {@code /admin/sys-file/oss/file?fileName=uuid.jpg};http 外链无法映射
+ * 到项目文件中心,返回空交由调用方跳过。
+ *
+ * @param faceImg 人脸记录存储的图片地址,可能为空
+ * @return 文件中心对象名;无法解析时返回 {@code null}
+ */
+ private String resolveStoredFileName(String faceImg) {
+ if (StrUtil.isBlank(faceImg)) {
+ return null;
+ }
+ String value = faceImg.trim().split(",")[0].trim();
+ int paramNameIndex = value.indexOf(FILE_NAME_PARAM);
+ if (paramNameIndex >= 0) {
+ String param = value.substring(paramNameIndex + FILE_NAME_PARAM.length());
+ int nextParamIndex = param.indexOf('&');
+ String fileName = nextParamIndex >= 0 ? param.substring(0, nextParamIndex) : param;
+ return StrUtil.isBlank(fileName) ? null : fileName;
+ }
+ if (value.toLowerCase().startsWith("http")) {
+ return null;
+ }
+ int slashIndex = value.lastIndexOf('/');
+ String segment = slashIndex >= 0 ? value.substring(slashIndex + 1) : value;
+ return StrUtil.isBlank(segment) ? null : segment;
+ }
+
+ /**
+ * 批量定位当前批人脸图片在文件中心的记录。
+ *
+ * @param records 当前批人脸记录
+ * @return 键为对象名的文件中心记录索引;无有效对象名时返回空集合
+ */
+ private Map loadFileRecords(List records) {
+ List fileNames = records.stream().map(record -> resolveStoredFileName(record.getFaceImg()))
+ .filter(Objects::nonNull).distinct().toList();
+ if (fileNames.isEmpty()) {
+ return Map.of();
+ }
+ return sysFileService.list(Wrappers.lambdaQuery().in(SysFile::getFileName, fileNames)).stream()
+ .collect(Collectors.toMap(SysFile::getFileName, Function.identity(), (first, ignored) -> first));
+ }
+
+ /**
+ * 查询当前筛选条件与数据权限范围内的人脸总数。
+ *
+ * @param querySnapshot 已冻结的列表筛选条件
+ * @param dataScope 当前登录人的部门数据权限
+ * @return 分页总数统计得到的人脸数量
+ */
+ private long countFaces(SysUserFaceDto querySnapshot, DataScope dataScope) {
+ Page countPage = sysUserFaceMapper.scopePageList(new Page<>(1, 1), querySnapshot, dataScope);
+ return countPage.getTotal();
+ }
+
+ /**
+ * 流式上传导出压缩包并登记文件中心记录。
+ * 压缩包可能达到数百兆,因此直接走对象存储流式接口并在本服务内登记文件记录,
+ * 避免经过内存 MultipartFile 造成导出峰值内存与文件大小成正比。
+ *
+ * @param zipFile 已写入全部图片的临时压缩包
+ * @param resultFileName 用户下载时使用的文件名称
+ * @return 文件中心提供的受控下载地址
+ */
+ private String uploadZip(Path zipFile, String resultFileName) {
+ String zipFileName = IdUtil.simpleUUID() + ".zip";
+ try (InputStream stream = Files.newInputStream(zipFile)) {
+ fileTemplate.putObject(fileProperties.getBucketName(), FACE_EXPORT_FILE_DIR, zipFileName, stream, ZIP_CONTENT_TYPE);
+ } catch (Exception exception) {
+ deleteQuietly(zipFile);
+ log.error("|{}|上传人脸导出压缩包失败", LOG_TAG, exception);
+ throw new IllegalStateException("上传人脸导出压缩包失败", exception);
+ }
+
+ try {
+ SysFile fileRecord = new SysFile();
+ fileRecord.setFileName(zipFileName);
+ fileRecord.setDir(FACE_EXPORT_FILE_DIR);
+ fileRecord.setOriginal(resultFileName);
+ fileRecord.setFileSize(Files.size(zipFile));
+ fileRecord.setBucketName(fileProperties.getBucketName());
+ fileRecord.setType(FACE_EXPORT_FILE_TYPE);
+ fileRecord.setHash(SecureUtil.md5(zipFile.toFile()));
+ sysFileService.save(fileRecord);
+ } catch (Exception exception) {
+ deleteQuietly(zipFile);
+ log.error("|{}|登记人脸导出文件记录失败", LOG_TAG, exception);
+ throw new IllegalStateException("登记人脸导出文件记录失败", exception);
+ }
+ deleteQuietly(zipFile);
+ return String.format("/admin/sys-file/oss/file?fileName=%s", zipFileName);
+ }
+
+ /**
+ * 静默删除临时文件,仅记录调试日志。
+ *
+ * @param zipFile 待删除的临时压缩包
+ */
+ private void deleteQuietly(Path zipFile) {
+ try {
+ Files.deleteIfExists(zipFile);
+ } catch (IOException exception) {
+ log.debug("|{}|临时压缩包删除失败:{}", LOG_TAG, zipFile, exception);
+ }
+ }
+
+ /**
+ * 复制人脸查询条件,避免控制器请求对象在异步任务开始前被其他代码修改。
+ *
+ * @param source 控制器接收的人脸筛选条件,可为空
+ * @return 供异步任务独占使用的查询条件副本
+ */
+ private SysUserFaceDto copyQuery(SysUserFaceDto source) {
+ SysUserFaceDto target = new SysUserFaceDto();
+ if (source == null) {
+ return target;
+ }
+ org.springframework.beans.BeanUtils.copyProperties(source, target);
+ target.setUserIds(copyList(source.getUserIds()));
+ target.setProcessStatuses(copyList(source.getProcessStatuses()));
+ target.setDeptIds(copyList(source.getDeptIds()));
+ // 导出不需要审批流程候选人标记
+ target.setIsFindCheckUser(false);
+ return target;
+ }
+
+ /**
+ * 复制可能为空的查询集合,避免异步任务与请求对象共享可变列表。
+ *
+ * @param source 请求参数中的可变列表
+ * @param 列表元素类型
+ * @return 空值保持为空,非空值返回新的可变列表
+ */
+ private List copyList(List source) {
+ return source == null ? null : new ArrayList<>(source);
+ }
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
index 0ee52782e..c79543310 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceServiceImpl.java
@@ -33,6 +33,7 @@ import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.service.SysDeptService;
import com.bsd.pjcloud.admin.service.SysRoleService;
import com.bsd.pjcloud.admin.service.SysUserFaceService;
+import com.bsd.pjcloud.admin.service.SysUserIdentityService;
import com.bsd.pjcloud.admin.service.SysUserRelationService;
import com.bsd.pjcloud.admin.service.SysUserService;
import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
@@ -146,6 +147,11 @@ public class SysUserFaceServiceImpl extends ServiceImpl list = baseMapper.scopePageList(page, dto, dataScope);
+ // 批量补齐身份协议与学生、老师扩展资料,供管理端“身份信息”复合列展示。
+ sysUserIdentityService.fillUserIdentities(list.getRecords());
+ // 回填人脸序号,供前端按“姓名-班级-序号”命名下载当前行人脸图片。
+ fillFaceIndex(list.getRecords());
// 审批流程
if (dto.getIsFindCheckUser()) {
List processInstanceIds = list.getRecords().stream().map(SysUserFaceVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList();
@@ -931,6 +941,39 @@ public class SysUserFaceServiceImpl extends ServiceImpl records) {
+ if (ObjectUtil.isEmpty(records)) {
+ return;
+ }
+ List userIds = records.stream().map(SysUserFaceVo::getUserId).filter(Objects::nonNull).distinct().toList();
+ if (ObjectUtil.isEmpty(userIds)) {
+ return;
+ }
+ Map> faceIdsByUserId = list(new LambdaQueryWrapper()
+ .select(SysUserFaceEntity::getId, SysUserFaceEntity::getUserId)
+ .in(SysUserFaceEntity::getUserId, userIds)
+ .orderByAsc(SysUserFaceEntity::getId))
+ .stream()
+ .collect(Collectors.groupingBy(SysUserFaceEntity::getUserId,
+ Collectors.mapping(SysUserFaceEntity::getId, Collectors.toList())));
+ for (SysUserFaceVo record : records) {
+ List faceIds = faceIdsByUserId.get(record.getUserId());
+ if (ObjectUtil.isEmpty(faceIds)) {
+ continue;
+ }
+ int index = faceIds.indexOf(record.getId());
+ if (index >= 0) {
+ record.setFaceIndex(index + 1);
+ }
+ }
+ }
+
@Override
public Page pageJoin(Page page, SysUserFaceDto dto) {
if (ObjectUtil.isNotEmpty(dto.getMerchantDeptIdOrGlobal())) {
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java
index bdc0cd2fa..5c48735c0 100755
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserIdentityEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTeacherEntity;
import com.bsd.pjcloud.admin.api.vo.UserVO;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.user.SysUserIdentityMapper;
import com.bsd.pjcloud.admin.service.SysUserIdentityService;
@@ -187,13 +188,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl userVos) {
+ public void fillUserIdentityTypes(List extends BaseUserIdentityInterface> userVos) {
if (CollUtil.isEmpty(userVos)) {
return;
}
List userIds = userVos.stream()
.filter(userVo -> userVo != null && userVo.getUserId() != null)
- .map(UserVO::getUserId)
+ .map(BaseUserIdentityInterface::getUserId)
.distinct()
.toList();
if (CollUtil.isEmpty(userIds)) {
@@ -217,13 +218,13 @@ public class SysUserIdentityServiceImpl extends ServiceImpl userVos) {
+ public void fillUserIdentities(List extends BaseUserIdentityInterface> userVos) {
if (CollUtil.isEmpty(userVos)) {
return;
}
List userIds = userVos.stream()
.filter(userVo -> userVo != null && userVo.getUserId() != null)
- .map(UserVO::getUserId)
+ .map(BaseUserIdentityInterface::getUserId)
.distinct()
.toList();
if (CollUtil.isEmpty(userIds)) {
@@ -236,7 +237,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl teacherByUserId = sysUserTeacherService.listByIds(userIds).stream()
.collect(Collectors.toMap(SysUserTeacherEntity::getUserId, Function.identity(), (first, ignored) -> first));
- for (UserVO userVo : userVos) {
+ for (BaseUserIdentityInterface userVo : userVos) {
if (userVo == null || userVo.getUserId() == null) {
continue;
}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx
index d9a724559..bd7108913 100644
Binary files a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_recharge.xlsx differ
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx
index 802d02f81..39a6d25ca 100644
Binary files a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/wallet_student_recharge.xlsx differ
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml
index dfe61e962..9d98f962c 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserFaceMapper.xml
@@ -84,8 +84,12 @@
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java
index fe838532f..e17d496d2 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/mch/MchAutoRefundConfigDto.java
@@ -63,4 +63,16 @@ public class MchAutoRefundConfigDto {
/** 过期预约退款是否恢复库存,1恢复、0不恢复。 */
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
+
+ /** 过期未核销自动核销开关,1开启、0关闭。 */
+ @Schema(description = "过期未核销自动核销开关")
+ private String autoVerifyEnabled;
+
+ /** 支付后自动核销天数,单位天,允许1至365;开关开启时必填。 */
+ @Schema(description = "支付后自动核销天数")
+ private Integer autoVerifyDays;
+
+ /** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复。 */
+ @Schema(description = "自动核销是否退库存")
+ private String autoVerifyRestoreInventory;
}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
index 577256eb1..01877a0df 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletRechargeExcelDto.java
@@ -14,6 +14,8 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
+import jakarta.validation.constraints.Size;
+
@Data
@Builder
@AllArgsConstructor
@@ -51,6 +53,14 @@ public class WalletRechargeExcelDto {
@ExcelDict(value = "wallet_transaction_tag", multiple = true)
private String tag;
+ /**
+ * Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。
+ */
+ @ColumnWidth(100)
+ @Size(max = 256, message = "备注不能超过256个字符")
+ @ExcelProperty("备注")
+ private String remark;
+
/**
* 导入校验后解析出的财务标签字典值,多个值使用英文逗号分隔,不直接读取 Excel。
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java
index cc32db6fa..6d4072d02 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/excel/WalletStudentRechargeExcelDto.java
@@ -10,6 +10,8 @@ import com.bsd.pjcloud.common.excel.annotation.ExcelDict;
import com.bsd.pjcloud.common.excel.annotation.ExcelLine;
import lombok.Data;
+import jakarta.validation.constraints.Size;
+
/**
* 学生充值/扣款申请 Excel 导入行。
* 使用学校和学号精确定位学生,再校验学生姓名,避免同名学生被错误充值或扣款。
@@ -52,6 +54,12 @@ public class WalletStudentRechargeExcelDto {
@ExcelDict(value = "wallet_transaction_tag", multiple = true)
private String tag;
+ /** Excel 中填写的流水备注,导入落库后随审批通过写入钱包流水 remark;允许为空。 */
+ @ColumnWidth(100)
+ @Size(max = 256, message = "备注不能超过256个字符")
+ @ExcelProperty("备注")
+ private String remark;
+
/** 校验阶段解析出的财务标签字典值,多个值使用英文逗号分隔。 */
@ExcelIgnore
private String tags;
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java
index 0dd6391a8..9913453ff 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchAutoRefundConfigEntity.java
@@ -79,6 +79,18 @@ public class MchAutoRefundConfigEntity extends Model
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
+ /** 过期未核销自动核销开关,持久化值为1开启、0关闭;与两个自动退款开关相互独立。 */
+ @Schema(description = "过期未核销自动核销开关")
+ private String autoVerifyEnabled;
+
+ /** 支付后自动核销天数,允许1至365;开关开启时必填,快照到订单生成触发时间。 */
+ @Schema(description = "支付后自动核销天数")
+ private Integer autoVerifyDays;
+
+ /** 自动核销是否恢复每日菜谱线上池库存,1恢复、0不恢复;配置保存时快照到订单和任务。 */
+ @Schema(description = "自动核销是否退库存")
+ private String autoVerifyRestoreInventory;
+
/**
* 创建人账号,由公共字段填充器在新增时写入。
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
index d006d8dd1..b2c1d615a 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/recharge/WalletRechargeImportEntity.java
@@ -73,6 +73,12 @@ public class WalletRechargeImportEntity extends Model {
void createAndActiveCard(WalletManageEntity dto);
+ /**
+ * 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现。
+ * 饭卡所属商户包含 Feign 远程调用,必须由调用方在事务开启前解析并传入,
+ * 事务内只保留空闲卡余量检查、必要的独立事务补卡和抢卡绑定,避免拉长抢卡持锁时间。
+ *
+ * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
+ * @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡
+ */
+ void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch);
+
/**
* 取消绑定饭卡
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
index 5e6f0de42..42430b34a 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
@@ -3,13 +3,16 @@ package com.bsd.pjcloud.wallet.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletTopupActionDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
+import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity;
import com.bsd.pjcloud.wallet.api.entity.mch.MchTakeOutSettingEntity;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo;
import jakarta.validation.constraints.NotNull;
@@ -35,6 +38,18 @@ public interface WalletManageService extends IService {
*/
void createWallet(@NotNull Long userId);
+ /**
+ * 创建钱包的短事务实现,仅由 {@link #createWallet(Long, Boolean)} 在准备动作完成后经代理调用。
+ * 事务内只保留钱包占位查询、钱包插入和饭卡自动绑定,不包含任何远程调用;
+ * 所需的用户部门、钱包类型全集和饭卡所属商户均由调用方在事务开启前解析传入。
+ *
+ * @param userId 用户 ID
+ * @param deptId 用户部门 ID
+ * @param walletTypes 事务外已查询的钱包类型全集;为空时不创建任何钱包
+ * @param eatMch 事务外按用户部门解析的饭卡所属商户;为空时饭卡钱包不自动绑卡
+ */
+ void createWalletInTx(Long userId, Long deptId, List walletTypes, MchManageVo eatMch);
+
/**
* 获取钱包没有就创建用户钱包(没有缓存)
*/
@@ -148,6 +163,16 @@ public interface WalletManageService extends IService {
*/
void userChange(Long userId, Boolean isDel);
+ /**
+ * 用户变更同步的短事务实现,仅由 {@link #userChange(Long, Boolean)} 在商户解析完成后经代理调用。
+ * 事务内只保留钱包部门和已绑饭卡商户的本地更新;用户资料与商户解析的远程调用、
+ * 正元第三方同步均由调用方在事务外执行。
+ *
+ * @param sysUser 已查询的用户资料,取部门和用户 ID
+ * @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属
+ */
+ void userChangeInTx(SysUser sysUser, MchManageVo mch);
+
/**
* 修改部门
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
index 843efb0dd..ff6ec9604 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java
@@ -40,6 +40,7 @@ import com.bsd.pjcloud.wallet.service.WalletTypeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -68,7 +69,9 @@ public class WalletActiveServiceImpl extends ServiceImpl学生学校可能挂在“其他食堂”商户下,因此自动绑卡同时匹配其他食堂和自营食堂;
- * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。
+ * 钱包已有卡号时视为已绑定并保持原行为,不重复分配。商户解析包含 Feign 远程调用,
+ * 在事务外完成后委托短事务重载执行,避免远程调用拉长抢卡事务持锁时间。
*
* @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
*/
@Override
- @Transactional(rollbackFor = Exception.class)
public void createAndActiveCard(WalletManageEntity wallet) {
if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber())) return;
// 学校部门可能归属其他食堂,必须与用户部门商户关系同步时采用相同的食堂类型范围。
MchManageVo mch = mchManageService.getFirstMchByDeptId(wallet.getDeptId(),
List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN));
if (ObjectUtil.isEmpty(mch)) return;
+ getSelf().createAndActiveCard(wallet, mch);
+ }
+
+ /**
+ * 为尚未绑定饭卡的钱包分配空闲卡并建立开卡关联的短事务实现,商户由调用方在事务外解析。
+ * 事务内仅包含空闲卡余量 COUNT、必要时的独立事务补卡和抢卡绑定:
+ * 抢卡 SELECT ... FOR UPDATE 依赖 idx_wallet_active_merchant_user_sort 复合索引将锁定范围
+ * 收敛到队首一张卡;卡池余量不足时的批量补卡走独立事务(见 {@link #fastCreateCard}),
+ * 避免插入意向锁与抢卡锁互相等待造成死锁。
+ *
+ * @param wallet 待绑定的饭卡钱包;为空或已有卡号时不执行任何操作
+ * @param mch 事务外按钱包部门解析的饭卡所属商户;为空时不自动绑卡
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void createAndActiveCard(WalletManageEntity wallet, MchManageVo mch) {
+ if (ObjectUtil.isEmpty(wallet) || ObjectUtil.isNotEmpty(wallet.getCardNumber()) || ObjectUtil.isEmpty(mch)) return;
LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
.isNull(WalletActiveEntity::getUserId)
.isNull(WalletActiveEntity::getWalletId)
@@ -206,6 +226,7 @@ public class WalletActiveServiceImpl extends ServiceImpl用户资料、权限参数、钱包类型全集和饭卡所属商户(均含远程调用)全部在事务外解析,
+ * 随后经代理调用短事务 {@link #createWalletInTx} 完成写库;抢卡锁冲突(死锁/锁等待超时)
+ * 时 MySQL 已回滚整笔短事务,直接重试即可,用尽次数后原样抛出。
+ * 钱包已存在的用户保持原有行为跳过,不重复自动绑卡。
+ *
+ * @param userId 用户 ID
+ * @param checkPermission 是否按 WALLET_AUTO_CREATE 角色参数控制自动开户
+ */
@Override
- @Transactional(rollbackFor = Exception.class)
public void createWallet(Long userId, Boolean checkPermission) {
UserInfo user = null;
if (checkPermission) {
@@ -119,22 +135,22 @@ public class WalletManageServiceImpl extends ServiceImpl list = walletTypeMapper.selectList(new LambdaQueryWrapper<>());
if (ObjectUtil.isEmpty(list)) return;
if (ObjectUtil.isEmpty(user)) user = userService.getInnerByUserId(userId).getData();
- for (WalletTypeEntity v : list) {
- WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper()
- .eq(WalletManageEntity::getUserId, userId)
- .eq(WalletManageEntity::getWalletTypeId, v.getId())
- );
- // 创建钱包
- WalletManageEntity insertObj = new WalletManageEntity();
- if (ObjectUtil.isEmpty(wallet)) {
- insertObj.setUserId(userId);
- insertObj.setWalletTypeId(v.getId());
- insertObj.setDeptId(user.getSysUser().getDeptId());
- baseMapper.insert(insertObj);
- }
- // 默认创建饭卡并绑定饭卡钱包
- if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
- walletActiveService.createAndActiveCard(insertObj);
+ Long deptId = user.getSysUser().getDeptId();
+ // 饭卡所属商户解析包含 Feign 远程调用,必须在事务开启前完成,避免拉长抢卡事务持锁时间。
+ MchManageVo eatMch = null;
+ if (list.stream().anyMatch(v -> ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode()))) {
+ eatMch = mchManageService.getFirstMchByDeptId(deptId,
+ List.of(MerchantTypeEnum.OTHER_CANTEEN, MerchantTypeEnum.CANTEEN));
+ }
+ for (int attempt = 1; ; attempt++) {
+ try {
+ getSelf().createWalletInTx(userId, deptId, list, eatMch);
+ return;
+ } catch (PessimisticLockingFailureException ex) {
+ // 死锁/锁等待超时发生时短事务已整体回滚,未产生部分创建的钱包,可直接重试
+ if (attempt >= CREATE_WALLET_MAX_ATTEMPTS) throw ex;
+ log.warn("|{}|创建钱包抢卡锁冲突,第{}次重试,userId:{}", TAG, attempt, userId, ex);
+ sleepBeforeWalletRetry(attempt, ex);
}
}
}
@@ -144,6 +160,54 @@ public class WalletManageServiceImpl extends ServiceImpl仅经代理由 {@link #createWallet(Long, Boolean)} 调用;事务内不发起任何远程调用,
+ * 避免网络往返期间持有抢卡行锁。饭卡类型钱包仅在本次新建成功后才自动绑卡,
+ * 已有钱包(含并发请求刚创建的)直接跳过,与原行为保持一致。
+ *
+ * @param userId 用户 ID
+ * @param deptId 事务外解析的用户部门 ID
+ * @param walletTypes 事务外查询的钱包类型全集
+ * @param eatMch 事务外解析的饭卡所属商户;为空时饭卡钱包不自动绑卡
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void createWalletInTx(Long userId, Long deptId, List walletTypes, MchManageVo eatMch) {
+ for (WalletTypeEntity v : walletTypes) {
+ WalletManageEntity wallet = baseMapper.selectOne(new LambdaQueryWrapper()
+ .eq(WalletManageEntity::getUserId, userId)
+ .eq(WalletManageEntity::getWalletTypeId, v.getId())
+ );
+ if (ObjectUtil.isNotEmpty(wallet)) continue;
+ // 创建钱包
+ WalletManageEntity insertObj = new WalletManageEntity();
+ insertObj.setUserId(userId);
+ insertObj.setWalletTypeId(v.getId());
+ insertObj.setDeptId(deptId);
+ baseMapper.insert(insertObj);
+ // 默认创建饭卡并绑定饭卡钱包
+ if (ObjectUtil.equals(v.getUniqueCode(), WalletTypeEnum.EAT.getCode())) {
+ walletActiveService.createAndActiveCard(insertObj, eatMch);
+ }
+ }
+ }
+
+ /**
+ * 抢卡锁冲突重试前的递增退避;线程被中断时恢复中断标记并终止重试。
+ *
+ * @param attempt 当前已失败的尝试序号(从 1 开始)
+ * @param cause 触发重试的锁冲突异常,中断时原样抛出
+ */
+ private void sleepBeforeWalletRetry(int attempt, PessimisticLockingFailureException cause) {
+ try {
+ Thread.sleep(CREATE_WALLET_RETRY_BACKOFF_MILLIS * attempt);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ throw cause;
+ }
+ }
+
@Override
public WalletManageEntity getOrCreateWallet(Long userId, WalletTypeEnum walletTypeCode) {
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);
@@ -534,8 +598,16 @@ public class WalletManageServiceImpl extends ServiceImpl用户资料查询与食堂商户解析(均含远程调用)在事务外完成,本地更新经短事务
+ * {@link #userChangeInTx} 提交;正元第三方同步放在事务提交后执行,
+ * 避免远程调用和第三方逻辑拉长事务持锁时间,同步异常也不再回滚本地部门变更。
+ *
+ * @param userId 变更用户 ID
+ * @param isDel 是否删除事件;仅处理非删除事件
+ */
@Override
- @Transactional(rollbackFor = Exception.class)
public void userChange(Long userId, Boolean isDel) {
if (ObjectUtil.isEmpty(userId)) return;
if (ObjectUtil.isEmpty(isDel)) return;
@@ -543,27 +615,39 @@ public class WalletManageServiceImpl extends ServiceImpl()
- .set(WalletManageEntity::getDeptId, sysUser.getDeptId())
- .ne(WalletManageEntity::getDeptId, sysUser.getDeptId())
- .eq(WalletManageEntity::getUserId, sysUser.getUserId())
- );
- // 修改钱包冻结开卡商户
- // 查询用户所属于的食堂商户
+ // 查询用户所属于的食堂商户,解析含远程调用,先于事务执行
MchManageVo mch = mchManageService.getFirstMchByDeptId(sysUser.getDeptId(), List.of(MerchantTypeEnum.CANTEEN));
- if (ObjectUtil.isNotEmpty(mch)) {
- walletActiveService.update(new LambdaUpdateWrapper()
- .set(WalletActiveEntity::getMerchantId, mch.getId())
- .ne(WalletActiveEntity::getMerchantId, mch.getId())
- .eq(WalletActiveEntity::getUserId, sysUser.getUserId())
- );
- }
- // 同步到正元
+ getSelf().userChangeInTx(sysUser, mch);
+ // 同步到正元:本地更新提交后执行,失败不影响钱包部门变更
walletThirdZyService.syncAllUser(userId);
}
}
+ /**
+ * 用户变更同步的短事务实现:钱包部门与已绑饭卡商户的本地更新。
+ *
+ * @param sysUser 已查询的用户资料,取部门和用户 ID
+ * @param mch 按用户部门解析的食堂商户;为空时不调整已绑饭卡的商户归属
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void userChangeInTx(SysUser sysUser, MchManageVo mch) {
+ // 修改钱包部门
+ baseMapper.update(new LambdaUpdateWrapper()
+ .set(WalletManageEntity::getDeptId, sysUser.getDeptId())
+ .ne(WalletManageEntity::getDeptId, sysUser.getDeptId())
+ .eq(WalletManageEntity::getUserId, sysUser.getUserId())
+ );
+ // 修改钱包冻结开卡商户
+ if (ObjectUtil.isNotEmpty(mch)) {
+ walletActiveService.update(new LambdaUpdateWrapper()
+ .set(WalletActiveEntity::getMerchantId, mch.getId())
+ .ne(WalletActiveEntity::getMerchantId, mch.getId())
+ .eq(WalletActiveEntity::getUserId, sysUser.getUserId())
+ );
+ }
+ }
+
@Override
public void deptChange(Long deptId, Boolean isDel) {
if (ObjectUtil.isEmpty(deptId)) return;
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java
index 014aa9018..b80387dc1 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchAutoRefundConfigServiceImpl.java
@@ -59,6 +59,15 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl MAX_AUTO_VERIFY_DAYS)) {
+ throw new RuntimeException("支付后自动核销天数必须在1至365天之间");
+ }
+ // 开启自动核销时天数必填,避免任务创建时无法计算触发时间。
+ if (Objects.equals(autoVerifyEnabled, YesNoEnum.YES.getCode()) && autoVerifyDays == null) {
+ throw new RuntimeException("开启自动核销时必须配置支付后天数");
+ }
+ if (autoVerifyRestoreInventory != null && !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.YES.getCode())
+ && !Objects.equals(autoVerifyRestoreInventory, YesNoEnum.NO.getCode())) {
+ throw new RuntimeException("自动核销退库存开关值无效");
+ }
}
/** 解析过期退款延迟,兼容历史配置行新增字段为空的场景。 */
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java
index 5572585b7..860dffe5d 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyImportServiceImpl.java
@@ -167,6 +167,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userDeptId(row.getUserDeptId())
.invoiceId(invoice.getId())
.tags(row.getTags())
+ // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
+ .remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
@@ -197,6 +199,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
+ // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
+ .remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
@@ -227,6 +231,8 @@ public class WalletRechargeApplyImportServiceImpl implements WalletRechargeApply
.userId(row.getUserId())
.userDeptId(row.getUserDeptId())
.tags(row.getTags())
+ // Excel"备注"列随明细落库,审批通过后写入钱包流水 remark
+ .remark(row.getRemark())
.build())
.toList();
walletRechargeImportService.saveBatch(details);
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
index 92986ffe4..3829354b5 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeApplyServiceImpl.java
@@ -369,7 +369,8 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl userIds = importList.stream().map(WalletRechargeImportEntity::getUserId).distinct().toList();