feat:迁移hard和wallet模块的定时任务

feat:增加订餐预约时间管理
This commit is contained in:
dzc committed 2026-10-08 11:48:24 +08:00
1 parent 03e1c65560
commit 940e457930
51 files changed
+1876 -942

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
-- 动态时间窗预约改版:多窗口模型(规则 → 多个开放窗口(完整datetime,支持跨天) → 窗口内 日期×餐段 明细)。
-- 旧模型(相对日期类型 startTimeType/endTimeType + hard_appt_time 映射表)作废:
-- 存量旧规则软删保留(del_flag='1'),hard_appt_time 表 DROP,hard_appt 旧列 DROP。
-- 订单 hard_order.appt_id 语义变为"窗口ID"(指向新表 hard_appt_window);旧订单指向软删规则行,展示有判空保护。
-- 幂等迁移脚本:可反复执行。每条 DDL 先查 information_schema,已存在/已删除则跳过(SELECT 1 占位)。
-- 执行顺序:本脚本 → 重启 hardware 服务 → App 发版 → 管理端前端。
-- 权限复用 hard_hardAppt_*,无新增菜单。
-- 【可选诊断】改造前现状。
SELECT COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' ORDER BY ORDINAL_POSITION;
-- 一、hard_appt 旧列删除(title/hour_type/sort/start_time/start_time_type/end_time/end_time_type/reserve_mode,各自独立判断)。
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `title`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'title');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `hour_type`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'hour_type');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `sort`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'sort');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `start_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'start_time');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `start_time_type`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'start_time_type');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `end_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'end_time');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `end_time_type`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'end_time_type');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(COUNT(*) > 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt` DROP COLUMN `reserve_mode`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' AND COLUMN_NAME = 'reserve_mode');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 二、存量旧规则软删(新模型与旧模型语义不同,作废;旧订单展示字段有判空保护)。
UPDATE `pjcloudx_hardware`.`hard_appt` SET del_flag = '1' WHERE del_flag = '0';
-- 三、旧映射表 DROP(表已不存在则跳过)。
SET @ddl = (SELECT IF(COUNT(*) > 0,
'DROP TABLE `pjcloudx_hardware`.`hard_appt_time`',
'SELECT 1')
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt_time');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 四、新表:开放窗口 + 窗口内可预约明细(日期×餐段)。
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_appt_window` (
`id` bigint(19) NOT NULL AUTO_INCREMENT COMMENT '编号',
`appt_id` bigint(19) NOT NULL COMMENT '预约规则ID(hard_appt.id)',
`mch_id` bigint(19) NOT NULL COMMENT '商户ID',
`start_time` datetime NOT NULL COMMENT '开始预约时间(支持跨天窗口)',
`end_time` datetime NOT NULL COMMENT '结束预约时间(必须晚于开始)',
`auto_refund_time` datetime DEFAULT NULL COMMENT '免审退款申请截止时间(闭区间),空=开放预约结束时间;须在(start_time,end_time]范围内',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标记,0正常,1删除',
`tenant_id` bigint(19) DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
KEY `idx_appt_window_appt` (`tenant_id`, `appt_id`, `del_flag`),
KEY `idx_appt_window_mch` (`tenant_id`, `mch_id`, `del_flag`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '动态时间窗预约开放窗口';
-- 四点一、已按旧版建表的库补列(列已存在则跳过)。
SET @ddl = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE `pjcloudx_hardware`.`hard_appt_window` ADD COLUMN `auto_refund_time` datetime DEFAULT NULL COMMENT ''免审退款申请截止时间(闭区间),空=开放预约结束时间;须在(start_time,end_time]范围内'' AFTER `end_time`',
'SELECT 1')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt_window' AND COLUMN_NAME = 'auto_refund_time');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS `pjcloudx_hardware`.`hard_appt_item` (
`id` bigint(19) NOT NULL AUTO_INCREMENT COMMENT '编号',
`window_id` bigint(19) NOT NULL COMMENT '预约窗口ID(hard_appt_window.id)',
`mch_id` bigint(19) NOT NULL COMMENT '商户ID',
`target_date` date NOT NULL COMMENT '目标用餐日期',
`hour_type` varchar(32) NOT NULL COMMENT '餐段字典值(daily_menu)',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '删除标记,0正常,1删除',
`tenant_id` bigint(19) DEFAULT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
KEY `idx_appt_item_window` (`tenant_id`, `window_id`, `del_flag`),
KEY `idx_appt_item_mch_date` (`tenant_id`, `mch_id`, `target_date`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '动态时间窗可预约明细(日期×餐段)';
-- 五、【验收】
-- 1) hard_appt 仅剩规则头字段 + 审计列:
SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt' ORDER BY ORDINAL_POSITION;
-- 2) 旧规则全部软删(期望 0):
SELECT COUNT(*) AS active_old_rules FROM `pjcloudx_hardware`.`hard_appt` WHERE del_flag = '0';
-- 3) 旧映射表已不存在(期望 0):
SELECT COUNT(*) AS legacy_time_table FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt_time';
-- 4) 新表就位:
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME IN ('hard_appt_window', 'hard_appt_item');
-- 5) 窗口表含免审退款截止列(期望 1):
SELECT COUNT(*) AS window_has_auto_refund_time FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'pjcloudx_hardware' AND TABLE_NAME = 'hard_appt_window' AND COLUMN_NAME = 'auto_refund_time';
@@ -0,0 +1,91 @@
-- 20261004 钱包流水表排序索引优化(可重复执行)
-- ⚠️ 本脚本第 4、5 步(前缀索引)已被 20261005_wallet_transaction_biz_type_varchar.sql 接替:
-- biz_type 改为 varchar 后重建为全列索引。线上已执行过 20261005 的环境勿再执行本脚本。
-- 背景:/api/wallet/wallet-transaction/page-all 分页查询很慢;索引生效后发现 page-cz/page-jz 仍慢(3~6s)。
-- 原因:查询固定排序 create_time DESC, user_id DESC, id DESC,
-- 旧索引 (tenant_id, del_flag, create_time DESC, user_id DESC) 末尾隐式追加的主键 id 为 ASC,
-- 与 ORDER BY 最后一段 id DESC 方向不匹配,索引无法提供有序结果,
-- 优化器对租户内全部流水做 filesort(执行计划 Using filesort),数据量越大越慢。
-- 方案:建显式包含 id DESC 的 5 列索引,排序完全走索引,LIMIT 只需读取前 N 条;
-- 旧 4 列索引被新索引前缀完全覆盖,一并删除以降低流水表写入放大。
-- 已在测试库(123.157.250.162:28816/pjcloudx_wallet)验证:四种查询形态(首页/部门权限包裹/业务类型+时间范围/冷门部门)
-- filesort 全部消除;删除旧索引后计划不变。
-- 幂等方式:本环境(OceanBase MySQL 模式)不支持 CREATE INDEX IF NOT EXISTS / DROP INDEX IF EXISTS,
-- 改用 information_schema.statistics 判断索引是否存在后经 PREPARE 动态执行;
-- 已存在/已删除时输出 skip 提示,重复执行无副作用。
-- 执行顺序:在线 DDL 不锁表,可独立执行;若担心流水表写入放大,可只执行第 1、2 步。
USE pjcloudx_wallet;
-- 1. 新建 5 列排序索引(与 Mapper commonWrapperQuery 的 ORDER BY 完全同序)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_ct_uid_id') = 0,
'CREATE INDEX idx_wallet_tx_tenant_del_ct_uid_id ON wallet_transaction (tenant_id, del_flag, create_time DESC, user_id DESC, id DESC)',
'SELECT ''skip: idx_wallet_tx_tenant_del_ct_uid_id 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 删除被完全覆盖的旧 4 列索引(第 1 步成功后执行)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_create_user') > 0,
'DROP INDEX idx_wallet_tx_tenant_del_create_user ON wallet_transaction',
'SELECT ''skip: idx_wallet_tx_tenant_del_create_user 不存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. 部门前置索引:数据权限为"本部门/自定义部门"的操作者,最终 SQL 是外层 dept_id IN (...) 过滤。
-- 部门占比小时按 create_time 顺序扫会翻大量无关行;该索引让优化器按部门范围直取,
-- 部门内排序与索引后缀同序同样免 filesort(已验证:冷门部门走此索引,大部门自动走 5 列索引)。
-- 代价是流水表(高写入)多维护一个二级索引,如写入压力敏感可只执行 1、2 步。
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_dept_ct') = 0,
'CREATE INDEX idx_wallet_tx_tenant_del_dept_ct ON wallet_transaction (tenant_id, del_flag, dept_id, create_time DESC, user_id DESC, id DESC)',
'SELECT ''skip: idx_wallet_tx_tenant_del_dept_ct 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 4. 业务类型+金额前缀索引:page-cz / page-jz 固定带 biz_type IN ('1','2','3') + price>0 / price<0。
-- biz_type 为 tinytext 无法整列建 B-tree(只能前缀),price 也不在任何索引中,
-- 此前 COUNT 与翻页需对租户内全部行回表校验这两个字段;记账扣款(price<0)仅约 0.5% 行且偏旧,
-- page-jz 首页还要按时间倒序翻过大量支付流水。
-- 本索引把 jz 的 COUNT/翻页访问行数从全租户降到 ~0.5%(测试库 40195→443),cz 同理;
-- 已验证 page-all 仍走 5 列排序索引、page-xf(biz_type='4') 计划不变,无回归。
-- 注:前缀索引仍需回表核对 biz_type 完整值,线上行数放大后 COUNT 预计降至 1s 内。
-- ⚠️ 已知代价(2026-10-04 线上实测):本索引会使优化器把 page-jz 的翻页查询切换为
-- "全量命中集回表+filesort"(无提前终止),jz 从 6s 恶化到 10s;cz/xf 等不受影响。
-- 根因:biz_type 为 tinytext 只能建前缀索引,前缀列的条件不会下推到索引层(ICP),
-- 也永远无法构成覆盖索引,多值 IN(1,2,3) 又无法沿用 biz 前导索引的顺序。
-- 纯索引手段无法同时优化 jz 的 COUNT 与翻页;根治需改代码(UNION 逐 biz_type 等值分支
-- + FORCE INDEX(idx_wallet_tx_biz_ct))或授权将 biz_type 改为 varchar 后建覆盖索引。
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_biz_price') = 0,
'CREATE INDEX idx_wallet_tx_tenant_del_biz_price ON wallet_transaction (tenant_id, del_flag, biz_type(2), price)',
'SELECT ''skip: idx_wallet_tx_tenant_del_biz_price 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 5. biz 前导有序索引:让单值 biz_type 等值/单元素 IN 的翻页(page-tk 退款/page-th 退还等)
-- 自动走"biz 前缀定位 + 索引序 + price ICP 过滤",跳过无关业务类型的回表;
-- 同时为 page-jz/page-cz 的 UNION 改写(代码修复)预置索引。
-- 注意:本索引对 jz/cz(多值 IN)的翻页无直接改善,仅建议需要提速 tk/th 或配合代码修复时执行。
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_biz_ct') = 0,
'CREATE INDEX idx_wallet_tx_biz_ct ON wallet_transaction (tenant_id, del_flag, biz_type(2), create_time DESC, user_id DESC, id DESC, price)',
'SELECT ''skip: idx_wallet_tx_biz_ct 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,101 @@
-- 20261005 wallet_transaction.biz_type 改列类型 + 全列索引重建(可重复执行)
-- 背景:page-cz / page-jz 在线上(约150万行)仍需 ~10s。根因是 biz_type 为 tinytext:
-- 只能建前缀索引,而前缀列条件不会下推索引层(ICP)、无法构成覆盖索引、
-- 多值 IN 也无法沿用前导列索引顺序——纯索引优化已到头(排序问题已由 20261004 脚本解决)。
-- 方案:biz_type 改为 VARCHAR(8)(现值域 '1'-'9',实体字段为 String,应用零改动、读写兼容),
-- 重建 3 个全列索引后:COUNT 走覆盖索引零回表,翻页 biz_type/price 全部索引内过滤。
-- 验证:测试库(81k 行)全量 EXPLAIN ANALYZE 实测——
-- jz COUNT 0.65ms(覆盖索引)、jz 翻页 2.5ms、cz COUNT 6.2ms、page-all 0.23ms、
-- 消费翻页 0.16ms、部门权限包裹翻页 0.26ms;ALTER 在 81k 行/6 索引上用时 19s。
-- ⚠️ 执行要求:第 2 步 ALGORITHM=COPY 期间**阻塞该表写入**(读不受影响),
-- 150 万行含索引重建预计 5~15 分钟,必须在低峰窗口执行;执行期间勿并发再跑本脚本。
-- 顺序:先删两个前缀 biz 索引以缩短 COPY 时间 → 改列 → 建新索引 → 删被前缀覆盖的旧索引。
-- 回滚:将 biz_type 改回 TINYTEXT NOT NULL(同样 COPY 需窗口)并按 20261004 脚本重建前缀索引。
USE pjcloudx_wallet;
-- 1. 删除 tinytext 时期的前缀索引(缩短第 2 步 COPY 重建时间)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_biz_price') > 0,
'DROP INDEX idx_wallet_tx_tenant_del_biz_price ON wallet_transaction',
'SELECT ''skip: idx_wallet_tx_tenant_del_biz_price 不存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_biz_ct') > 0,
'DROP INDEX idx_wallet_tx_biz_ct ON wallet_transaction',
'SELECT ''skip: idx_wallet_tx_biz_ct 不存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 改列:tinytext -> varchar(8)(⚠️ 阻塞写入,需低峰窗口)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND column_name = 'biz_type' AND data_type = 'tinytext') > 0,
'ALTER TABLE wallet_transaction MODIFY biz_type VARCHAR(8) NOT NULL COMMENT ''关联类型'', ALGORITHM=COPY',
'SELECT ''skip: biz_type 已是 varchar'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. 全列排序+过滤索引(page-all/各 tab 翻页:索引序 + biz_type/price 索引内过滤)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_order_biz') = 0,
'CREATE INDEX idx_wallet_tx_order_biz ON wallet_transaction (tenant_id, del_flag, create_time DESC, user_id DESC, id DESC, biz_type, price)',
'SELECT ''skip: idx_wallet_tx_order_biz 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 4. biz_type 前导覆盖索引(page-cz/page-jz/page-xf 等的 COUNT:零回表)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_biz_price') = 0,
'CREATE INDEX idx_wallet_tx_biz_price ON wallet_transaction (tenant_id, del_flag, biz_type, price)',
'SELECT ''skip: idx_wallet_tx_biz_price 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 5. 部门数据权限全列索引(部门包裹翻页/COUNT)
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_dept_biz') = 0,
'CREATE INDEX idx_wallet_tx_dept_biz ON wallet_transaction (tenant_id, del_flag, dept_id, create_time DESC, user_id DESC, id DESC, biz_type, price)',
'SELECT ''skip: idx_wallet_tx_dept_biz 已存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 6. 删除被新索引前缀完全覆盖的旧排序/部门索引
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_ct_uid_id') > 0,
'DROP INDEX idx_wallet_tx_tenant_del_ct_uid_id ON wallet_transaction',
'SELECT ''skip: idx_wallet_tx_tenant_del_ct_uid_id 不存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'pjcloudx_wallet' AND table_name = 'wallet_transaction'
AND index_name = 'idx_wallet_tx_tenant_del_dept_ct') > 0,
'DROP INDEX idx_wallet_tx_tenant_del_dept_ct ON wallet_transaction',
'SELECT ''skip: idx_wallet_tx_tenant_del_dept_ct 不存在'' AS msg');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,85 @@
========================================================================
钱包自动创建补漏检查 - 定时任务手工添加清单 (2026-10-08)
========================================================================
说明:以下内容需要管理员在对应环境手工执行(直接执行 SQL)。
SQL 按租户幂等,风格与 db/20260924_钱包预约执行定时任务.txt 一致。
注意库名:sys_job 在 `pjcloudx_job` 库(与 qrtz_* / sys_job_log 同库)。
一、迁移背景与功能说明:
用户开通钱包依赖创建入口调用 wallet 服务 createWallet(内部按 WALLET_AUTO_CREATE
参数配置的角色控制自动开户)。用户导入、历史数据或调用方漏调等原因可能造成
「拥有自动开户角色但从未执行过创建钱包」的用户(无 wallet_manage 记录、无饭卡)。
本任务每天凌晨跨租户扫描这类用户并补建钱包(饭卡钱包照常自动绑卡),属于检查
补漏性质,不改变任何已有钱包数据;补建复用现有幂等创建逻辑,重复触发安全。
执行链路(daemon-quartz sys_job → Feign → wallet 内部端点 → UPMS 内部端点):
walletManageTask.ensureAutoCreateWallets
→ POST /wallet/wallet-manage/inner/ensure-auto-create-wallets(@Inner)
→ 遍历全部有效租户(RemoteTenantService.list)
→ 逐租户读 WALLET_AUTO_CREATE 参数(角色编码)
→ GET /upms/user/inner/user-ids-by-role-code(@Inner,本次新增,按角色编码
查当前有效用户 ID 列表,已过滤角色关系里残留的已删除用户)
→ 分批比对 wallet_manage,筛出完全没有钱包记录的用户
→ 逐个调用既有 createWallet(userId, false) 幂等补建(已有即跳过)
二、部署顺序(硬前提,反序部署会导致任务首轮回调失败并记失败日志):
1. 部署包含本次改动的 pjcloud-upms-biz(新增内部端点
GET /user/inner/user-ids-by-role-code);
2. 部署包含本次改动的 pjcloud-wallet-biz(新增内部端点
POST /wallet-manage/inner/ensure-auto-create-wallets 及补漏实现);
3. 部署包含本次改动的 pjcloud-daemon-quartz(新增 spring bean walletManageTask);
4. 执行下方 sys_job INSERT(job_status='2' 运行中);
5. daemon-quartz 重启时由 PjcloudInitQuartzJob 自动注册进 Quartz;服务不重启可调
管理端 POST /sys-job/refresh-jobs(权限 job_sys_job_refresh_job)手动加载。
空窗期影响:任务每天 04:00 才触发,部署顺序颠倒最多造成首轮回调失败并记一条
失败日志,下一个调度周期自动恢复,无数据风险。
三、行为说明:
- cron '0 0 4 * * ?':每天凌晨 4 点执行一次;
- misfire_policy='3',@DisallowConcurrentExecution 由统一 PjcloudQuartzFactory 保证,
多实例部署下 Quartz 集群锁兜底,任务不会并发执行;
- 单个租户失败不影响其余租户,单个用户补建失败不影响其余用户(记 error 日志),
均由下一轮任务自动重试;
- 租户未配置 WALLET_AUTO_CREATE 参数时该租户跳过(info 日志),不影响其他租户;
- 任务 sys_job 行 tenant_id=1 仅用于 daemon 侧调度上下文,wallet 端点内部自行
遍历全部有效租户,所有租户的角色用户都会被覆盖。
------------------------------------------------------------------------
四、sys_job:钱包自动创建补漏检查(job_type=2,反射调 daemon 内 spring bean)
------------------------------------------------------------------------
INSERT INTO `pjcloudx_job`.`sys_job`
(`job_id`, `job_name`, `job_group`, `job_order`, `job_type`, `execute_path`, `class_name`, `method_name`,
`method_params_value`, `cron_expression`, `misfire_policy`, `job_tenant_type`, `job_status`,
`job_execute_status`, `create_by`, `create_time`, `update_by`, `update_time`, `tenant_id`, `remark`)
SELECT (SELECT GREATEST(COALESCE(MAX(`job_id`), 0), 2026100800000000000) FROM `pjcloudx_job`.`sys_job`) + 1,
'钱包自动创建补漏检查', 'WALLET', '3', '2', NULL, 'walletManageTask', 'ensureAutoCreateWallets',
NULL, '0 0 4 * * ?', '3', '1', '2',
'0', 'system', NOW(), 'system', NOW(), 1, '每天凌晨检查拥有WALLET_AUTO_CREATE角色但未创建钱包的用户并补建'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `pjcloudx_job`.`sys_job`
WHERE `job_name` = '钱包自动创建补漏检查' AND `job_group` = 'WALLET' AND `tenant_id` = 1
);
------------------------------------------------------------------------
五、内部端点与任务对应关系(@Inner + @NoToken 仅服务间调用,勿对外)
------------------------------------------------------------------------
GET /upms/user/inner/user-ids-by-role-code 按角色编码查当前有效用户ID列表
(本次新增,RemoteUserService
getInnerUserIdsByRoleCode)
POST /wallet/wallet-manage/inner/ensure-auto-create-wallets
跨租户补建缺失钱包(本次新增,
RemoteWalletService
ensureAutoCreateWallets)
------------------------------------------------------------------------
六、回滚说明
------------------------------------------------------------------------
停用任务:将 sys_job 该行 job_status 改为 '4'(删除)或 '3'(暂停)并重启
daemon-quartz(或调 POST /sys-job/refresh-jobs);
代码回滚:回滚 upms-biz / wallet-biz / daemon-quartz 三个服务即可,本次无
表结构变更、无菜单权限变更;已补建的钱包数据与正常创建的钱包一致,
如确需清理请先与业务确认后按 wallet_manage / wallet_active 记录逐条核对处理。
@@ -912,18 +912,9 @@ public interface ErrorCodes {
/** 预约餐段已停用 */
String HARD_APPT_SLOT_DISABLED = "hard.appt.slot.disabled";
/** 预约餐段与商户当前模式不匹配 */
String HARD_APPT_SLOT_MODE_MISMATCH = "hard.appt.slot.mode.mismatch";
/** 该预约日期不在可选范围内 */
String HARD_APPT_DATE_OUT_RANGE = "hard.appt.date.out.range";
/** 该日期未配置此预约餐段 */
String HARD_APPT_DATE_NO_SLOT = "hard.appt.date.no.slot";
/** 预约日期类型无效 */
String HARD_APPT_DATE_TYPE_INVALID = "hard.appt.date.type.invalid";
/** 动态预约餐段不能为空 */
String HARD_APPT_DYNAMIC_SLOT_EMPTY = "hard.appt.dynamic.slot.empty";
@@ -936,15 +927,9 @@ public interface ErrorCodes {
/** 预约已截止 */
String HARD_APPT_CLOSED = "hard.appt.closed";
/** 预约规则模式无效 */
String HARD_APPT_RULE_MODE_INVALID = "hard.appt.rule.mode.invalid";
/** 预约规则配置不完整 */
String HARD_APPT_RULE_INCOMPLETE = "hard.appt.rule.incomplete";
/** 预约规则时间范围无效 */
String HARD_APPT_RULE_TIME_INVALID = "hard.appt.rule.time.invalid";
/** 周期预约规则不存在或已停用 */
String HARD_APPT_CYCLE_MISSING = "hard.appt.cycle.missing";
@@ -287,17 +287,12 @@ hard.stock.no.resp=No response querying product stock
hard.appt.slot.missing=Reservation slot not found
hard.appt.slot.mch.mismatch=Reservation slot does not match merchant
hard.appt.slot.disabled=Reservation slot disabled
hard.appt.slot.mode.mismatch=Reservation slot mismatches merchant mode
hard.appt.date.out.range=Reservation date out of range
hard.appt.date.no.slot=This date has no such reservation slot
hard.appt.date.type.invalid=Invalid reservation date type
hard.appt.dynamic.slot.empty=Dynamic reservation slot required
hard.appt.period.closed=Current reservation period closed
hard.appt.not.open=Reservation not open yet
hard.appt.closed=Reservation closed
hard.appt.rule.mode.invalid=Invalid reservation rule mode
hard.appt.rule.incomplete=Reservation rule incomplete
hard.appt.rule.time.invalid=Reservation rule time range invalid
hard.appt.cycle.missing=Cycle rule missing or disabled
hard.appt.mch.conf.fail=Failed to load merchant reservation config
hard.appt.mch.mode.invalid=Invalid merchant reservation time mode
@@ -287,17 +287,12 @@ hard.stock.no.resp=查询商品库存无响应
hard.appt.slot.missing=预约餐段不存在
hard.appt.slot.mch.mismatch=预约餐段与当前商户不匹配
hard.appt.slot.disabled=预约餐段已停用
hard.appt.slot.mode.mismatch=预约餐段与商户当前模式不匹配
hard.appt.date.out.range=该预约日期不在可选范围内
hard.appt.date.no.slot=该日期未配置此预约餐段
hard.appt.date.type.invalid=预约日期类型无效
hard.appt.dynamic.slot.empty=动态预约餐段不能为空
hard.appt.period.closed=本期预约已截止
hard.appt.not.open=预约尚未开放
hard.appt.closed=预约已截止
hard.appt.rule.mode.invalid=预约规则模式无效
hard.appt.rule.incomplete=预约规则配置不完整
hard.appt.rule.time.invalid=预约规则时间范围无效
hard.appt.cycle.missing=周期预约规则不存在或已停用
hard.appt.mch.conf.fail=获取商户预约配置失败
hard.appt.mch.mode.invalid=商户预约时间模式无效
@@ -287,17 +287,12 @@ hard.stock.no.resp=查詢商品庫存無響應
hard.appt.slot.missing=預約餐段不存在
hard.appt.slot.mch.mismatch=預約餐段與當前商戶不匹配
hard.appt.slot.disabled=預約餐段已停用
hard.appt.slot.mode.mismatch=預約餐段與商戶當前模式不匹配
hard.appt.date.out.range=該預約日期不在可選範圍內
hard.appt.date.no.slot=該日期未配置此預約餐段
hard.appt.date.type.invalid=預約日期類型無效
hard.appt.dynamic.slot.empty=動態預約餐段不能為空
hard.appt.period.closed=本期預約已截止
hard.appt.not.open=預約尚未開放
hard.appt.closed=預約已截止
hard.appt.rule.mode.invalid=預約規則模式無效
hard.appt.rule.incomplete=預約規則配置不完整
hard.appt.rule.time.invalid=預約規則時間範圍無效
hard.appt.cycle.missing=週期預約規則不存在或已停用
hard.appt.mch.conf.fail=獲取商戶預約配置失敗
hard.appt.mch.mode.invalid=商戶預約時間模式無效
@@ -1,47 +0,0 @@
package com.bsd.pjcloud.hard.api.constant;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 硬件预约时间类型
*/
@AllArgsConstructor
@Getter
public enum HardApptTimeTypeEnum {
YESTERDAY("1", -1, "昨天"),
TODAY("2", 0, "今天"),
TOMORROW("3", 1, "明天"),
AFTER_TOMORROW("4", 2, "后天"),
;
private final String type;
private final Integer day;
private final String description;
public static HardApptTimeTypeEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) return null;
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null);
}
public static HardApptTimeTypeEnum valueOfByTypeOrToday(String type) {
if (ObjectUtil.isEmpty(type)) {
return HardApptTimeTypeEnum.TODAY;
}
return valueOfByType(type);
}
public static HardApptTimeTypeEnum valueOfByDay(int day) {
return Arrays.stream(values()).filter(value -> value.getDay() == day).findFirst().orElse(null);
}
public static boolean isReservationDateType(String type) {
HardApptTimeTypeEnum value = valueOfByType(type);
return value != null && value.getDay() >= 0;
}
}
@@ -5,9 +5,12 @@ import lombok.Data;
import java.util.List;
/**
* 动态时间窗预约规则入参:一条规则携带多个开放窗口,每窗口携带日期×餐段目标集合。
*/
@Data
public class HardApptDto extends HardApptEntity {
private List<String> reserveTimeList;
private List<HardApptWindowDto> windowList;
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.hard.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
/**
* 动态时间窗可预约目标项:目标用餐日期 × 餐段。
*/
@Data
@Schema(description = "动态时间窗可预约目标项")
public class HardApptItemDto {
@Schema(description = "目标用餐日期")
private LocalDate targetDate;
@Schema(description = "餐段字典值(daily_menu)")
private String hourType;
}
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.hard.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 动态时间窗开放窗口:一次性绝对时间窗(支持跨天)及其可预约目标集合。
*/
@Data
@Schema(description = "动态时间窗开放窗口")
public class HardApptWindowDto {
@Schema(description = "窗口ID;规则新增/整单重建时为空,窗口级编辑时必填")
private Long id;
@Schema(description = "开始预约时间")
private LocalDateTime startTime;
@Schema(description = "结束预约时间")
private LocalDateTime endTime;
@Schema(description = "免审退款申请截止时间(闭区间);空则按开放预约结束时间处理,须在(start_time,end_time]范围内")
private LocalDateTime autoRefundTime;
@Schema(description = "可预约目标集合(日期×餐段)")
private List<HardApptItemDto> itemList;
}
@@ -74,11 +74,6 @@ public class HardAppCreateDailyMenuReserveOrdersDto {
*/
private String hourType;
/**
* 预约时间类型,沿用预约列表返回的协议值并参与预约有效性校验。
*/
private String apptTimeType;
/**
* 实际用餐日期,精确到日,并用于同日同餐段重复预约校验。
*/
@@ -58,10 +58,7 @@ public class HardAppCreateOrderDto {
// 餐端;周期预约必传,动态时间窗由 apptId 对应规则确定
private String hourType;
// 预约时间类型
private String apptTimeType;
// 实际预约日期;新客户端必须传递,apptTimeType 仅用于兼容旧客户端
// 实际预约日期;动态/周期预约均必须传递
private LocalDate apptDate;
// 地址
@@ -38,7 +38,5 @@ public class HardAppCreateReserveOrdersDto {
private String hourType;
private String apptTimeType;
private LocalDate apptDate;
}
@@ -7,18 +7,17 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 设备预约管理
* 动态时间窗预约规则(多窗口模型的规则头)。
* 开放时间窗见 {@link HardApptWindowEntity},窗口内可预约明细见 {@link HardApptItemEntity}。
*/
@Data
@TenantTable
@TableName("hard_appt")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "设备预约管理")
@Schema(description = "动态时间窗预约规则")
public class HardApptEntity extends Model<HardApptEntity> {
@@ -35,54 +34,6 @@ public class HardApptEntity extends Model<HardApptEntity> {
@Schema(description = "商户id")
private Long mchId;
/**
* 标题
*/
@Schema(description = "标题")
private String title;
/**
* 开始时间
*/
@Schema(description = "开始时间")
private LocalTime startTime;
/**
* 开始时间类型
*/
@Schema(description = "开始时间类型")
private String startTimeType;
/**
* 结束时间
*/
@Schema(description = "结束时间")
private LocalTime endTime;
/**
* 结束时间
*/
@Schema(description = "结束时间")
private String endTimeType;
/**
* 预约限制模式
*/
@Schema(description = "预约限制模式(TIME_WINDOW动态时间窗,WEEKLY_CYCLE按周集中预约)")
private String reserveMode;
/**
* 预约时间段
*/
@Schema(description = "预约时间段")
private String hourType;
/**
* 排序
*/
@Schema(description = "排序")
private Integer sort;
/**
* 状态
*/
@@ -1,91 +1,67 @@
package com.bsd.pjcloud.hard.api.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import com.bsd.pjcloud.common.core.util.TenantTable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 设备预约时间管理
* 动态时间窗窗口内的可预约明细:目标用餐日期 × 餐段。
*/
@Data
@TenantTable
@TableName("hard_appt_time")
@TableName("hard_appt_item")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "设备预约时间管理")
public class HardApptTimeEntity extends Model<HardApptTimeEntity> {
@Schema(description = "动态时间窗可预约明细(日期×餐段)")
public class HardApptItemEntity extends Model<HardApptItemEntity> {
/**
* 编号
*/
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "编号")
private Long id;
/**
* 商户ID
*/
@Schema(description = "预约窗口ID(hard_appt_window.id)")
private Long windowId;
@Schema(description = "商户ID")
private Long mchId;
/**
* appt_id
*/
@Schema(description = "appt_id")
private Long apptId;
@Schema(description = "目标用餐日期")
private LocalDate targetDate;
/**
* 预约时间类型(昨天、今天、明天、自定义)
*
* @see com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum
*/
@Schema(description = "预约时间类型(昨天、今天、明天、自定义)")
private String type;
@Schema(description = "餐段字典值(daily_menu)")
private String hourType;
/**
* 创建人
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
/**
* 修改人
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
/**
* 修改时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
/**
* 删除标记
*/
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "删除标记")
private String delFlag;
/**
* 租户ID
*/
@Schema(description = "租户ID")
private Long tenantId;
}
}
@@ -0,0 +1,69 @@
package com.bsd.pjcloud.hard.api.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 动态时间窗预约的开放窗口:一次性绝对时间窗(支持跨天),订单 appt_id 指向本表。
*/
@Data
@TenantTable
@TableName("hard_appt_window")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "动态时间窗预约开放窗口")
public class HardApptWindowEntity extends Model<HardApptWindowEntity> {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "编号")
private Long id;
@Schema(description = "预约规则ID(hard_appt.id)")
private Long apptId;
@Schema(description = "商户ID")
private Long mchId;
@Schema(description = "开始预约时间")
private LocalDateTime startTime;
@Schema(description = "结束预约时间")
private LocalDateTime endTime;
@Schema(description = "免审退款申请截止时间(闭区间),空=开放预约结束时间;须在(start_time,end_time]范围内")
private LocalDateTime autoRefundTime;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "删除标记")
private String delFlag;
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -14,8 +14,5 @@ public class HardApptDateOptionsVo {
/** 实际用餐日期。 */
private LocalDate apptDate;
/** 当日期属于旧版今天/明天/后天范围时返回,用于平滑升级。 */
private String apptTimeType;
private List<HardApptOptionVo> apptList;
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.hard.api.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDate;
/**
* 动态时间窗可预约目标项展示:目标用餐日期 × 餐段。
*/
@Data
@Schema(description = "动态时间窗可预约目标项展示")
public class HardApptItemVo {
@Schema(description = "目标用餐日期")
private LocalDate targetDate;
@Schema(description = "餐段字典值(daily_menu)")
private String hourType;
}
@@ -1,24 +1,41 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* App 订餐预约餐段选项。
* App 订餐预约餐段选项。动态时间窗 id 为窗口 ID(订单 appt_id 提交该值);周期预约 apptRuleId 为规则 ID。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class HardApptOptionVo extends HardApptEntity {
@Schema(description = "App 订餐预约餐段选项")
public class HardApptOptionVo {
/** 动态时间窗窗口ID;周期预约为 null。 */
private Long id;
/** 周期预约规则ID;动态时间窗为 null。 */
private Long apptRuleId;
/** 周期预约直接使用餐端名称;动态时间窗继续使用 hard_appt.title。 */
/** 预约限制模式:TIME_WINDOW / WEEKLY_CYCLE。 */
private String reserveMode;
/** 餐段编码(daily_menu)。 */
private String hourType;
/** 餐段名称。 */
private String hourTypeName;
/** 选项标题,默认取餐段名称。 */
private String title;
/** 动态时间窗窗口起止时间;周期预约为 null。 */
private LocalDateTime startTime;
/** 动态时间窗窗口起止时间;周期预约为 null。 */
private LocalDateTime endTime;
/** 当前时刻是否允许提交该日期和餐段。 */
private Boolean available;
@@ -1,18 +0,0 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardApptTimeCascadeVo {
private String apptTimeType;
private LocalDate apptTimeWeekStr;
private List<HardApptEntity> apptList;
}
@@ -1,12 +0,0 @@
package com.bsd.pjcloud.hard.api.vo;
import lombok.Data;
@Data
public class HardApptTimeTypeVo {
private Long mchId;
private Long type;
}
@@ -5,9 +5,12 @@ import lombok.Data;
import java.util.List;
/**
* 动态时间窗预约规则展示:含全部开放窗口及窗口内可预约明细。
*/
@Data
public class HardApptVo extends HardApptEntity {
private List<String> reserveTimeList;
private List<HardApptWindowVo> windowList;
}
@@ -0,0 +1,30 @@
package com.bsd.pjcloud.hard.api.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 动态时间窗开放窗口展示:一次性绝对时间窗及其可预约目标集合。
*/
@Data
@Schema(description = "动态时间窗开放窗口展示")
public class HardApptWindowVo {
@Schema(description = "窗口ID(订单 appt_id 指向该值)")
private Long id;
@Schema(description = "开始预约时间")
private LocalDateTime startTime;
@Schema(description = "结束预约时间")
private LocalDateTime endTime;
@Schema(description = "免审退款申请截止时间(闭区间),空=开放预约结束时间")
private LocalDateTime autoRefundTime;
@Schema(description = "可预约目标集合(日期×餐段)")
private List<HardApptItemVo> itemList;
}
@@ -12,7 +12,7 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.util.List;
/** 硬件订单管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@@ -102,9 +102,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
private String apptReserveMode;
private LocalTime apptStartTime;
private LocalDateTime apptStartTime;
private LocalTime apptEndTime;
private LocalDateTime apptEndTime;
/**
* 外卖订单的期望送达配置,来源于 hard_take_out_appt;非外卖或历史未记录时为空。
@@ -2,19 +2,17 @@ package com.bsd.pjcloud.hard.controller;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import cn.hutool.core.util.ArrayUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.api.vo.HardApptVo;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardApptService;
@@ -31,12 +29,12 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 设备预约管理
* 动态时间窗预约规则管理(多窗口模型)
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/hard-appt")
@Tag(description = "hard-appt", name = "设备预约管理管理")
@Tag(description = "hard-appt", name = "动态时间窗预约规则管理")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardApptController {
@@ -45,23 +43,6 @@ public class HardApptController {
/* ************************************* start app start ************************************* */
@Operation(summary = "app预约时间列表", description = "app预约时间列表")
@ApiCryptoAes
@GetMapping("/app/list")
public R<List<HardApptEntity>> appList(@ParameterObject HardApptDto dto) {
if (ObjectUtil.isEmpty(dto.getMchId())) {
return R.ok(List.of());
}
return R.ok(hardApptReservationService.listLegacyFlatOptions(dto.getMchId()));
}
@Operation(summary = "app预约时间列表v2", description = "app预约时间列表v2")
@ApiCryptoAes
@GetMapping("/app/v2/list")
public R<List<HardApptTimeCascadeVo>> appV2List(@ParameterObject HardApptDto dto) {
return R.ok(hardApptReservationService.listLegacyOptions(dto.getMchId()));
}
@Operation(summary = "app预约时间列表v3", description = "按商户当前预约模式返回实际用餐日期和餐段")
@ApiCryptoAes
@GetMapping("/app/v3/list")
@@ -85,24 +66,42 @@ public class HardApptController {
return R.ok(hardApptService.join(id));
}
@Operation(summary = "新增设备预约管理", description = "新增设备预约管理")
@SysLog("新增设备预约管理")
@Operation(summary = "新增预约规则", description = "新增预约规则")
@SysLog("新增预约规则")
@PostMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_add')")
public R save(@RequestBody HardApptDto hardAppt) {
return R.ok(hardApptService.create(hardAppt));
}
@Operation(summary = "修改设备预约管理", description = "修改设备预约管理")
@SysLog("修改设备预约管理")
@Operation(summary = "修改预约规则", description = "修改预约规则;windowList 为空时仅更新状态/备注")
@SysLog("修改预约规则")
@PutMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_edit')")
public R updateById(@RequestBody HardApptDto hardAppt) {
return R.ok(hardApptService.edit(hardAppt));
}
@Operation(summary = "通过id删除设备预约管理", description = "通过id删除设备预约管理")
@SysLog("通过id删除设备预约管理")
@Operation(summary = "修改单个开放窗口", description = "窗口级修改:时间/可自动退款时间/可预约明细全量重建")
@SysLog("修改预约开放窗口")
@PutMapping("/window")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_edit')")
public R updateWindow(@RequestBody HardApptWindowDto window) {
hardApptService.updateWindow(window);
return R.ok();
}
@Operation(summary = "删除单个开放窗口", description = "窗口级删除:级联删除明细;规则下无剩余窗口时连带删除规则")
@SysLog("删除预约开放窗口")
@DeleteMapping("/window/{id}")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_del')")
public R removeWindow(@PathVariable("id") Long id) {
hardApptService.deleteWindow(id);
return R.ok();
}
@Operation(summary = "通过id删除预约规则", description = "通过id删除预约规则")
@SysLog("通过id删除预约规则")
@DeleteMapping
@PreAuthorize("@pms.hasPermission('hard_hardAppt_del')")
public R removeById(@RequestBody Long[] ids) {
@@ -511,7 +511,6 @@ public class HardOrderController {
@RequestParam(required = false) Long apptId,
@RequestParam(required = false) Long apptRuleId,
@RequestParam(required = false) String hourType,
@RequestParam(required = false) String apptTimeType,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate apptDate,
@RequestParam HardTypeEnum hardType) {
PjcloudUser user = SecurityUtils.getUser();
@@ -529,7 +528,6 @@ public class HardOrderController {
.hourType(hourType)
.apptDate(apptDate)
.build();
legacyDto.setApptTimeType(apptTimeType);
return R.ok(hardwareOrderService.appCreateOrder(legacyDto));
}
HardAppCreateReserveOrdersDto batchDto = new HardAppCreateReserveOrdersDto();
@@ -538,7 +536,6 @@ public class HardOrderController {
batchDto.setApptId(apptId);
batchDto.setApptRuleId(apptRuleId);
batchDto.setHourType(hourType);
batchDto.setApptTimeType(apptTimeType);
batchDto.setApptDate(apptDate);
return R.ok(hardwareOrderService.appCreateReserveOrders(user.getId(), batchDto).getOrderIds().get(0));
}
@@ -4,8 +4,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.YyxService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.hard.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HardApptItemMapper extends PjcloudBaseMapper<HardApptItemEntity> {
}
@@ -1,16 +0,0 @@
package com.bsd.pjcloud.hard.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeTypeVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface HardApptTimeMapper extends PjcloudBaseMapper<HardApptTimeEntity> {
List<HardApptTimeTypeVo> getTypeListByMchId(@Param("mchId") Long mchId);
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.hard.mapper;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HardApptWindowMapper extends PjcloudBaseMapper<HardApptWindowEntity> {
}
@@ -1,8 +1,6 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -13,10 +11,6 @@ import java.util.List;
*/
public interface HardApptReservationService {
List<HardApptEntity> listLegacyFlatOptions(Long mchId);
List<HardApptTimeCascadeVo> listLegacyOptions(Long mchId);
List<HardApptDateOptionsVo> listOptions(Long mchId);
/**
@@ -29,19 +23,19 @@ public interface HardApptReservationService {
List<HardApptDateOptionsVo> previewOptions(Long mchId, Integer days);
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, String legacyApptTimeType);
LocalDate apptDate);
/**
* 服务端校验通过的预约上下文,周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。
* 服务端校验通过的预约上下文,窗口/周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。
*
* @param appt 动态预约配置;周期模式为空
* @param apptId 动态预约窗口 ID(订单 appt_id 固化该值);周期模式为空
* @param hourType 标准化餐段编码
* @param apptDate 实际用餐日期
* @param apptRuleId 周期预约规则 ID;动态模式为空
* @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
* @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用
*/
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
record ReservationValidation(Long apptId, String hourType, LocalDate apptDate, Long apptRuleId,
LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) {
}
}
@@ -5,6 +5,7 @@ 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.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptVo;
@@ -19,7 +20,7 @@ public interface HardApptService extends IService<HardApptEntity> {
Long create(HardApptDto hardAppt);
/**
* 修改
* 修改:windowList 为空时仅更新规则头字段(状态/备注)
*/
Long edit(HardApptDto hardAppt);
@@ -28,6 +29,16 @@ public interface HardApptService extends IService<HardApptEntity> {
*/
void delByIds(ArrayList<Long> list);
/**
* 窗口级修改:更新单个开放窗口(时间/免审退款截止/明细全量重建),商户级唯一性排除本窗口。
*/
void updateWindow(HardApptWindowDto window);
/**
* 窗口级删除:级联删除窗口明细;规则下无剩余窗口时连带删除规则。
*/
void deleteWindow(Long windowId);
/**
* 分页查询
*/
@@ -1,13 +0,0 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeTypeVo;
import java.util.List;
public interface HardApptTimeService extends IService<HardApptTimeEntity> {
List<HardApptTimeTypeVo> getTypeListByMchId(Long mchId);
}
@@ -4,25 +4,24 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.exception.I18nBizException;
import com.bsd.pjcloud.common.core.util.MsgUtils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.mapper.HardApptItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import com.bsd.pjcloud.hard.service.HardApptMerchantModeService;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -36,16 +35,15 @@ import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Collectors;
/**
* 预约列表和下单共用同一套规则,避免客户端展示状态与服务端最终校验不一致。
* 动态时间窗为多窗口模型:规则(hard_appt) → 开放窗口(hard_appt_window) → 窗口内日期×餐段明细(hard_appt_item)。
*/
@Service
@RequiredArgsConstructor
@@ -57,51 +55,11 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
private static final int MAX_PREVIEW_DAYS = 31;
private final HardApptService hardApptService;
private final HardApptTimeService hardApptTimeService;
private final HardApptWindowMapper hardApptWindowMapper;
private final HardApptItemMapper hardApptItemMapper;
private final HardApptCycleRuleService cycleRuleService;
private final HardApptMerchantModeService merchantModeService;
@Override
public List<HardApptEntity> listLegacyFlatOptions(Long mchId) {
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
if (merchantMode != HardApptReserveModeEnum.TIME_WINDOW) {
return List.of();
}
return findEnabledAppts(mchId, HardApptReserveModeEnum.TIME_WINDOW);
}
@Override
public List<HardApptTimeCascadeVo> listLegacyOptions(Long mchId) {
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
if (merchantMode != HardApptReserveModeEnum.TIME_WINDOW) {
return List.of();
}
List<HardApptEntity> dynamicAppts = findEnabledAppts(mchId, merchantMode);
if (dynamicAppts.isEmpty()) {
return List.of();
}
Map<String, Set<Long>> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts);
LocalDate today = nowInShanghai().toLocalDate();
List<HardApptTimeCascadeVo> result = new ArrayList<>();
for (int day = 0; day <= HardApptTimeTypeEnum.AFTER_TOMORROW.getDay(); day++) {
HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay(day);
Set<Long> mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of());
List<HardApptEntity> options = dynamicAppts.stream()
.filter(appt -> mappedIds.contains(appt.getId()))
.toList();
if (options.isEmpty()) {
continue;
}
HardApptTimeCascadeVo dateOptions = new HardApptTimeCascadeVo();
dateOptions.setApptTimeType(dateType.getType());
dateOptions.setApptTimeWeekStr(today.plusDays(day));
dateOptions.setApptList(options);
result.add(dateOptions);
}
return result;
}
@Override
public List<HardApptDateOptionsVo> listOptions(Long mchId) {
return listOptions(mchId, nowInShanghai());
@@ -110,22 +68,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
List<HardApptDateOptionsVo> listOptions(Long mchId, LocalDateTime now) {
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
LocalDate today = now.toLocalDate();
Map<LocalDate, List<HardApptOptionVo>> optionsByDate = new TreeMap<>();
Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> optionsByDate = new TreeMap<>();
if (merchantMode == HardApptReserveModeEnum.TIME_WINDOW) {
List<HardApptEntity> dynamicAppts = findEnabledAppts(mchId, merchantMode);
Map<String, Set<Long>> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts);
for (HardApptTimeTypeEnum dateType : HardApptTimeTypeEnum.values()) {
if (!HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) {
continue;
}
Set<Long> mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of());
LocalDate targetDate = today.plusDays(dateType.getDay());
dynamicAppts.stream()
.filter(appt -> mappedIds.contains(appt.getId()))
.map(appt -> buildOption(appt, evaluateAvailability(appt, targetDate, now)))
.forEach(option -> optionsByDate.computeIfAbsent(targetDate, key -> new ArrayList<>()).add(option));
}
addDynamicOptions(mchId, now, today, null, optionsByDate);
} else {
for (HardApptCycleRuleVo rule : cycleRuleService.listEnabledConfigurations(mchId)) {
HardApptCycleRuleCalculator.CycleBatch batch;
@@ -147,7 +93,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
LocalDate targetDate = batch.targetStartDate().plusDays(item.getTargetDayOffset());
HardApptOptionVo option = buildCycleOption(hourType, availability);
option.setApptRuleId(rule.getId());
optionsByDate.computeIfAbsent(targetDate, key -> new ArrayList<>()).add(option);
optionsByDate.computeIfAbsent(targetDate, key -> new LinkedHashMap<>())
.put("cycle:" + rule.getId() + ":" + hourType.getType(), option);
}
}
}
@@ -155,10 +102,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
return optionsByDate.entrySet().stream().map(entry -> {
HardApptDateOptionsVo dateOptions = new HardApptDateOptionsVo();
dateOptions.setApptDate(entry.getKey());
long offset = ChronoUnit.DAYS.between(today, entry.getKey());
HardApptTimeTypeEnum legacyType = HardApptTimeTypeEnum.valueOfByDay((int) offset);
dateOptions.setApptTimeType(legacyType == null ? null : legacyType.getType());
dateOptions.setApptList(entry.getValue());
dateOptions.setApptList(new ArrayList<>(entry.getValue().values()));
return dateOptions;
}).toList();
}
@@ -186,7 +130,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
}
if (merchantMode == HardApptReserveModeEnum.TIME_WINDOW) {
addDynamicPreviewOptions(mchId, now, startDate, endDate, optionsByDate);
addDynamicOptions(mchId, now, startDate, endDate, optionsByDate);
} else {
addCyclePreviewOptions(mchId, now, startDate, endDate, optionsByDate);
}
@@ -199,25 +143,24 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
}
/**
* 动态模式仍只按已有今天、明天、后天映射展示,不把相对日期规则错误扩展到整个推演范围。
* 动态模式按"窗口×明细"展开:仅输出 startDate 及以后的目标日期,endDate 非空时限制在推演范围内。
* 选项按 "窗口ID:餐段" 去重,正常数据由保存时的商户级唯一性保证不重复,这里兜底脏数据。
*/
private void addDynamicPreviewOptions(Long mchId, LocalDateTime now, LocalDate startDate, LocalDate endDate,
Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> optionsByDate) {
List<HardApptEntity> dynamicAppts = findEnabledAppts(mchId, HardApptReserveModeEnum.TIME_WINDOW);
Map<String, Set<Long>> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts);
for (HardApptTimeTypeEnum dateType : HardApptTimeTypeEnum.values()) {
if (!HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) {
continue;
private void addDynamicOptions(Long mchId, LocalDateTime now, LocalDate startDate, LocalDate endDate,
Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> optionsByDate) {
for (EnabledWindow enabled : findEnabledWindows(mchId)) {
Availability availability = windowAvailability(enabled.window(), now);
for (HardApptItemEntity item : enabled.items()) {
if (item.getTargetDate() == null || item.getTargetDate().isBefore(startDate)) {
continue;
}
if (endDate != null && item.getTargetDate().isAfter(endDate)) {
continue;
}
HardApptOptionVo option = buildDynamicOption(enabled.window(), item, availability);
optionsByDate.computeIfAbsent(item.getTargetDate(), key -> new LinkedHashMap<>())
.put("appt:" + enabled.window().getId() + ":" + item.getHourType(), option);
}
LocalDate targetDate = startDate.plusDays(dateType.getDay());
if (targetDate.isAfter(endDate)) {
continue;
}
Set<Long> mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of());
dynamicAppts.stream()
.filter(appt -> mappedIds.contains(appt.getId()))
.forEach(appt -> optionsByDate.get(targetDate).put("appt:" + appt.getId(),
buildOption(appt, evaluateAvailability(appt, targetDate, now))));
}
}
@@ -306,12 +249,12 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
@Override
public ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, String legacyApptTimeType) {
return validateReservation(mchId, apptId, apptRuleId, hourType, apptDate, legacyApptTimeType, nowInShanghai());
LocalDate apptDate) {
return validateReservation(mchId, apptId, apptRuleId, hourType, apptDate, nowInShanghai());
}
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate,
String legacyApptTimeType, LocalDateTime now) {
ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType,
LocalDate apptDate, LocalDateTime now) {
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
if (merchantMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
if (apptRuleId == null) {
@@ -325,43 +268,45 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (ObjectUtil.isEmpty(apptId)) {
throw new I18nBizException(ErrorCodes.HARD_APPT_DYNAMIC_SLOT_EMPTY);
}
if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) {
throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_MEAL_REQUIRED);
}
HardApptEntity appt = hardApptService.getById(apptId);
if (appt == null) {
HardApptWindowEntity window = hardApptWindowMapper.selectById(apptId);
if (window == null) {
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_MISSING);
}
if (ObjectUtil.notEqual(mchId, appt.getMchId())) {
if (ObjectUtil.notEqual(mchId, window.getMchId())) {
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_MCH_MISMATCH);
}
if (ObjectUtil.notEqual(YesNoEnum.YES.getCode(), appt.getStatus())) {
HardApptEntity rule = hardApptService.getById(window.getApptId());
if (rule == null) {
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_MISSING);
}
if (ObjectUtil.notEqual(YesNoEnum.YES.getCode(), rule.getStatus())) {
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_DISABLED);
}
HardApptReserveModeEnum reserveMode = resolveMode(appt);
if (reserveMode != HardApptReserveModeEnum.TIME_WINDOW) {
throw new I18nBizException(ErrorCodes.HARD_APPT_SLOT_MODE_MISMATCH);
}
LocalDate targetDate = resolveLegacyApptDate(apptDate, legacyApptTimeType, now.toLocalDate());
long dayOffset = ChronoUnit.DAYS.between(now.toLocalDate(), targetDate);
HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay((int) dayOffset);
if (dateType == null || !HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) {
throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_OUT_RANGE);
}
long mappingCount = hardApptTimeService.count(new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getMchId, mchId)
.eq(HardApptTimeEntity::getApptId, apptId)
.eq(HardApptTimeEntity::getType, dateType.getType()));
if (mappingCount == 0) {
Long itemCount = hardApptItemMapper.selectCount(new LambdaQueryWrapper<HardApptItemEntity>()
.eq(HardApptItemEntity::getWindowId, apptId)
.eq(HardApptItemEntity::getTargetDate, apptDate)
.eq(HardApptItemEntity::getHourType, hourType));
if (itemCount == null || itemCount == 0) {
throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_NO_SLOT);
}
Availability availability = evaluateAvailability(appt, targetDate, now);
Availability availability = windowAvailability(window, now);
if (!availability.available()) {
throwUnavailable(availability);
}
return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(),
resolveUsageEndDateTime(targetDate));
return new ReservationValidation(window.getId(), hourType, apptDate, null, resolveAutoRefundDeadline(window),
resolveUsageEndDateTime(apptDate));
}
/**
* 免审退款申请截止:窗口持久化的可自动退款时间优先,为空回退开放预约结束时间(与周期模式批次截止同源)。
*/
private LocalDateTime resolveAutoRefundDeadline(HardApptWindowEntity window) {
return window.getAutoRefundTime() != null ? window.getAutoRefundTime() : window.getEndTime();
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) {
@@ -416,8 +361,73 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
return new CycleMatch(rule, cycleAvailability(rule, batch));
}
private HardApptOptionVo buildOption(HardApptEntity appt, Availability availability) {
HardApptOptionVo option = BeanUtil.toBean(appt, HardApptOptionVo.class);
/**
* 商户全部启用规则的开放窗口及其明细(窗口按开始时间升序,明细按日期+餐段排序)。
*/
private List<EnabledWindow> findEnabledWindows(Long mchId) {
List<Long> ruleIds = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream()
.map(HardApptEntity::getId).toList();
if (ruleIds.isEmpty()) {
return List.of();
}
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(
new LambdaQueryWrapper<HardApptWindowEntity>()
.in(HardApptWindowEntity::getApptId, ruleIds)
.orderByAsc(HardApptWindowEntity::getStartTime));
if (windows.isEmpty()) {
return List.of();
}
Map<Long, List<HardApptItemEntity>> itemsByWindow = hardApptItemMapper.selectList(
new LambdaQueryWrapper<HardApptItemEntity>()
.in(HardApptItemEntity::getWindowId,
windows.stream().map(HardApptWindowEntity::getId).toList())).stream()
.collect(Collectors.groupingBy(HardApptItemEntity::getWindowId));
return windows.stream()
.map(window -> new EnabledWindow(window,
itemsByWindow.getOrDefault(window.getId(), List.of()).stream()
.filter(item -> item.getTargetDate() != null && item.getHourType() != null)
.sorted((left, right) -> {
int byDate = left.getTargetDate().compareTo(right.getTargetDate());
return byDate != 0 ? byDate : left.getHourType().compareTo(right.getHourType());
})
.toList()))
.toList();
}
/**
* 窗口开放性判定:闭区间 [start, end],开始和结束准点均可提交。
*/
static Availability windowAvailability(HardApptWindowEntity window, LocalDateTime now) {
if (window == null || window.getStartTime() == null || window.getEndTime() == null
|| !window.getEndTime().isAfter(window.getStartTime())) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_RULE_INCOMPLETE);
}
if (now.isBefore(window.getStartTime())) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_NOT_OPEN, window.getStartTime(), window.getEndTime());
}
if (now.isAfter(window.getEndTime())) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_CLOSED, window.getStartTime(), window.getEndTime());
}
return Availability.available(window.getStartTime(), window.getEndTime());
}
/**
* 动态选项以窗口 ID 作为 id(App 提交订单 appt_id 即该值),窗口起止时间原样透出供客户端展示。
*/
private static HardApptOptionVo buildDynamicOption(HardApptWindowEntity window, HardApptItemEntity item,
Availability availability) {
HardApptOptionVo option = new HardApptOptionVo();
option.setId(window.getId());
option.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
HardHourTypeEnum hourType = HardHourTypeEnum.valueOfByType(item.getHourType());
String hourTypeName = hourType != null ? hourType.getDescription() : item.getHourType();
option.setHourType(item.getHourType());
option.setHourTypeName(hourTypeName);
option.setTitle(hourTypeName);
option.setStartTime(window.getStartTime());
option.setEndTime(window.getEndTime());
option.setAvailable(availability.available());
option.setUnavailableReason(availability.resolvedUnavailableReason());
option.setReserveStartDateTime(availability.startDateTime());
@@ -438,47 +448,6 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
return option;
}
static Availability evaluateAvailability(HardApptEntity appt, LocalDate apptDate, LocalDateTime now) {
HardApptReserveModeEnum reserveMode = HardApptReserveModeEnum.valueOfByType(appt.getReserveMode());
if (reserveMode == null) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_RULE_MODE_INVALID);
}
if (reserveMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_RULE_INCOMPLETE);
}
HardApptTimeTypeEnum startType = HardApptTimeTypeEnum.valueOfByType(appt.getStartTimeType());
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
if (startType == null || endType == null || appt.getStartTime() == null || appt.getEndTime() == null) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_RULE_INCOMPLETE);
}
LocalDateTime startDateTime = apptDate.plusDays(startType.getDay()).atTime(appt.getStartTime());
LocalDateTime endDateTime = apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
if (!endDateTime.isAfter(startDateTime)) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_RULE_TIME_INVALID);
}
if (now.isBefore(startDateTime)) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_NOT_OPEN, startDateTime, endDateTime);
}
if (now.isAfter(endDateTime)) {
return Availability.unavailableI18n(ErrorCodes.HARD_APPT_CLOSED, startDateTime, endDateTime);
}
// 业务要求开始和结束准点均可提交,因此使用闭区间 [start, end]。
return Availability.available(startDateTime, endDateTime);
}
private static LocalDate resolveLegacyApptDate(LocalDate apptDate, String legacyApptTimeType, LocalDate today) {
if (apptDate != null) {
return apptDate;
}
HardApptTimeTypeEnum legacyType = HardApptTimeTypeEnum.valueOfByTypeOrToday(legacyApptTimeType);
if (legacyType == null || !HardApptTimeTypeEnum.isReservationDateType(legacyType.getType())) {
throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_TYPE_INVALID);
}
return today.plusDays(legacyType.getDay());
}
private static Availability cycleAvailability(HardApptCycleRuleVo rule,
HardApptCycleRuleCalculator.CycleBatch batch) {
LocalDateTime start = batch.window().startDateTime();
@@ -490,50 +459,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
weekdayLabel(rule.getOpenWeekday()), rule.getOpenTime().format(TIME_FORMATTER));
}
private List<HardApptEntity> findEnabledAppts(Long mchId, HardApptReserveModeEnum reserveMode) {
return hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.eq(HardApptEntity::getReserveMode, reserveMode.getType())
.orderByAsc(HardApptEntity::getHourType)
.orderByDesc(HardApptEntity::getSort));
}
private HardApptReserveModeEnum resolveMerchantMode(Long mchId) {
return merchantModeService.resolve(mchId).reserveMode();
}
private Map<String, Set<Long>> groupMappingsByDateType(Long mchId, List<HardApptEntity> appts) {
if (apptListIsEmpty(appts)) {
return Map.of();
}
Set<Long> apptIds = appts.stream().map(HardApptEntity::getId)
.filter(ObjectUtil::isNotNull).collect(Collectors.toSet());
if (apptIds.isEmpty()) {
return Map.of();
}
List<HardApptTimeEntity> mappings = hardApptTimeService.list(
new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getMchId, mchId)
.in(HardApptTimeEntity::getApptId, apptIds));
return mappings.stream()
.filter(mapping -> ObjectUtil.isAllNotEmpty(mapping.getType(), mapping.getApptId()))
.filter(mapping -> HardApptTimeTypeEnum.isReservationDateType(mapping.getType()))
.collect(Collectors.groupingBy(
HardApptTimeEntity::getType,
LinkedHashMap::new,
Collectors.mapping(HardApptTimeEntity::getApptId, Collectors.toCollection(HashSet::new))
));
}
private static boolean apptListIsEmpty(List<HardApptEntity> appts) {
return appts == null || appts.isEmpty();
}
private static HardApptReserveModeEnum resolveMode(HardApptEntity appt) {
return HardApptReserveModeEnum.valueOfByType(appt.getReserveMode());
}
private static LocalDateTime nowInShanghai() {
return ZonedDateTime.now(RESERVATION_ZONE).toLocalDateTime();
}
@@ -594,4 +523,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
record CycleMatch(HardApptCycleRuleVo rule, Availability availability) {
}
record EnabledWindow(HardApptWindowEntity window, List<HardApptItemEntity> items) {
}
}
@@ -5,15 +5,22 @@ import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.dto.HardApptItemDto;
import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptVo;
import com.bsd.pjcloud.hard.api.vo.HardApptWindowVo;
import com.bsd.pjcloud.hard.mapper.HardApptItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptMapper;
import com.bsd.pjcloud.hard.mapper.HardApptTimeMapper;
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
import com.bsd.pjcloud.hard.service.HardApptMerchantModeService;
import com.bsd.pjcloud.hard.service.HardApptService;
import lombok.RequiredArgsConstructor;
@@ -22,12 +29,18 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 设备预约管理
* 动态时间窗预约规则管理(多窗口模型)。
*/
@Validated
@Slf4j
@@ -35,110 +48,143 @@ import java.util.List;
@Service
public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEntity> implements HardApptService {
private final HardApptTimeMapper hardApptTimeMapper;
private final HardApptWindowMapper hardApptWindowMapper;
private final HardApptItemMapper hardApptItemMapper;
private final HardApptMerchantModeService merchantModeService;
/**
* 新增
* 新增:一条规则携带多个开放窗口,逐窗口校验并做商户级唯一性检查。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(HardApptDto dto) {
HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(dto.getMchId());
if (ObjectUtil.isNotEmpty(dto.getReserveMode())
&& HardApptReserveModeEnum.valueOfByType(dto.getReserveMode()) != HardApptReserveModeEnum.TIME_WINDOW) {
throw new RuntimeException("动态餐段只能使用动态时间窗模式");
}
dto.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
normalizeAndValidateRule(dto, HardApptReserveModeEnum.TIME_WINDOW.getType());
// title不能重复
if (existTitle(dto.getTitle(), dto.getMchId(), null)) {
throw new RuntimeException("标题不能重复");
}
List<HardApptWindowDto> windows = validateAndNormalizeWindows(dto, null);
dto.setStatus(ObjectUtil.defaultIfEmpty(dto.getStatus(), YesNoEnum.YES.getCode()));
dto.setDeptId(merchantContext.merchant().getDeptId());
baseMapper.insert(dto);
createApptTime(dto, dto.getReserveTimeList());
saveWindows(dto.getId(), dto.getMchId(), windows);
return dto.getId();
}
/**
* 修改
* 修改:商户不可更换;windowList 非空时窗口与明细全量重建,为空时仅更新规则头字段(状态/备注)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long edit(HardApptDto dto) {
HardApptEntity existing = baseMapper.selectById(dto.getId());
if (existing == null) {
throw new RuntimeException("时间预约不存在");
throw new RuntimeException("预约规则不存在");
}
if (ObjectUtil.notEqual(existing.getMchId(), dto.getMchId())) {
throw new RuntimeException("预约餐段不允许更换商户");
if (dto.getMchId() != null && ObjectUtil.notEqual(existing.getMchId(), dto.getMchId())) {
throw new RuntimeException("预约规则不允许更换商户");
}
if (HardApptReserveModeEnum.valueOfByType(existing.getReserveMode()) != HardApptReserveModeEnum.TIME_WINDOW) {
throw new RuntimeException("周期预约餐段由周期预约规则维护");
List<HardApptWindowDto> windows = null;
if (ObjectUtil.isNotEmpty(dto.getWindowList())) {
HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(existing.getMchId());
windows = validateAndNormalizeWindows(dto, dto.getId());
dto.setDeptId(merchantContext.merchant().getDeptId());
}
HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(dto.getMchId());
normalizeAndValidateRule(dto, HardApptReserveModeEnum.TIME_WINDOW.getType());
// title不能重复
if (existTitle(dto.getTitle(), dto.getMchId(), dto.getId())) {
throw new RuntimeException("标题不能重复");
}
dto.setDeptId(merchantContext.merchant().getDeptId());
baseMapper.updateById(dto);
createApptTime(dto, dto.getReserveTimeList());
if (windows != null) {
deleteWindows(dto.getId());
saveWindows(dto.getId(), existing.getMchId(), windows);
}
return dto.getId();
}
/**
* 删除
* 删除:级联删除窗口与窗口内可预约明细。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void delByIds(ArrayList<Long> list) {
if (ObjectUtil.isEmpty(list)) return;
hardApptTimeMapper.delete(new LambdaQueryWrapper<HardApptTimeEntity>()
.in(HardApptTimeEntity::getApptId, list));
for (Long id : list) {
deleteWindows(id);
}
baseMapper.deleteBatchIds(list);
}
/**
* 窗口级修改:时间/免审退款截止/明细全量重建,商户级唯一性排除本窗口(含同规则兄弟窗口参与比对)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateWindow(HardApptWindowDto window) {
if (window == null || window.getId() == null) {
throw new RuntimeException("缺少预约窗口ID");
}
HardApptWindowEntity existing = hardApptWindowMapper.selectById(window.getId());
if (existing == null) {
throw new RuntimeException("预约窗口不存在");
}
HardApptEntity rule = baseMapper.selectById(existing.getApptId());
if (rule == null) {
throw new RuntimeException("预约规则不存在");
}
requireTimeWindowMerchant(rule.getMchId());
normalizeWindow(window, 1);
checkMerchantUniqueness(rule.getMchId(), List.of(window), null, window.getId());
HardApptWindowEntity update = new HardApptWindowEntity();
update.setId(window.getId());
update.setStartTime(window.getStartTime());
update.setEndTime(window.getEndTime());
update.setAutoRefundTime(window.getAutoRefundTime());
hardApptWindowMapper.updateById(update);
hardApptItemMapper.delete(new LambdaQueryWrapper<HardApptItemEntity>()
.eq(HardApptItemEntity::getWindowId, window.getId()));
hardApptItemMapper.insert(window.getItemList().stream().map(item -> {
HardApptItemEntity entity = new HardApptItemEntity();
entity.setWindowId(window.getId());
entity.setMchId(existing.getMchId());
entity.setTargetDate(item.getTargetDate());
entity.setHourType(item.getHourType());
return entity;
}).toList());
}
/**
* 窗口级删除:级联删除明细;规则下无剩余窗口时连带删除规则(空规则无业务意义)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteWindow(Long windowId) {
if (windowId == null) return;
HardApptWindowEntity existing = hardApptWindowMapper.selectById(windowId);
if (existing == null) return;
hardApptItemMapper.delete(new LambdaQueryWrapper<HardApptItemEntity>()
.eq(HardApptItemEntity::getWindowId, windowId));
hardApptWindowMapper.deleteById(windowId);
Long remaining = hardApptWindowMapper.selectCount(new LambdaQueryWrapper<HardApptWindowEntity>()
.eq(HardApptWindowEntity::getApptId, existing.getApptId()));
if (remaining != null && remaining == 0) {
baseMapper.deleteById(existing.getApptId());
}
}
/**
* 分页查询
*/
@Override
public Page<HardApptVo> scopePage(Page page, HardApptDto dto, DataScope of) {
LambdaQueryWrapper<HardApptEntity> wrapper = new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId())
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardApptEntity::getDeptId, dto.getDeptId())
.eq(ObjectUtil.isNotEmpty(dto.getReserveMode()), HardApptEntity::getReserveMode, dto.getReserveMode())
.like(ObjectUtil.isNotEmpty(dto.getTitle()), HardApptEntity::getTitle, dto.getTitle())
.orderByAsc(HardApptEntity::getHourType);
LambdaQueryWrapper<HardApptEntity> wrapper = buildQueryWrapper(dto);
Page pageList = baseMapper.selectPageByScope(page, wrapper, of);
List<HardApptVo> hardApptVos = BeanUtil.copyToList(pageList.getRecords(), HardApptVo.class);
pageList.setRecords(hardApptVos);
if (ObjectUtil.isNotEmpty(pageList)) {
disposePageList(pageList.getRecords());
}
disposeWindowList(hardApptVos);
return pageList;
}
@Override
public List<HardApptVo> scopeList(HardApptDto dto, DataScope of) {
LambdaQueryWrapper<HardApptEntity> wrapper = new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId())
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardApptEntity::getDeptId, dto.getDeptId())
.eq(ObjectUtil.isNotEmpty(dto.getReserveMode()), HardApptEntity::getReserveMode, dto.getReserveMode())
.like(ObjectUtil.isNotEmpty(dto.getTitle()), HardApptEntity::getTitle, dto.getTitle())
.orderByAsc(HardApptEntity::getHourType);
List<HardApptEntity> list = baseMapper.selectListByScope(wrapper, of);
List<HardApptEntity> list = baseMapper.selectListByScope(buildQueryWrapper(dto), of);
List<HardApptVo> resultList = BeanUtil.copyToList(list, HardApptVo.class);
if (ObjectUtil.isNotEmpty(resultList)) {
disposePageList(resultList);
}
disposeWindowList(resultList);
return resultList;
}
@@ -149,87 +195,240 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
public HardApptVo join(Long id) {
HardApptEntity hardAppt = baseMapper.selectById(id);
if (ObjectUtil.isEmpty(hardAppt)) {
throw new RuntimeException("时间预约不存在");
throw new RuntimeException("预约规则不存在");
}
List<HardApptVo> resultList = List.of(BeanUtil.toBean(hardAppt, HardApptVo.class));
disposePageList(resultList);
disposeWindowList(resultList);
return resultList.get(0);
}
// 创建预约时间
private void createApptTime(HardApptEntity apptEntity, List<String> reserveTimeList) {
if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId())) {
throw new RuntimeException("预约餐段信息不完整");
}
hardApptTimeMapper.delete(new LambdaQueryWrapper<HardApptTimeEntity>()
.eq(HardApptTimeEntity::getApptId, apptEntity.getId())
);
if (ObjectUtil.isEmpty(reserveTimeList)) {
return;
}
ArrayList<HardApptTimeEntity> addList = new ArrayList<>();
reserveTimeList.forEach(v -> {
HardApptTimeEntity entity = new HardApptTimeEntity();
entity.setMchId(apptEntity.getMchId());
entity.setApptId(apptEntity.getId());
entity.setType(v);
addList.add(entity);
});
hardApptTimeMapper.insert(addList);
private LambdaQueryWrapper<HardApptEntity> buildQueryWrapper(HardApptDto dto) {
return new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId())
.eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardApptEntity::getDeptId, dto.getDeptId())
.orderByDesc(HardApptEntity::getId);
}
/**
* 服务端负责规范化规则,避免绕过后台页面提交无效模式、非法日期或倒置时间窗。
* 服务端负责规范化窗口,避免绕过后台页面提交倒置时间窗、非法餐段或重复目标。
* 商户级唯一性:与该商户其他启用规则的所有窗口比较,开放时间不得重叠、可预约日期不得重复。
*
* @param dto 规则入参
* @param selfRuleId 编辑时的当前规则 ID,唯一性检查排除自身;新增传 null
*/
private void normalizeAndValidateRule(HardApptDto dto, String fallbackMode) {
String modeValue = ObjectUtil.defaultIfEmpty(dto.getReserveMode(),
ObjectUtil.defaultIfEmpty(fallbackMode, HardApptReserveModeEnum.TIME_WINDOW.getType()));
HardApptReserveModeEnum reserveMode = HardApptReserveModeEnum.valueOfByType(modeValue);
if (reserveMode == null) {
throw new RuntimeException("预约限制模式无效");
private List<HardApptWindowDto> validateAndNormalizeWindows(HardApptDto dto, Long selfRuleId) {
List<HardApptWindowDto> windowList = dto.getWindowList();
if (ObjectUtil.isEmpty(windowList)) {
throw new RuntimeException("至少需要配置一个预约窗口");
}
dto.setReserveMode(reserveMode.getType());
for (int i = 0; i < windowList.size(); i++) {
normalizeWindow(windowList.get(i), i + 1);
}
// 规则内:窗口之间开放时间不重叠、可预约日期不重复
for (int i = 0; i < windowList.size(); i++) {
for (int j = i + 1; j < windowList.size(); j++) {
HardApptWindowDto left = windowList.get(i);
HardApptWindowDto right = windowList.get(j);
if (left.getStartTime().isBefore(right.getEndTime()) && right.getStartTime().isBefore(left.getEndTime())) {
throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 开放时间重叠");
}
LocalDate duplicatedDate = findFirstDuplicatedDate(left, right);
if (duplicatedDate != null) {
throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 可预约日期重复:" + duplicatedDate);
}
}
}
checkMerchantUniqueness(dto.getMchId(), windowList, selfRuleId, null);
return windowList;
}
if (reserveMode == HardApptReserveModeEnum.WEEKLY_CYCLE) {
// 周模式由独立周期规则配置,不保留旧的相对日期映射,避免切换模式后误命中脏数据。
dto.setReserveTimeList(List.of());
dto.setStartTimeType(null);
dto.setStartTime(null);
dto.setEndTimeType(null);
dto.setEndTime(null);
/**
* 单窗口归一化:时间合法性、免审退款截止默认与范围、餐段合法、明细去重。
*
* @param ordinal 窗口序号(从 1 起),仅用于错误提示
*/
private void normalizeWindow(HardApptWindowDto window, int ordinal) {
if (window.getStartTime() == null || window.getEndTime() == null) {
throw new RuntimeException("窗口 " + ordinal + " 缺少开始/结束预约时间");
}
if (!window.getEndTime().isAfter(window.getStartTime())) {
throw new RuntimeException("窗口 " + ordinal + " 结束时间必须晚于开始时间");
}
if (window.getAutoRefundTime() == null) {
window.setAutoRefundTime(window.getEndTime());
} else if (!window.getAutoRefundTime().isAfter(window.getStartTime())
|| window.getAutoRefundTime().isAfter(window.getEndTime())) {
throw new RuntimeException("窗口 " + ordinal + " 可自动退款时间须在开放预约时间范围内");
}
if (ObjectUtil.isEmpty(window.getItemList())) {
throw new RuntimeException("窗口 " + ordinal + " 至少需要配置一个可预约日期餐段");
}
LinkedHashMap<String, HardApptItemDto> uniqueItems = new LinkedHashMap<>();
for (HardApptItemDto item : window.getItemList()) {
if (item == null || item.getTargetDate() == null) {
throw new RuntimeException("窗口 " + ordinal + " 存在缺少目标日期的可预约项");
}
if (HardHourTypeEnum.valueOfByType(item.getHourType()) == null) {
throw new RuntimeException("窗口 " + ordinal + " 存在无效餐段:" + item.getHourType());
}
uniqueItems.putIfAbsent(item.getTargetDate() + "|" + item.getHourType(), item);
}
if (uniqueItems.isEmpty()) {
throw new RuntimeException("窗口 " + ordinal + " 可预约项不能为空");
}
window.setItemList(List.copyOf(uniqueItems.values()));
}
/**
* 商户级唯一性:与该商户启用规则的存量窗口比较,开放时间不得重叠、可预约日期不得重复。
*
* @param selfRuleId 规则级编辑排除的规则 ID;null 表示不排除任何规则(窗口级编辑,兄弟窗口参与比对)
* @param selfWindowId 窗口级编辑排除的窗口 ID;null 表示不排除任何窗口
*/
private void checkMerchantUniqueness(Long mchId, List<HardApptWindowDto> windowList, Long selfRuleId, Long selfWindowId) {
List<HardApptWindowEntity> existingWindows = findMerchantWindows(mchId, selfRuleId, selfWindowId);
if (existingWindows.isEmpty()) {
return;
}
if (ObjectUtil.isEmpty(dto.getReserveTimeList())) {
throw new RuntimeException("可预约日期不能为空");
Map<Long, Set<LocalDate>> existingDates = loadWindowDates(existingWindows);
for (int i = 0; i < windowList.size(); i++) {
HardApptWindowDto window = windowList.get(i);
for (HardApptWindowEntity existing : existingWindows) {
if (window.getStartTime().isBefore(existing.getEndTime())
&& existing.getStartTime().isBefore(window.getEndTime())) {
throw new RuntimeException("窗口 " + (i + 1) + " 开放时间与商户已存在窗口重叠");
}
}
Set<LocalDate> windowDates = window.getItemList().stream()
.map(HardApptItemDto::getTargetDate).collect(Collectors.toSet());
for (Map.Entry<Long, Set<LocalDate>> entry : existingDates.entrySet()) {
LocalDate duplicatedDate = windowDates.stream()
.filter(entry.getValue()::contains).findFirst().orElse(null);
if (duplicatedDate != null) {
throw new RuntimeException("窗口 " + (i + 1) + " 可预约日期 " + duplicatedDate + " 已存在于商户其他窗口");
}
}
}
List<String> reserveTimeList = dto.getReserveTimeList().stream()
.filter(ObjectUtil::isNotEmpty)
.distinct()
}
private LocalDate findFirstDuplicatedDate(HardApptWindowDto left, HardApptWindowDto right) {
Set<LocalDate> rightDates = right.getItemList().stream()
.map(HardApptItemDto::getTargetDate).collect(Collectors.toSet());
for (HardApptItemDto item : left.getItemList()) {
if (rightDates.contains(item.getTargetDate())) {
return item.getTargetDate();
}
}
return null;
}
/**
* 查询商户启用规则的开放窗口(可按规则或窗口排除自身)。
*
* @param selfRuleId 规则级编辑排除的规则 ID;null 不排除
* @param selfWindowId 窗口级编辑排除的窗口 ID;null 不排除
*/
private List<HardApptWindowEntity> findMerchantWindows(Long mchId, Long selfRuleId, Long selfWindowId) {
LambdaQueryWrapper<HardApptEntity> ruleWrapper = new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.ne(selfRuleId != null, HardApptEntity::getId, selfRuleId);
List<Long> ruleIds = baseMapper.selectList(ruleWrapper).stream()
.map(HardApptEntity::getId).toList();
if (ruleIds.isEmpty()) {
return List.of();
}
return hardApptWindowMapper.selectList(new LambdaQueryWrapper<HardApptWindowEntity>()
.in(HardApptWindowEntity::getApptId, ruleIds))
.stream()
.filter(window -> !Objects.equals(window.getId(), selfWindowId))
.toList();
if (reserveTimeList.isEmpty()
|| reserveTimeList.stream().anyMatch(type -> !HardApptTimeTypeEnum.isReservationDateType(type))) {
throw new RuntimeException("可预约日期无效");
}
dto.setReserveTimeList(reserveTimeList);
}
if (!ObjectUtil.isAllNotEmpty(dto.getStartTimeType(), dto.getStartTime(), dto.getEndTimeType(), dto.getEndTime())) {
throw new RuntimeException("动态时间窗配置不完整");
private Map<Long, Set<LocalDate>> loadWindowDates(List<HardApptWindowEntity> windows) {
List<Long> windowIds = windows.stream().map(HardApptWindowEntity::getId).toList();
return hardApptItemMapper.selectList(new LambdaQueryWrapper<HardApptItemEntity>()
.in(HardApptItemEntity::getWindowId, windowIds)).stream()
.collect(Collectors.groupingBy(HardApptItemEntity::getWindowId,
Collectors.mapping(HardApptItemEntity::getTargetDate, Collectors.toSet())));
}
private void saveWindows(Long apptId, Long mchId, List<HardApptWindowDto> windows) {
for (HardApptWindowDto window : windows) {
HardApptWindowEntity windowEntity = new HardApptWindowEntity();
windowEntity.setApptId(apptId);
windowEntity.setMchId(mchId);
windowEntity.setStartTime(window.getStartTime());
windowEntity.setEndTime(window.getEndTime());
windowEntity.setAutoRefundTime(window.getAutoRefundTime());
hardApptWindowMapper.insert(windowEntity);
List<HardApptItemEntity> items = window.getItemList().stream().map(item -> {
HardApptItemEntity entity = new HardApptItemEntity();
entity.setWindowId(windowEntity.getId());
entity.setMchId(mchId);
entity.setTargetDate(item.getTargetDate());
entity.setHourType(item.getHourType());
return entity;
}).toList();
hardApptItemMapper.insert(items);
}
HardApptTimeTypeEnum startType = HardApptTimeTypeEnum.valueOfByType(dto.getStartTimeType());
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(dto.getEndTimeType());
if (startType == null || endType == null) {
throw new RuntimeException("动态时间窗日期类型无效");
}
private void deleteWindows(Long apptId) {
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(
new LambdaQueryWrapper<HardApptWindowEntity>().eq(HardApptWindowEntity::getApptId, apptId));
if (!windows.isEmpty()) {
hardApptItemMapper.delete(new LambdaQueryWrapper<HardApptItemEntity>()
.in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList()));
}
long startMinutes = startType.getDay() * 24L * 60L + dto.getStartTime().getHour() * 60L + dto.getStartTime().getMinute();
long endMinutes = endType.getDay() * 24L * 60L + dto.getEndTime().getHour() * 60L + dto.getEndTime().getMinute();
if (endMinutes <= startMinutes) {
throw new RuntimeException("结束时间必须大于开始时间");
hardApptWindowMapper.delete(new LambdaQueryWrapper<HardApptWindowEntity>()
.eq(HardApptWindowEntity::getApptId, apptId));
}
/**
* 为规则展示回填全部窗口及窗口内明细(窗口按开始时间升序,明细按日期+餐段排序)。
*/
private void disposeWindowList(List<HardApptVo> records) {
if (ObjectUtil.isEmpty(records)) return;
List<Long> ruleIds = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).toList();
if (ruleIds.isEmpty()) return;
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(
new LambdaQueryWrapper<HardApptWindowEntity>()
.in(HardApptWindowEntity::getApptId, ruleIds)
.orderByAsc(HardApptWindowEntity::getStartTime));
Map<Long, List<HardApptItemEntity>> itemsByWindow = windows.isEmpty() ? Map.of()
: hardApptItemMapper.selectList(new LambdaQueryWrapper<HardApptItemEntity>()
.in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList()))
.stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId));
for (HardApptVo record : records) {
record.setWindowList(windows.stream()
.filter(window -> Objects.equals(window.getApptId(), record.getId()))
.map(window -> {
HardApptWindowVo windowVo = new HardApptWindowVo();
windowVo.setId(window.getId());
windowVo.setStartTime(window.getStartTime());
windowVo.setEndTime(window.getEndTime());
windowVo.setAutoRefundTime(window.getAutoRefundTime());
windowVo.setItemList(itemsByWindow.getOrDefault(window.getId(), List.of()).stream()
.sorted((left, right) -> {
int byDate = left.getTargetDate().compareTo(right.getTargetDate());
return byDate != 0 ? byDate : left.getHourType().compareTo(right.getHourType());
})
.map(item -> {
HardApptItemVo itemVo = new HardApptItemVo();
itemVo.setTargetDate(item.getTargetDate());
itemVo.setHourType(item.getHourType());
return itemVo;
})
.collect(Collectors.toList()));
return windowVo;
})
.collect(Collectors.toList()));
}
}
/**
* 餐段时间窗只在商户当前选择动态模式时允许维护,切换模式不会删除原有数据。
* 餐段规则只在商户当前选择动态模式时允许维护,切换模式不会删除原有数据。
*/
private HardApptMerchantModeService.MerchantModeContext requireTimeWindowMerchant(Long mchId) {
HardApptMerchantModeService.MerchantModeContext context = merchantModeService.resolve(mchId);
@@ -239,36 +438,4 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
return context;
}
private boolean existTitle(String title, Long mchId, Long id) {
Long count = baseMapper.selectCount(new LambdaQueryWrapper<HardApptEntity>()
.eq(ObjectUtil.isNotEmpty(title), HardApptEntity::getTitle, title)
.eq(ObjectUtil.isNotEmpty(mchId), HardApptEntity::getMchId, mchId)
.ne(ObjectUtil.isNotEmpty(id), HardApptEntity::getId, id)
);
if (ObjectUtil.isNotEmpty(id)) {
return count >= 1;
}
return count > 0;
}
private void disposePageList(List<HardApptVo> records) {
if (ObjectUtil.isEmpty(records)) return;
List<Long> ids = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).distinct().toList();
if (ObjectUtil.isNotEmpty(ids)) {
List<HardApptTimeEntity> targetList = hardApptTimeMapper.selectList(new LambdaQueryWrapper<HardApptTimeEntity>().in(HardApptTimeEntity::getApptId, ids));
for (HardApptVo item : records) {
List<HardApptTimeEntity> apptTimeList = targetList.stream().filter(v -> ObjectUtil.equals(item.getId(), v.getApptId())).toList();
if (ObjectUtil.isNotEmpty(apptTimeList)) {
List<String> typeList = apptTimeList.stream()
.map(HardApptTimeEntity::getType)
.filter(HardApptTimeTypeEnum::isReservationDateType)
.distinct()
.sorted(Comparator.comparingInt(type -> HardApptTimeTypeEnum.valueOfByType(type).getDay()))
.toList();
item.setReserveTimeList(typeList);
}
}
}
}
}
@@ -1,29 +0,0 @@
package com.bsd.pjcloud.hard.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeTypeVo;
import com.bsd.pjcloud.hard.mapper.HardApptTimeMapper;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.List;
/**
* 设备预约时间管理
*/
@Validated
@Slf4j
@RequiredArgsConstructor
@Service
public class HardApptTimeServiceImpl extends ServiceImpl<HardApptTimeMapper, HardApptTimeEntity> implements HardApptTimeService {
@Override
public List<HardApptTimeTypeVo> getTypeListByMchId(Long mchId) {
return baseMapper.getTypeListByMchId(mchId);
}
}
@@ -78,7 +78,8 @@ import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.spu.DeductionSpuStockDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.api.entity.HardThirdMiOrderEntity;
import com.bsd.pjcloud.hard.api.entity.address.HardUserMapAddressEntity;
import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
@@ -98,6 +99,7 @@ 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;
import com.bsd.pjcloud.hard.util.MchJobTimeUtils;
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
@@ -215,6 +217,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final HardSpuService hardSpuService;
private final HardApptService hardApptService;
private final HardApptReservationService hardApptReservationService;
private final HardApptWindowMapper hardApptWindowMapper;
private final HardOrderAddressService hardOrderAddressService;
private final HardUserMapAddressService hardUserMapAddressService;
private final HardTakeOutApptService hardTakeOutApptService;
@@ -468,16 +471,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
hardAfterSaleCarService.fillRefundQuantityStats(detail.getParentCarId(), carVoList);
detail.setCarList(carVoList);
});
// 预约订单
// 预约订单(apptId 指向动态时间窗开放窗口)
CompletableFuture<Void> findApptFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
HardApptEntity apptDetail = hardApptService.getById(detail.getApptId());
if (ObjectUtil.isEmpty(apptDetail)) return;
detail.setApptTitle(apptDetail.getTitle());
detail.setApptHourType(apptDetail.getHourType());
detail.setApptReserveMode(apptDetail.getReserveMode());
detail.setApptStartTime(apptDetail.getStartTime());
detail.setApptEndTime(apptDetail.getEndTime());
HardApptWindowEntity apptWindow = hardApptWindowMapper.selectById(detail.getApptId());
if (ObjectUtil.isEmpty(apptWindow)) return;
detail.setApptReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
detail.setApptStartTime(apptWindow.getStartTime());
detail.setApptEndTime(apptWindow.getEndTime());
});
CompletableFuture.allOf(findUserFuture, findMchFuture, findCarFuture, findApptFuture).join();
}
@@ -1467,7 +1468,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
batchDto.setApptId(dto.getApptId());
batchDto.setApptRuleId(dto.getApptRuleId());
batchDto.setHourType(dto.getHourType());
batchDto.setApptTimeType(dto.getApptTimeType());
batchDto.setApptDate(dto.getApptDate());
return getSelf().appCreateReserveOrders(dto.getUserId(), batchDto).getOrderIds().get(0);
}
@@ -1583,7 +1583,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
throw new I18nBizException(ErrorCodes.COMMON_CART_NOT_FOUND);
}
validateReserveCartItems(sourceCarList);
HardApptReservationService.ReservationValidation reservation = hardApptReservationService.validateReservation(sourceParentCar.getMerchantId(), dto.getApptId(), dto.getApptRuleId(), dto.getHourType(), dto.getApptDate(), dto.getApptTimeType());
HardApptReservationService.ReservationValidation reservation = hardApptReservationService.validateReservation(sourceParentCar.getMerchantId(), dto.getApptId(), dto.getApptRuleId(), dto.getHourType(), dto.getApptDate());
MchManageVo merchant = Optional.ofNullable(remoteMchService.getById(sourceParentCar.getMerchantId())).map(R::getData).orElseThrow(() -> new I18nBizException(ErrorCodes.COMMON_MCH_NOT_FOUND));
if (!Objects.equals(merchant.getTypes(), MerchantTypeEnum.CANTEEN.getType())) {
throw new I18nBizException(ErrorCodes.HARD_CART_NOT_CANTEEN);
@@ -2094,7 +2094,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
validateReserveCartItems(carList);
HardApptReservationService.ReservationValidation reservation = hardApptReservationService
.validateReservation(parentCar.getMerchantId(), group.getApptId(), group.getApptRuleId(),
group.getHourType(), group.getApptDate(), group.getApptTimeType());
group.getHourType(), group.getApptDate());
String mealSlot = reservation.apptDate() + "::" + reservation.hourType();
if (!mealSlots.add(mealSlot)) {
throw new I18nBizException(ErrorCodes.HARD_DUP_DATE_MEAL);
@@ -2202,7 +2202,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
order.setAutoRefundConfigId(config.getId());
order.setAutoRefundRuleType(config.getRuleType());
if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE
&& reservation.apptRuleId() != null && reservation.eligibilityDeadline() != null) {
&& (reservation.apptRuleId() != null || reservation.apptId() != null)
&& reservation.eligibilityDeadline() != null) {
order.setAutoRefundDeadline(reservation.eligibilityDeadline());
}
}
@@ -2860,7 +2861,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.productDesc(subject)
.build();
order.setHourType(reservation.hourType());
order.setApptId(reservation.appt() == null ? null : reservation.appt().getId());
order.setApptId(reservation.apptId());
order.setApptRuleId(reservation.apptRuleId());
return order;
}
@@ -5868,20 +5869,18 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
hardAfterSaleCarService.fillRefundQuantityStats(carListsByParentId);
}
});
// 预约订单
// 预约订单(apptId 指向动态时间窗开放窗口)
CompletableFuture<Void> findApptListFuture = CompletableFuture.runAsync(() -> {
if (dto.getIsFindApptList()) {
List<Long> apptList = list.stream().map(HardOrderVo::getApptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isEmpty(apptList)) return;
List<HardApptEntity> apptOrderList = hardApptService.listByIds(apptList);
if (ObjectUtil.isEmpty(apptOrderList)) return;
Map<Long, HardApptEntity> apptMap = CollectionUtils.convertMap(apptOrderList, HardApptEntity::getId);
List<HardApptWindowEntity> apptWindowList = hardApptWindowMapper.selectBatchIds(apptList);
if (ObjectUtil.isEmpty(apptWindowList)) return;
Map<Long, HardApptWindowEntity> apptMap = CollectionUtils.convertMap(apptWindowList, HardApptWindowEntity::getId);
for (HardOrderVo v : list) {
HardApptEntity target = apptMap.get(v.getApptId());
HardApptWindowEntity target = apptMap.get(v.getApptId());
if (ObjectUtil.isEmpty(target)) continue;
v.setApptTitle(target.getTitle());
v.setApptHourType(target.getHourType());
v.setApptReserveMode(target.getReserveMode());
v.setApptReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
v.setApptStartTime(target.getStartTime());
v.setApptEndTime(target.getEndTime());
}
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.hard.mapper.HardApptTimeMapper">
<select id="getTypeListByMchId" resultType="com.bsd.pjcloud.hard.api.vo.HardApptTimeTypeVo">
select a.mch_id, a.type
from hard_appt_time a
where a.del_flag = '0'
and a.mch_id = #{mchId}
group by a.type
order by a.type
</select>
</mapper>
@@ -1,18 +1,21 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.HardApptTimeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptTimeEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleItemVo;
import com.bsd.pjcloud.hard.api.vo.HardApptCycleRuleVo;
import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo;
import com.bsd.pjcloud.hard.api.vo.HardApptTimeCascadeVo;
import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo;
import com.bsd.pjcloud.hard.mapper.HardApptItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
import com.bsd.pjcloud.hard.service.HardApptCycleRuleService;
import com.bsd.pjcloud.hard.service.HardApptMerchantModeService;
import com.bsd.pjcloud.hard.service.HardApptService;
import com.bsd.pjcloud.hard.service.HardApptTimeService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.exception.I18nBizException;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -27,80 +30,164 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 多窗口动态时间窗模型 + 周期预约的预约校验用例。
*/
class HardApptReservationServiceImplTest {
private static final LocalDate APPT_DATE = LocalDate.of(2026, 7, 21);
@Test
void timeWindowIncludesStartAndEndInstants() {
HardApptEntity appt = timeWindowAppt();
/* ************************************* 动态时间窗(多窗口) ************************************* */
assertTrue(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 20, 17, 0)).available());
assertTrue(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0)).available());
/** 窗口为绝对时间闭区间 [start, end],起止准点均可提交。 */
@Test
void windowIncludesStartAndEndInstants() {
HardApptWindowEntity window = crossDayWindow();
assertTrue(HardApptReservationServiceImpl.windowAvailability(
window, LocalDateTime.of(2026, 7, 20, 17, 0)).available());
assertTrue(HardApptReservationServiceImpl.windowAvailability(
window, LocalDateTime.of(2026, 7, 21, 10, 0)).available());
}
@Test
void timeWindowRejectsInstantsOutsideClosedInterval() {
HardApptEntity appt = timeWindowAppt();
void windowRejectsInstantsOutsideClosedInterval() {
HardApptWindowEntity window = crossDayWindow();
assertFalse(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 20, 16, 59, 59)).available());
assertFalse(HardApptReservationServiceImpl.evaluateAvailability(
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
assertFalse(HardApptReservationServiceImpl.windowAvailability(
window, LocalDateTime.of(2026, 7, 20, 16, 59, 59)).available());
assertFalse(HardApptReservationServiceImpl.windowAvailability(
window, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
/** 动态预约固化用餐日当天 23:59:59 作为核销有效期结束时间。 */
/** 窗口缺失或时间倒置视为规则不完整,直接不可预约。 */
@Test
void dynamicReservationUsesValidatedSegmentEndTime() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setHourType("1");
dynamic.setStatus(YesNoEnum.YES.getCode());
void windowWithInvalidTimesIsUnavailable() {
HardApptWindowEntity broken = new HardApptWindowEntity();
broken.setStartTime(LocalDateTime.of(2026, 7, 21, 10, 0));
broken.setEndTime(LocalDateTime.of(2026, 7, 20, 17, 0));
assertFalse(HardApptReservationServiceImpl.windowAvailability(
broken, LocalDateTime.of(2026, 7, 20, 18, 0)).available());
assertFalse(HardApptReservationServiceImpl.windowAvailability(
null, LocalDateTime.of(2026, 7, 20, 18, 0)).available());
}
var validation = reservationService(dynamic, 1, List.of())
.validateReservation(10L, 1L, null, "1", APPT_DATE, null,
/** 动态校验固化窗口截止时间为免审截止,固化用餐日 23:59:59 为核销有效期结束。 */
@Test
void dynamicReservationUsesWindowEndAndMealDayEnd() {
var validation = dynamicService(dynamicRule(), crossDayWindow(),
List.of(windowItem(1L, APPT_DATE, "1")), 1L)
.validateReservation(10L, 1L, null, "1", APPT_DATE,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(1L, validation.apptId());
assertEquals("1", validation.hourType());
assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.eligibilityDeadline());
assertEquals(LocalDateTime.of(2026, 7, 21, 23, 59, 59), validation.usageEndDateTime());
}
/** 周期预约固化用餐日当天 23:59:59,不受当前 hard_appt 餐段配置影响。 */
/** 窗口持久化了可自动退款时间时,免审截止优先取该值而非开放预约结束时间。 */
@Test
void weeklyReservationUsesUseDateEndOfDay() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
void dynamicReservationPrefersWindowAutoRefundTime() {
HardApptWindowEntity window = crossDayWindow();
window.setAutoRefundTime(LocalDateTime.of(2026, 7, 21, 9, 0));
var validation = reservationService(weekly, 0, List.of(rule))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
var validation = dynamicService(dynamicRule(), window,
List.of(windowItem(1L, APPT_DATE, "1")), 1L)
.validateReservation(10L, 1L, null, "1", APPT_DATE,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
assertEquals(LocalDateTime.of(2026, 7, 21, 9, 0), validation.eligibilityDeadline());
}
/** 周期预约即使存在重复餐段配置,也按订单用餐日固化全天核销有效期。 */
/** 窗口内不存在的日期或餐段(篡改入参)一律拒绝。 */
@Test
void weeklyReservationIgnoresDuplicateSegmentDefinitions() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
HardApptEntity duplicate = weeklyAppt(2L, 10L);
duplicate.setHourType("1");
void dynamicValidationRejectsDateAndSegmentOutsideWindow() {
HardApptReservationServiceImpl service = dynamicService(dynamicRule(), crossDayWindow(),
List.of(windowItem(1L, APPT_DATE, "1")), 0L);
LocalDateTime inWindow = LocalDateTime.of(2026, 7, 20, 18, 0);
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, 1L, null, "1", APPT_DATE.plusDays(1), inWindow));
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, 1L, null, "3", APPT_DATE, inWindow));
}
/** 错商户、窗口缺失、规则停用均拒绝。 */
@Test
void dynamicValidationRejectsWrongMerchantMissingWindowAndDisabledRule() {
HardApptWindowEntity window = crossDayWindow();
LocalDateTime inWindow = LocalDateTime.of(2026, 7, 20, 18, 0);
assertThrows(RuntimeException.class, () -> dynamicService(dynamicRule(), window,
List.of(windowItem(1L, APPT_DATE, "1")), 1L)
.validateReservation(99L, 1L, null, "1", APPT_DATE, inWindow));
assertThrows(RuntimeException.class, () -> dynamicService(dynamicRule(), null,
List.of(), 0L)
.validateReservation(10L, 1L, null, "1", APPT_DATE, inWindow));
HardApptEntity disabled = dynamicRule();
disabled.setStatus(YesNoEnum.NO.getCode());
assertThrows(RuntimeException.class, () -> dynamicService(disabled, window,
List.of(windowItem(1L, APPT_DATE, "1")), 1L)
.validateReservation(10L, 1L, null, "1", APPT_DATE, inWindow));
}
/** v3 列表按窗口×明细展开、按日期分组,今天之前的目标日期不输出,选项 id 即窗口 ID。 */
@Test
void v3ListGroupsDynamicOptionsByDateAndOmitsPastDates() {
List<HardApptItemEntity> items = List.of(
windowItem(1L, LocalDate.of(2026, 7, 23), "1"),
windowItem(1L, LocalDate.of(2026, 7, 23), "2"),
windowItem(1L, LocalDate.of(2026, 7, 24), "1"),
windowItem(1L, LocalDate.of(2026, 7, 22), "1"));
List<HardApptDateOptionsVo> result = dynamicService(dynamicRule(), wideWindow(), items, 0L)
.listOptions(10L, LocalDateTime.of(2026, 7, 23, 8, 0));
assertEquals(List.of(LocalDate.of(2026, 7, 23), LocalDate.of(2026, 7, 24)),
result.stream().map(HardApptDateOptionsVo::getApptDate).toList());
assertEquals(2, result.get(0).getApptList().size());
assertEquals(1, result.get(1).getApptList().size());
assertTrue(result.stream().flatMap(day -> day.getApptList().stream())
.allMatch(option -> HardApptReserveModeEnum.TIME_WINDOW.getType().equals(option.getReserveMode())
&& Long.valueOf(1L).equals(option.getId())
&& Boolean.TRUE.equals(option.getAvailable())
&& option.getStartTime() != null && option.getEndTime() != null));
}
/** 管理端推演限制在推演日期范围内,范围外日期不输出。 */
@Test
void dynamicPreviewBoundsOptionsToDateRange() {
List<HardApptItemEntity> items = List.of(
windowItem(1L, LocalDate.of(2026, 7, 24), "1"),
windowItem(1L, LocalDate.of(2026, 8, 15), "1"));
List<HardApptDateOptionsVo> result = dynamicService(dynamicRule(), wideWindow(), items, 0L)
.previewOptions(10L, 7, LocalDateTime.of(2026, 7, 24, 8, 0));
assertEquals(List.of(LocalDate.of(2026, 7, 24)),
result.stream().filter(day -> !day.getApptList().isEmpty())
.map(HardApptDateOptionsVo::getApptDate).toList());
}
/* ************************************* 周期预约 ************************************* */
/** 周期预约固化用餐日当天 23:59:59,不读取动态窗口配置。 */
@Test
void weeklyReservationUsesUseDateEndOfDay() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
var validation = reservationService(weekly, 0, List.of(rule), List.of(weekly, duplicate))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
var validation = cycleService(List.of(rule), 10L)
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28),
LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 28, 23, 59, 59), validation.usageEndDateTime());
@@ -125,30 +212,30 @@ class HardApptReservationServiceImplTest {
void configurableCycleOnlyAcceptsConfiguredOffsetsAndSegments() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weeklyAppt(1L, 10L), 0, List.of(rule));
HardApptReservationServiceImpl service = cycleService(List.of(rule), 10L);
LocalDateTime fridayOpen = LocalDateTime.of(2026, 7, 24, 18, 0);
assertEquals(LocalDate.of(2026, 7, 28),
service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 7, 28), null, fridayOpen).apptDate());
LocalDate.of(2026, 7, 28), fridayOpen).apptDate());
assertEquals(LocalDate.of(2026, 8, 3),
service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 3), null, fridayOpen).apptDate());
LocalDate.of(2026, 8, 3), fridayOpen).apptDate());
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 4), null, fridayOpen));
LocalDate.of(2026, 8, 4), fridayOpen));
}
@Test
void configurableCycleSupportsTargetDateOnCloseDay() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 0, 1, "1");
HardApptReservationServiceImpl service = reservationService(weeklyAppt(1L, 10L), 0, List.of(rule));
HardApptReservationServiceImpl service = cycleService(List.of(rule), 10L);
LocalDateTime fridayOpen = LocalDateTime.of(2026, 7, 24, 18, 0);
assertEquals(LocalDate.of(2026, 7, 26),
service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 7, 26), null, fridayOpen).apptDate());
LocalDate.of(2026, 7, 26), fridayOpen).apptDate());
}
@Test
@@ -204,223 +291,134 @@ class HardApptReservationServiceImplTest {
assertThrows(RuntimeException.class, () -> HardApptCycleRuleCalculator.validate(rule));
}
/** 周期模式 v3 列表跳过未配置餐段的自然日,并保留逐日餐段分配。 */
@Test
void v3ListOnlyReturnsDynamicRulesForTimeWindowMerchant() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
HardApptCycleRuleVo cycleRule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "2");
HardApptTimeEntity mapping = new HardApptTimeEntity();
mapping.setMchId(10L);
mapping.setApptId(1L);
mapping.setType(HardApptTimeTypeEnum.TODAY.getType());
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of(dynamic));
when(timeService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptTimeEntity>>any()))
.thenReturn(List.of(mapping));
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(cycleRule));
List<HardApptDateOptionsVo> result = new HardApptReservationServiceImpl(
apptService, timeService, cycleRuleService,
merchantModeService(HardApptReserveModeEnum.TIME_WINDOW))
.listOptions(10L, LocalDateTime.of(2026, 7, 24, 17, 0));
assertEquals(1, result.size());
assertEquals(1, result.get(0).getApptList().size());
assertTrue(result.stream().flatMap(day -> day.getApptList().stream())
.allMatch(option -> HardApptReserveModeEnum.TIME_WINDOW.getType().equals(option.getReserveMode())));
}
@Test
void v3ListOmitsEmptyTargetDaysAndKeepsDailySegmentAssignments() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
void weeklyV3ListOmitsEmptyTargetDaysAndKeepsDailySegmentAssignments() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 3, "1");
rule.setItemList(List.of(cycleItem(100L, 10L, 0, "1"), cycleItem(100L, 10L, 2, "3")));
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of());
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(rule));
List<HardApptDateOptionsVo> result = new HardApptReservationServiceImpl(
apptService, timeService, cycleRuleService,
merchantModeService(HardApptReserveModeEnum.WEEKLY_CYCLE))
List<HardApptDateOptionsVo> result = cycleService(List.of(rule), 10L)
.listOptions(10L, LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(List.of(LocalDate.of(2026, 7, 28), LocalDate.of(2026, 7, 30)),
result.stream().map(HardApptDateOptionsVo::getApptDate).toList());
assertEquals(List.of("1", "3"), result.stream()
.flatMap(day -> day.getApptList().stream())
.map(HardApptEntity::getHourType)
.map(HardApptOptionVo::getHourType)
.toList());
}
@Test
void legacyV2ListOnlyReturnsDynamicRules() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
HardApptEntity weekly = weeklyAppt(2L, 10L);
HardApptTimeEntity mapping = new HardApptTimeEntity();
mapping.setMchId(10L);
mapping.setApptId(1L);
mapping.setType(HardApptTimeTypeEnum.TODAY.getType());
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(List.of(dynamic));
when(timeService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptTimeEntity>>any()))
.thenReturn(List.of(mapping));
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
List<HardApptTimeCascadeVo> result =
new HardApptReservationServiceImpl(
apptService, timeService, cycleRuleService,
merchantModeService(HardApptReserveModeEnum.TIME_WINDOW))
.listLegacyOptions(10L);
assertEquals(1, result.size());
assertEquals(List.of(1L), result.get(0).getApptList().stream().map(HardApptEntity::getId).toList());
}
@Test
void legacyListsAreEmptyForWeeklyMerchant() {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
HardApptReservationServiceImpl service = new HardApptReservationServiceImpl(
apptService, timeService, cycleRuleService,
merchantModeService(HardApptReserveModeEnum.WEEKLY_CYCLE));
assertTrue(service.listLegacyFlatOptions(10L).isEmpty());
assertTrue(service.listLegacyOptions(10L).isEmpty());
}
@Test
void validationRejectsWrongMerchantDisabledRuleTamperedDateAndMissingWeeklyDate() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
LocalDateTime inWindow = LocalDateTime.of(2026, 7, 20, 18, 0);
HardApptReservationServiceImpl service = reservationService(dynamic, 1, List.of());
assertThrows(RuntimeException.class,
() -> service.validateReservation(99L, 1L, null, null, APPT_DATE, null, inWindow));
assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, 1L, null, null, APPT_DATE.plusDays(3), null, inWindow));
dynamic.setStatus(YesNoEnum.NO.getCode());
assertThrows(RuntimeException.class,
() -> reservationService(dynamic, 1, List.of())
.validateReservation(10L, 1L, null, null, APPT_DATE, null, inWindow));
HardApptEntity weekly = weeklyAppt(2L, 10L);
assertThrows(RuntimeException.class,
() -> reservationService(weekly, 0, List.of())
.validateReservation(10L, 2L, null, null, null, null, inWindow));
}
@Test
void dynamicValidationRejectsDateWithoutMapping() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setStatus(YesNoEnum.YES.getCode());
assertThrows(RuntimeException.class, () -> reservationService(dynamic, 0, List.of())
.validateReservation(10L, 1L, null, null, APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0)));
}
@Test
void weeklyValidationRejectsTamperedRuleDateAndHourType() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(rule));
HardApptReservationServiceImpl service = cycleService(List.of(rule), 10L);
LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0);
var validation = service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 7, 28), null, now);
LocalDate.of(2026, 7, 28), now);
assertEquals(100L, validation.apptRuleId());
assertEquals(LocalDateTime.of(2026, 7, 26, 22, 0), validation.eligibilityDeadline());
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 999L, "1",
LocalDate.of(2026, 7, 28), null, now));
LocalDate.of(2026, 7, 28), now));
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "1",
LocalDate.of(2026, 8, 10), null, now));
LocalDate.of(2026, 8, 10), now));
assertThrows(RuntimeException.class, () -> service.validateReservation(10L, null, 100L, "3",
LocalDate.of(2026, 7, 28), null, now));
LocalDate.of(2026, 7, 28), now));
}
@Test
void weeklyValidationRequiresRuleId() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
HardApptCycleRuleVo firstRule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
HardApptReservationServiceImpl service = reservationService(weekly, 0, List.of(firstRule));
HardApptReservationServiceImpl service = cycleService(List.of(firstRule), 10L);
LocalDate targetDate = LocalDate.of(2026, 7, 28);
LocalDateTime now = LocalDateTime.of(2026, 7, 24, 18, 0);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.validateReservation(10L, null, null, "1", targetDate, null, now));
assertTrue(exception.getMessage().contains("周期预约规则不能为空"));
assertEquals(100L, service.validateReservation(10L, null, 100L, "1", targetDate, null, now).apptRuleId());
() -> service.validateReservation(10L, null, null, "1", targetDate, now));
assertEquals(ErrorCodes.HARD_APPT_CYCLE_RULE_REQUIRED, ((I18nBizException) exception).getCode());
assertEquals(100L, service.validateReservation(10L, null, 100L, "1", targetDate, now).apptRuleId());
}
private HardApptEntity timeWindowAppt() {
HardApptEntity appt = new HardApptEntity();
appt.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType());
appt.setStartTimeType(HardApptTimeTypeEnum.YESTERDAY.getType());
appt.setStartTime(LocalTime.of(17, 0));
appt.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
appt.setEndTime(LocalTime.of(10, 0));
return appt;
/* ************************************* 测试替身 ************************************* */
/** 跨天窗口:7-20 17:00 开放至 7-21 10:00 截止。 */
private HardApptWindowEntity crossDayWindow() {
HardApptWindowEntity window = new HardApptWindowEntity();
window.setId(1L);
window.setApptId(1L);
window.setMchId(10L);
window.setStartTime(LocalDateTime.of(2026, 7, 20, 17, 0));
window.setEndTime(LocalDateTime.of(2026, 7, 21, 10, 0));
return window;
}
private HardApptEntity weeklyAppt(Long id, Long mchId) {
HardApptEntity appt = new HardApptEntity();
appt.setId(id);
appt.setMchId(mchId);
appt.setStatus(YesNoEnum.YES.getCode());
appt.setReserveMode(HardApptReserveModeEnum.WEEKLY_CYCLE.getType());
return appt;
/** 覆盖列表/推演测试时刻的宽窗口,保证选项处于可约状态避免触发 i18n 解析。 */
private HardApptWindowEntity wideWindow() {
HardApptWindowEntity window = new HardApptWindowEntity();
window.setId(1L);
window.setApptId(1L);
window.setMchId(10L);
window.setStartTime(LocalDateTime.of(2026, 7, 20, 0, 0));
window.setEndTime(LocalDateTime.of(2026, 8, 31, 23, 59, 59));
return window;
}
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules) {
return reservationService(appt, mappingCount, rules, List.of(appt));
private HardApptEntity dynamicRule() {
HardApptEntity rule = new HardApptEntity();
rule.setId(1L);
rule.setMchId(10L);
rule.setStatus(YesNoEnum.YES.getCode());
return rule;
}
private HardApptItemEntity windowItem(Long windowId, LocalDate targetDate, String hourType) {
HardApptItemEntity item = new HardApptItemEntity();
item.setWindowId(windowId);
item.setMchId(10L);
item.setTargetDate(targetDate);
item.setHourType(hourType);
return item;
}
/**
* 构造可控制实际餐段查询结果的预约服务。
* 动态时间窗模式的预约服务替身。
*
* @param appt 动态校验按主键读取的餐段,同时决定商户预约模式
* @param mappingCount 动态日期与餐段映射数量
* @param rules 当前商户有效周期规则
* @param enabledAppts 周期模式按商户和餐段查询到的有效 hard_appt 列表
* @return 注入测试替身的预约校验服务
* @param rule 窗口归属规则
* @param window 按窗口 ID 读取的开放窗口;null 表示窗口不存在
* @param items 商户全部窗口明细(用于列表)
* @param itemCount 校验时按 (窗口,日期,餐段) 统计的明细数量
*/
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules,
List<HardApptEntity> enabledAppts) {
private HardApptReservationServiceImpl dynamicService(HardApptEntity rule, HardApptWindowEntity window,
List<HardApptItemEntity> items, Long itemCount) {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptWindowMapper windowMapper = mock(HardApptWindowMapper.class);
HardApptItemMapper itemMapper = mock(HardApptItemMapper.class);
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any()))
.thenReturn(window == null ? List.of() : List.of(rule));
when(apptService.getById(rule.getId())).thenReturn(rule);
if (window != null) {
when(windowMapper.selectById(window.getId())).thenReturn(window);
when(windowMapper.selectList(org.mockito.ArgumentMatchers.<Wrapper<HardApptWindowEntity>>any()))
.thenReturn(List.of(window));
}
when(itemMapper.selectList(org.mockito.ArgumentMatchers.<Wrapper<HardApptItemEntity>>any()))
.thenReturn(items);
when(itemMapper.selectCount(org.mockito.ArgumentMatchers.<Wrapper<HardApptItemEntity>>any()))
.thenReturn(itemCount);
return new HardApptReservationServiceImpl(apptService, windowMapper, itemMapper,
mock(HardApptCycleRuleService.class), merchantModeService(HardApptReserveModeEnum.TIME_WINDOW));
}
/** 周期模式的预约服务替身。 */
private HardApptReservationServiceImpl cycleService(List<HardApptCycleRuleVo> rules, Long mchId) {
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
HardApptMerchantModeService merchantModeService = merchantModeService(
HardApptReserveModeEnum.valueOfByType(appt.getReserveMode()));
when(apptService.getById(appt.getId())).thenReturn(appt);
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any())).thenReturn(enabledAppts);
when(timeService.count(any())).thenReturn(mappingCount);
when(cycleRuleService.listEnabledConfigurations(appt.getMchId())).thenReturn(rules);
return new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService, merchantModeService);
when(cycleRuleService.listEnabledConfigurations(mchId)).thenReturn(rules);
return new HardApptReservationServiceImpl(mock(HardApptService.class), mock(HardApptWindowMapper.class),
mock(HardApptItemMapper.class), cycleRuleService,
merchantModeService(HardApptReserveModeEnum.WEEKLY_CYCLE));
}
private HardApptMerchantModeService merchantModeService(HardApptReserveModeEnum reserveMode) {
@@ -0,0 +1,264 @@
package com.bsd.pjcloud.hard.service.impl;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.dto.HardApptDto;
import com.bsd.pjcloud.hard.api.dto.HardApptItemDto;
import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto;
import com.bsd.pjcloud.hard.api.entity.HardApptEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.mapper.HardApptItemMapper;
import com.bsd.pjcloud.hard.mapper.HardApptMapper;
import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper;
import com.bsd.pjcloud.hard.service.HardApptMerchantModeService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
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 HardApptServiceImplTest {
private static final Long MCH_ID = 10L;
/* ************************************* 可自动退款时间 ************************************* */
/** 未填可自动退款时间的窗口按开放预约结束时间落库。 */
@Test
void createNormalizesNullAutoRefundTimeToEnd() {
Fixture fixture = new Fixture();
HardApptDto dto = ruleDto(window(null));
fixture.service.create(dto);
ArgumentCaptor<HardApptWindowEntity> captor = ArgumentCaptor.forClass(HardApptWindowEntity.class);
verify(fixture.windowMapper).insert(captor.capture());
assertEquals(LocalDateTime.of(2026, 10, 1, 12, 0), captor.getValue().getAutoRefundTime());
}
/** 可自动退款时间早于等于开始或晚于结束均拒绝(约束 (start, end])。 */
@Test
void createRejectsAutoRefundTimeOutsideWindow() {
Fixture fixture = new Fixture();
HardApptWindowDto beforeStart = window(LocalDateTime.of(2026, 9, 30, 23, 59, 59));
RuntimeException early = assertThrows(RuntimeException.class, () -> fixture.service.create(ruleDto(beforeStart)));
assertTrue(early.getMessage().contains("可自动退款时间"));
HardApptWindowDto afterEnd = window(LocalDateTime.of(2026, 10, 1, 12, 0, 1));
assertThrows(RuntimeException.class, () -> fixture.service.create(ruleDto(afterEnd)));
verify(fixture.windowMapper, never()).insert(any(HardApptWindowEntity.class));
}
/* ************************************* 规则头字段编辑 ************************************* */
/** windowList 为空的编辑只更新规则头字段,不触发窗口重建。 */
@Test
void editWithoutWindowListOnlyUpdatesHeader() {
Fixture fixture = new Fixture();
HardApptEntity existing = existingRule();
when(fixture.apptMapper.selectById(existing.getId())).thenReturn(existing);
HardApptDto dto = new HardApptDto();
dto.setId(existing.getId());
dto.setMchId(MCH_ID);
dto.setStatus(YesNoEnum.NO.getCode());
dto.setRemark("停用整条规则");
fixture.service.edit(dto);
verify(fixture.apptMapper).updateById(dto);
verify(fixture.windowMapper, never()).delete(any());
verify(fixture.itemMapper, never()).delete(any());
}
/* ************************************* 窗口级编辑 ************************************* */
/** 窗口级编辑:更新窗口时间并全量重建明细,排除本窗口后无冲突。 */
@Test
void updateWindowRebuildsItemsAndPersistsRefundTime() {
Fixture fixture = new Fixture();
HardApptWindowEntity existing = existingWindow();
when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing);
when(fixture.apptMapper.selectById(existing.getApptId())).thenReturn(existingRule());
// 商户启用规则含本规则,窗口列表含本窗口;排除本窗口后比对集合为空
when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule()));
when(fixture.windowMapper.selectList(any())).thenReturn(List.of(existing));
HardApptWindowDto dto = window(LocalDateTime.of(2026, 10, 1, 9, 30));
dto.setId(existing.getId());
fixture.service.updateWindow(dto);
ArgumentCaptor<HardApptWindowEntity> captor = ArgumentCaptor.forClass(HardApptWindowEntity.class);
verify(fixture.windowMapper).updateById(captor.capture());
assertEquals(existing.getId(), captor.getValue().getId());
assertEquals(LocalDateTime.of(2026, 10, 1, 9, 30), captor.getValue().getAutoRefundTime());
verify(fixture.itemMapper).delete(any());
verify(fixture.itemMapper).insert(anyList());
}
/** 窗口级编辑与商户其他窗口时间重叠拒绝。 */
@Test
void updateWindowRejectsOverlapWithOtherWindow() {
Fixture fixture = new Fixture();
HardApptWindowEntity existing = existingWindow();
when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing);
when(fixture.apptMapper.selectById(existing.getApptId())).thenReturn(existingRule());
HardApptEntity otherRule = existingRule();
otherRule.setId(2L);
when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule(), otherRule));
HardApptWindowEntity otherWindow = existingWindow();
otherWindow.setId(99L);
otherWindow.setApptId(2L);
otherWindow.setStartTime(LocalDateTime.of(2026, 10, 1, 0, 0));
otherWindow.setEndTime(LocalDateTime.of(2026, 10, 1, 23, 59, 59));
when(fixture.windowMapper.selectList(any())).thenReturn(List.of(existing, otherWindow));
HardApptWindowDto dto = window(null);
dto.setId(existing.getId());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> fixture.service.updateWindow(dto));
assertTrue(exception.getMessage().contains("开放时间与商户已存在窗口重叠"));
verify(fixture.windowMapper, never()).updateById(any(HardApptWindowEntity.class));
}
/** 窗口级编辑撞商户其他窗口的可预约日期拒绝。 */
@Test
void updateWindowRejectsDuplicatedTargetDate() {
Fixture fixture = new Fixture();
HardApptWindowEntity existing = existingWindow();
when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing);
when(fixture.apptMapper.selectById(existing.getApptId())).thenReturn(existingRule());
HardApptEntity otherRule = existingRule();
otherRule.setId(2L);
when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule(), otherRule));
HardApptWindowEntity otherWindow = existingWindow();
otherWindow.setId(99L);
otherWindow.setApptId(2L);
otherWindow.setStartTime(LocalDateTime.of(2026, 10, 5, 0, 0));
otherWindow.setEndTime(LocalDateTime.of(2026, 10, 5, 12, 0));
when(fixture.windowMapper.selectList(any())).thenReturn(List.of(existing, otherWindow));
HardApptItemEntity taken = new HardApptItemEntity();
taken.setWindowId(99L);
taken.setTargetDate(LocalDate.of(2026, 10, 2));
taken.setHourType("1");
when(fixture.itemMapper.selectList(any())).thenReturn(List.of(taken));
HardApptWindowDto duplicatedDto = window(null);
duplicatedDto.setId(existing.getId());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> fixture.service.updateWindow(duplicatedDto));
assertTrue(exception.getMessage().contains("已存在于商户其他窗口"));
}
/* ************************************* 窗口级删除 ************************************* */
/** 规则下最后一个窗口删除后连带删除规则。 */
@Test
void deleteWindowRemovesRuleWhenNoWindowsRemain() {
Fixture fixture = new Fixture();
HardApptWindowEntity existing = existingWindow();
when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing);
when(fixture.windowMapper.selectCount(any())).thenReturn(0L);
fixture.service.deleteWindow(existing.getId());
verify(fixture.itemMapper).delete(any());
verify(fixture.windowMapper).deleteById(existing.getId());
verify(fixture.apptMapper).deleteById(existing.getApptId());
}
/** 仍有兄弟窗口时仅删窗口,规则保留。 */
@Test
void deleteWindowKeepsRuleWhenSiblingsRemain() {
Fixture fixture = new Fixture();
HardApptWindowEntity existing = existingWindow();
when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing);
when(fixture.windowMapper.selectCount(any())).thenReturn(1L);
fixture.service.deleteWindow(existing.getId());
verify(fixture.windowMapper).deleteById(existing.getId());
verify(fixture.apptMapper, never()).deleteById(any(Long.class));
}
/* ************************************* 测试替身 ************************************* */
/** 规则 1:商户 10,启用;新增时主键由 mapper 替身回填。 */
private HardApptEntity existingRule() {
HardApptEntity rule = new HardApptEntity();
rule.setId(1L);
rule.setMchId(MCH_ID);
rule.setStatus(YesNoEnum.YES.getCode());
return rule;
}
/** 存量窗口 5:归属规则 1,10-01 08:00 开放至 12:00。 */
private HardApptWindowEntity existingWindow() {
HardApptWindowEntity window = new HardApptWindowEntity();
window.setId(5L);
window.setApptId(1L);
window.setMchId(MCH_ID);
window.setStartTime(LocalDateTime.of(2026, 10, 1, 8, 0));
window.setEndTime(LocalDateTime.of(2026, 10, 1, 12, 0));
window.setAutoRefundTime(LocalDateTime.of(2026, 10, 1, 12, 0));
return window;
}
private HardApptDto ruleDto(HardApptWindowDto window) {
HardApptDto dto = new HardApptDto();
dto.setMchId(MCH_ID);
dto.setWindowList(List.of(window));
return dto;
}
/** 新窗口入参:10-01 08:00 ~ 12:00,可预约 10-02 早餐。 */
private HardApptWindowDto window(LocalDateTime autoRefundTime) {
HardApptWindowDto dto = new HardApptWindowDto();
dto.setStartTime(LocalDateTime.of(2026, 10, 1, 8, 0));
dto.setEndTime(LocalDateTime.of(2026, 10, 1, 12, 0));
dto.setAutoRefundTime(autoRefundTime);
HardApptItemDto item = new HardApptItemDto();
item.setTargetDate(LocalDate.of(2026, 10, 2));
item.setHourType("1");
dto.setItemList(List.of(item));
return dto;
}
/** 组装被测服务:ServiceImpl 的 baseMapper 字段需反射注入。 */
private static class Fixture {
final HardApptMapper apptMapper = mock(HardApptMapper.class);
final HardApptWindowMapper windowMapper = mock(HardApptWindowMapper.class);
final HardApptItemMapper itemMapper = mock(HardApptItemMapper.class);
final HardApptServiceImpl service;
Fixture() {
HardApptMerchantModeService merchantModeService = mock(HardApptMerchantModeService.class);
when(merchantModeService.resolve(any())).thenReturn(new HardApptMerchantModeService.MerchantModeContext(
new MchManageVo(), HardApptReserveModeEnum.TIME_WINDOW));
service = new HardApptServiceImpl(windowMapper, itemMapper, merchantModeService);
ReflectionTestUtils.setField(service, "baseMapper", apptMapper);
doAnswer(invocation -> {
((HardApptEntity) invocation.getArgument(0)).setId(100L);
return 1;
}).when(apptMapper).insert(any(HardApptEntity.class));
}
}
}
@@ -238,6 +238,16 @@ public interface RemoteUserService {
@NoToken
R<List<Long>> getUserIdListByRoleIdList(@RequestParam("roleIdList") List<Long> roleIdList);
/**
* 内部调用:根据角色编码查询当前有效用户ID列表(wallet 定时任务补建钱包检查用)。
*
* @param code 角色编码
* @return 该角色下当前仍存在的用户 ID 列表;角色不存在时返回空列表
*/
@NoToken
@GetMapping("/user/inner/user-ids-by-role-code")
R<List<Long>> getInnerUserIdsByRoleCode(@RequestParam("code") String code);
/**
* 根据部门ID列表获取用户ID列表接口
*
@@ -710,6 +710,29 @@ public class SysUserController {
return R.ok(userService.listUserIdByRoleIds(CollUtil.toList(roleIdList)));
}
/**
* 内部调用:根据角色编码查询当前有效用户ID列表(wallet 定时任务补建钱包检查用)。
* <p>角色关系可能残留已删除用户,这里按用户表二次过滤,只返回仍存在的用户,
* 避免下游按无效用户创建钱包。</p>
*
* @param code 角色编码
* @return 该角色下当前仍存在的用户 ID 列表;角色不存在时返回空列表
*/
@Inner
@GetMapping("/inner/user-ids-by-role-code")
public R<List<Long>> getInnerUserIdsByRoleCode(@RequestParam("code") String code) {
SysRole sysRole = sysRoleService.getOne(Wrappers.<SysRole>lambdaQuery().eq(SysRole::getRoleCode, code));
if (sysRole == null) {
return R.ok(new ArrayList<>());
}
List<Long> userIds = userService.listUserIdByRoleIds(CollUtil.toList(sysRole.getRoleId()));
if (CollUtil.isEmpty(userIds)) {
return R.ok(new ArrayList<>());
}
return R.ok(userService.list(Wrappers.<SysUser>lambdaQuery().in(SysUser::getUserId, userIds))
.stream().map(SysUser::getUserId).collect(Collectors.toList()));
}
/**
* 根据部门ID列表获取用户ID列表接口
*
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.daemon.quartz.task;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.daemon.quartz.constants.PjcloudQuartzEnum;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 钱包自动创建补漏任务。sys_job 通过 spring bean 反射调用(job_type=2,
* class_name=walletManageTask),bean 内走 Feign 调 wallet 服务内部端点:
* 跨租户检查拥有 WALLET_AUTO_CREATE 角色但未执行过创建钱包的用户并补建,
* 补建复用 wallet 侧按类型幂等的创建逻辑,任务重复触发安全。
*/
@Slf4j
@Component("walletManageTask")
@RequiredArgsConstructor
public class WalletManageTask {
private final RemoteWalletService remoteWalletService;
/**
* 无参重载:sys_job 未配置 method_params_value 时,SpringBeanTaskInvok 按无参方法反射调用。
*/
public String ensureAutoCreateWallets() {
return ensureAutoCreateWallets(null);
}
/**
* 检查拥有自动开户角色(WALLET_AUTO_CREATE)但未创建钱包的用户并补建,每天凌晨触发一次。
*/
@SneakyThrows
public String ensureAutoCreateWallets(String para) {
log.info("钱包自动创建补漏检查开始:{},输入参数:{}", LocalDateTime.now(), para);
try {
R<Integer> result = remoteWalletService.ensureAutoCreateWallets();
if (result.isOk()) {
log.info("钱包自动创建补漏检查完成,本轮补建:{}个用户", result.getData());
} else {
log.error("钱包自动创建补漏检查失败,code:{},msg:{}", result.getCode(), result.getMsg());
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
} catch (Exception e) {
log.error("钱包自动创建补漏检查错误error:{},时间:{}", e.getMessage(), LocalDateTime.now(), e);
return PjcloudQuartzEnum.JOB_LOG_STATUS_FAIL.getType();
}
return PjcloudQuartzEnum.JOB_LOG_STATUS_SUCCESS.getType();
}
}
@@ -64,14 +64,14 @@ public class WalletActiveDto extends WalletActiveEntity implements BaseIsInclude
private List<Long> excludeUserIds;
/**
* 是否绑定用户
* 是否绑定用户;1 表示已绑定,0 表示未绑定,空值表示不限制。
*/
private Boolean isBindUser;
private String isBindUser;
/**
* 是否硬件激活
* 是否硬件激活;1 表示已激活,0 表示未激活,空值表示不限制。
*/
private Boolean isMchActive;
private String isMchActive;
/**
* 卡号长度
@@ -76,6 +76,11 @@ public interface RemoteWalletService {
@GetMapping("/wallet-manage/create-wallet")
R<WalletManageEntity> createWallet(@RequestParam Long userId);
/** 定时任务补漏:跨租户检查拥有 WALLET_AUTO_CREATE 角色但未创建任何钱包的用户并补建 */
@NoToken
@PostMapping("/wallet-manage/inner/ensure-auto-create-wallets")
R<Integer> ensureAutoCreateWallets();
@NoToken
@GetMapping("/wallet-manage/del-wallet")
R<Boolean> delWallet(@RequestParam("userIds") List<Long> userIds);
@@ -116,6 +116,15 @@ public class WalletManageController {
return R.ok();
}
/**
* 定时任务入口:跨租户检查拥有自动开户角色但未创建任何钱包的用户并补建(daemon-quartz Feign 调用)。
*/
@Inner
@PostMapping("/inner/ensure-auto-create-wallets")
public R<Integer> ensureAutoCreateWallets() {
return R.ok(walletManageService.ensureAutoCreateWallets());
}
@Inner
@GetMapping("/get-wallet")
public R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode) {
@@ -54,7 +54,7 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
.orderByDesc(WalletActiveEntity::getCardSort)
.orderByDesc(WalletActiveEntity::getId);
if (ObjectUtil.isNotEmpty(dto.getIsBindUser())) {
if (dto.getIsBindUser()) {
if (YesNoEnum.YES.getCode().equals(dto.getIsBindUser())) {
wrapper.isNotNull(WalletActiveEntity::getUserId);
wrapper.isNotNull(WalletActiveEntity::getWalletId);
} else {
@@ -63,11 +63,7 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
}
}
if (ObjectUtil.isNotEmpty(dto.getIsMchActive())) {
if (dto.getIsMchActive()) {
wrapper.eq(WalletActiveEntity::getIsActiveHardware, YesNoEnum.YES.getCode());
} else {
wrapper.eq(WalletActiveEntity::getIsActiveHardware, YesNoEnum.NO.getCode());
}
wrapper.eq(WalletActiveEntity::getIsActiveHardware, dto.getIsMchActive());
}
Page list = selectPageByScope(page, wrapper, DataScope.of());
list.setRecords(BeanUtils.toBean(list.getRecords(), WalletActiveVo.class));
@@ -38,6 +38,17 @@ public interface WalletManageService extends IService<WalletManageEntity> {
*/
void createWallet(@NotNull Long userId);
/**
* 定时任务补漏:检查拥有自动开户角色(WALLET_AUTO_CREATE 参数配置的角色)但尚未创建任何钱包的用户并补建。
*
* <p>跨租户遍历有效租户,逐租户读取自动开户角色参数、查询角色下有效用户,
* 过滤出完全没有钱包记录的用户后免角色校验补建(复用 {@link #createWallet(Long, Boolean)},内部幂等);
* 单个租户、单个用户失败均不影响其余处理,留待下一轮任务重试。</p>
*
* @return 本轮补建成功的用户数
*/
int ensureAutoCreateWallets();
/**
* 创建钱包的短事务实现,仅由 {@link #createWallet(Long, Boolean)} 在准备动作完成后经代理调用。
* <p>事务内只保留钱包占位查询、钱包插入和饭卡自动绑定,不包含任何远程调用;
@@ -4,6 +4,7 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.exception.I18nBizException;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
@@ -14,9 +15,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysTenant;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
@@ -29,6 +32,7 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.PjcloudDefaultDataScopeHandle;
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
@@ -81,6 +85,9 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
/** 抢卡锁冲突重试的基础退避毫秒数,按尝试次数递增,避免高并发导入下形成重试风暴。 */
private static final long CREATE_WALLET_RETRY_BACKOFF_MILLIS = 100L;
/** 钱包补漏任务分批比对已有钱包用户的批大小,控制 IN 条件长度。 */
private static final int ENSURE_WALLET_USER_BATCH = 500;
private final RemoteDataScopeService dataScopeService;
private final WalletTypeMapper walletTypeMapper;
@@ -97,6 +104,9 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
private final RemoteUserService userService;
private final RemoteDeptService deptService;
/** 租户列表查询,供无用户上下文的定时任务跨租户遍历。 */
private final RemoteTenantService tenantService;
/** 钱包用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。 */
private final WalletUserIdentitySupport userIdentitySupport;
@@ -210,6 +220,100 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
}
}
/**
* 定时任务补漏:跨租户检查拥有自动开户角色但未创建任何钱包的用户并补建(daemon-quartz Feign 调用)。
* <p>定时任务无用户上下文,先查全部有效租户,再逐租户恢复上下文执行
* {@link #ensureAutoCreateWalletsInTenant()};角色参数与角色用户查询的 Feign 调用在租户上下文内发起,
* 租户头由 Feign 拦截器自动透传。单个租户失败不阻断其余租户,留待下一轮任务重试。</p>
*
* @return 全部租户本轮补建成功的用户数
*/
@Override
public int ensureAutoCreateWallets() {
List<SysTenant> tenants = tenantService.list().getData();
if (ObjectUtil.isEmpty(tenants)) {
log.warn("|{}|钱包补建检查未查询到有效租户", TAG);
return 0;
}
int created = 0;
for (SysTenant tenant : tenants) {
try {
Integer tenantCreated = TenantBroker.applyAs(tenant.getId(), tenantId -> ensureAutoCreateWalletsInTenant());
created += ObjectUtil.defaultIfNull(tenantCreated, 0);
} catch (Exception ex) {
log.error("|{}|租户钱包补建检查异常,tenantId:{}", TAG, tenant.getId(), ex);
}
}
log.info("|{}|钱包补建检查完成,租户数:{},本轮补建用户数:{}", TAG, tenants.size(), created);
return created;
}
/**
* 当前租户内补建:查询自动开户角色下完全没有钱包记录的用户并逐个补建。
* <p>角色用户列表来自 UPMS 内部端点(已过滤无效用户);分批比对过滤出没有任何钱包记录的用户后,
* 免二次角色校验逐个补建(复用 {@link #createWallet(Long, Boolean)},内部按类型幂等、已有即跳过)。
* 单个用户失败不影响其余用户,留待下一轮任务重试。</p>
*
* @return 本租户补建成功的用户数
*/
private Integer ensureAutoCreateWalletsInTenant() {
String autoWalletRoleCode = ParamResolver.getStr(CommonRoleConstant.AUTO_CREATE_WALLET);
if (ObjectUtil.isEmpty(autoWalletRoleCode)) {
log.info("|{}|租户未配置WALLET_AUTO_CREATE参数,跳过钱包补建检查", TAG);
return 0;
}
R<List<Long>> roleUserResult = userService.getInnerUserIdsByRoleCode(autoWalletRoleCode);
if (roleUserResult == null || !roleUserResult.isOk()) {
log.error("|{}|查询自动开户角色用户失败,roleCode:{},code:{},msg:{}", TAG, autoWalletRoleCode,
roleUserResult == null ? null : roleUserResult.getCode(),
roleUserResult == null ? null : roleUserResult.getMsg());
return 0;
}
List<Long> roleUserIds = roleUserResult.getData();
if (ObjectUtil.isEmpty(roleUserIds)) {
log.info("|{}|自动开户角色下无用户,roleCode:{}", TAG, autoWalletRoleCode);
return 0;
}
List<Long> missingUserIds = findUsersWithoutAnyWallet(roleUserIds);
if (ObjectUtil.isEmpty(missingUserIds)) {
log.info("|{}|钱包补建检查完成,角色用户均已开通钱包,roleCode:{},用户数:{}", TAG, autoWalletRoleCode, roleUserIds.size());
return 0;
}
int created = 0;
for (Long userId : missingUserIds) {
try {
// 用户已按角色筛选,免二次角色校验;创建内部按用户补查部门并保持幂等
getSelf().createWallet(userId, false);
created++;
} catch (Exception ex) {
log.error("|{}|补建用户钱包失败,userId:{}", TAG, userId, ex);
}
}
log.info("|{}|钱包补建检查完成,roleCode:{},角色用户:{},缺钱包:{},补建成功:{}",
TAG, autoWalletRoleCode, roleUserIds.size(), missingUserIds.size(), created);
return created;
}
/**
* 分批比对已有钱包记录的用户,返回目标用户中完全没有钱包记录(未执行过创建钱包)的部分。
*
* @param roleUserIds 自动开户角色下的用户 ID
* @return 尚未创建任何钱包的用户 ID 列表
*/
private List<Long> findUsersWithoutAnyWallet(List<Long> roleUserIds) {
List<Long> missingUserIds = new ArrayList<>();
for (List<Long> batch : ListUtil.partition(roleUserIds, ENSURE_WALLET_USER_BATCH)) {
Set<Long> walletUserIds = baseMapper.selectList(new LambdaQueryWrapper<WalletManageEntity>()
.select(WalletManageEntity::getUserId)
.in(WalletManageEntity::getUserId, batch))
.stream()
.map(WalletManageEntity::getUserId)
.collect(Collectors.toSet());
batch.stream().filter(userId -> !walletUserIds.contains(userId)).forEach(missingUserIds::add);
}
return missingUserIds;
}
@Override
public WalletManageEntity getOrCreateWallet(Long userId, WalletTypeEnum walletTypeCode) {
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);