From 940e457930862fbe30052a988f3fe569b40aa0f2 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 8 Oct 2026 11:48:24 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E8=BF=81=E7=A7=BBhard=E5=92=8Cwal?= =?UTF-8?q?let=E6=A8=A1=E5=9D=97=E7=9A=84=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E8=AE=A2=E9=A4=90?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E6=97=B6=E9=97=B4=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/20260928_hard_appt_window_model.sql | 143 ++++++ ...d_wallet_transaction_scope_order_index.sql | 91 ++++ ...05_wallet_transaction_biz_type_varchar.sql | 101 ++++ db/20261008_钱包自动创建补漏定时任务.txt | 85 ++++ .../common/core/exception/ErrorCodes.java | 15 - .../resources/i18n/messages_en.properties | 5 - .../resources/i18n/messages_zh_CN.properties | 5 - .../resources/i18n/messages_zh_HK.properties | 5 - .../api/constant/HardApptTimeTypeEnum.java | 47 -- .../bsd/pjcloud/hard/api/dto/HardApptDto.java | 5 +- .../pjcloud/hard/api/dto/HardApptItemDto.java | 20 + .../hard/api/dto/HardApptWindowDto.java | 30 ++ ...ardAppCreateDailyMenuReserveOrdersDto.java | 5 - .../dto/order/app/HardAppCreateOrderDto.java | 5 +- .../app/HardAppCreateReserveOrdersDto.java | 2 - .../hard/api/entity/HardApptEntity.java | 55 +-- ...imeEntity.java => HardApptItemEntity.java} | 64 +-- .../hard/api/entity/HardApptWindowEntity.java | 69 +++ .../hard/api/vo/HardApptDateOptionsVo.java | 3 - .../pjcloud/hard/api/vo/HardApptItemVo.java | 20 + .../pjcloud/hard/api/vo/HardApptOptionVo.java | 29 +- .../hard/api/vo/HardApptTimeCascadeVo.java | 18 - .../hard/api/vo/HardApptTimeTypeVo.java | 12 - .../bsd/pjcloud/hard/api/vo/HardApptVo.java | 5 +- .../pjcloud/hard/api/vo/HardApptWindowVo.java | 30 ++ .../hard/api/vo/order/HardOrderVo.java | 6 +- .../hard/controller/HardApptController.java | 57 ++- .../controller/order/HardOrderController.java | 3 - .../controller/yyx/HardYyxController.java | 2 - .../hard/mapper/HardApptItemMapper.java | 9 + .../hard/mapper/HardApptTimeMapper.java | 16 - .../hard/mapper/HardApptWindowMapper.java | 9 + .../service/HardApptReservationService.java | 14 +- .../pjcloud/hard/service/HardApptService.java | 13 +- .../hard/service/HardApptTimeService.java | 13 - .../impl/HardApptReservationServiceImpl.java | 326 +++++------- .../service/impl/HardApptServiceImpl.java | 463 ++++++++++++------ .../service/impl/HardApptTimeServiceImpl.java | 29 -- .../impl/order/HardOrderServiceImpl.java | 43 +- .../resources/mapper/HardApptTimeMapper.xml | 15 - .../HardApptReservationServiceImplTest.java | 436 ++++++++--------- .../service/impl/HardApptServiceImplTest.java | 264 ++++++++++ .../admin/api/feign/RemoteUserService.java | 10 + .../admin/controller/SysUserController.java | 23 + .../daemon/quartz/task/WalletManageTask.java | 53 ++ .../wallet/api/dto/WalletActiveDto.java | 8 +- .../wallet/api/feign/RemoteWalletService.java | 5 + .../controller/WalletManageController.java | 9 + .../wallet/mapper/WalletActiveMapper.java | 8 +- .../wallet/service/WalletManageService.java | 11 + .../service/impl/WalletManageServiceImpl.java | 104 ++++ 51 files changed, 1876 insertions(+), 942 deletions(-) create mode 100644 db/20260928_hard_appt_window_model.sql create mode 100644 db/20261004_add_wallet_transaction_scope_order_index.sql create mode 100644 db/20261005_wallet_transaction_biz_type_varchar.sql create mode 100644 db/20261008_钱包自动创建补漏定时任务.txt delete mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptItemDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptWindowDto.java rename pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/{HardApptTimeEntity.java => HardApptItemEntity.java} (58%) mode change 100755 => 100644 create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptWindowEntity.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeCascadeVo.java delete mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeTypeVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptItemMapper.java delete mode 100755 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptTimeMapper.java create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptWindowMapper.java delete mode 100755 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptTimeService.java delete mode 100755 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptTimeServiceImpl.java delete mode 100755 pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardApptTimeMapper.xml create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java create mode 100644 pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletManageTask.java diff --git a/db/20260928_hard_appt_window_model.sql b/db/20260928_hard_appt_window_model.sql new file mode 100644 index 000000000..62b549c21 --- /dev/null +++ b/db/20260928_hard_appt_window_model.sql @@ -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'; diff --git a/db/20261004_add_wallet_transaction_scope_order_index.sql b/db/20261004_add_wallet_transaction_scope_order_index.sql new file mode 100644 index 000000000..9999d215c --- /dev/null +++ b/db/20261004_add_wallet_transaction_scope_order_index.sql @@ -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; diff --git a/db/20261005_wallet_transaction_biz_type_varchar.sql b/db/20261005_wallet_transaction_biz_type_varchar.sql new file mode 100644 index 000000000..da4638cd7 --- /dev/null +++ b/db/20261005_wallet_transaction_biz_type_varchar.sql @@ -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; diff --git a/db/20261008_钱包自动创建补漏定时任务.txt b/db/20261008_钱包自动创建补漏定时任务.txt new file mode 100644 index 000000000..004cd9897 --- /dev/null +++ b/db/20261008_钱包自动创建补漏定时任务.txt @@ -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 记录逐条核对处理。 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java index 7039cb637..0fcb12690 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/exception/ErrorCodes.java @@ -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"; diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties index fb55e13c6..6cab779de 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_en.properties @@ -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 diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties index a9d5f2aa6..facbd50fa 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_CN.properties @@ -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=商户预约时间模式无效 diff --git a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties index 1378754de..aefc8f2d7 100644 --- a/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties +++ b/pjcloud-common/pjcloud-common-core/src/main/resources/i18n/messages_zh_HK.properties @@ -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=商戶預約時間模式無效 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java deleted file mode 100644 index af614ce3c..000000000 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/HardApptTimeTypeEnum.java +++ /dev/null @@ -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; - } - -} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java index 3f14cb103..816cf73c5 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java @@ -5,9 +5,12 @@ import lombok.Data; import java.util.List; +/** + * 动态时间窗预约规则入参:一条规则携带多个开放窗口,每窗口携带日期×餐段目标集合。 + */ @Data public class HardApptDto extends HardApptEntity { - private List reserveTimeList; + private List windowList; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptItemDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptItemDto.java new file mode 100644 index 000000000..d172a1be1 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptItemDto.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptWindowDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptWindowDto.java new file mode 100644 index 000000000..73b17b9ba --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptWindowDto.java @@ -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 itemList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java index 42579019d..f9b7b0d69 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateDailyMenuReserveOrdersDto.java @@ -74,11 +74,6 @@ public class HardAppCreateDailyMenuReserveOrdersDto { */ private String hourType; - /** - * 预约时间类型,沿用预约列表返回的协议值并参与预约有效性校验。 - */ - private String apptTimeType; - /** * 实际用餐日期,精确到日,并用于同日同餐段重复预约校验。 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java index 176449424..8148ee274 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateOrderDto.java @@ -58,10 +58,7 @@ public class HardAppCreateOrderDto { // 餐端;周期预约必传,动态时间窗由 apptId 对应规则确定 private String hourType; - // 预约时间类型 - private String apptTimeType; - - // 实际预约日期;新客户端必须传递,apptTimeType 仅用于兼容旧客户端 + // 实际预约日期;动态/周期预约均必须传递 private LocalDate apptDate; // 地址 diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java index df094055b..0dd4580b8 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/app/HardAppCreateReserveOrdersDto.java @@ -38,7 +38,5 @@ public class HardAppCreateReserveOrdersDto { private String hourType; - private String apptTimeType; - private LocalDate apptDate; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java index 2ac9ad317..268f7d2b9 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptEntity.java @@ -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 { @@ -35,54 +34,6 @@ public class HardApptEntity extends Model { @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; - /** * 状态 */ diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptTimeEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptItemEntity.java old mode 100755 new mode 100644 similarity index 58% rename from pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptTimeEntity.java rename to pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptItemEntity.java index 8ee7f3df2..e51a00aa4 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptTimeEntity.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptItemEntity.java @@ -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 { +@Schema(description = "动态时间窗可预约明细(日期×餐段)") +public class HardApptItemEntity extends Model { - - /** - * 编号 - */ @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; -} \ No newline at end of file +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptWindowEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptWindowEntity.java new file mode 100644 index 000000000..808fedceb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/HardApptWindowEntity.java @@ -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 { + + @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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java index ab92076c8..eb735126e 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptDateOptionsVo.java @@ -14,8 +14,5 @@ public class HardApptDateOptionsVo { /** 实际用餐日期。 */ private LocalDate apptDate; - /** 当日期属于旧版今天/明天/后天范围时返回,用于平滑升级。 */ - private String apptTimeType; - private List apptList; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java new file mode 100644 index 000000000..3186053ba --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java @@ -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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java index d5a81f060..b3ea7414b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java @@ -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; diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeCascadeVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeCascadeVo.java deleted file mode 100644 index 04a3986bb..000000000 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeCascadeVo.java +++ /dev/null @@ -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 apptList; - -} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeTypeVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeTypeVo.java deleted file mode 100644 index d12134380..000000000 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptTimeTypeVo.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.bsd.pjcloud.hard.api.vo; - -import lombok.Data; - -@Data -public class HardApptTimeTypeVo { - - private Long mchId; - - private Long type; - -} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptVo.java index fa9118f01..3910f20c0 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptVo.java @@ -5,9 +5,12 @@ import lombok.Data; import java.util.List; +/** + * 动态时间窗预约规则展示:含全部开放窗口及窗口内可预约明细。 + */ @Data public class HardApptVo extends HardApptEntity { - private List reserveTimeList; + private List windowList; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java new file mode 100644 index 000000000..704c2e9e3 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java @@ -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 itemList; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java index c02b50c41..fbc782142 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java @@ -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;非外卖或历史未记录时为空。 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java index 61b8807d4..e24c4fea0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java @@ -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> 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> 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) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index bc0ed1df6..24ff7e12c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -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)); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/yyx/HardYyxController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/yyx/HardYyxController.java index 3a2d2b86d..8a15abef2 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/yyx/HardYyxController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/yyx/HardYyxController.java @@ -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; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptItemMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptItemMapper.java new file mode 100644 index 000000000..1431f62cf --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptItemMapper.java @@ -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 { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptTimeMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptTimeMapper.java deleted file mode 100755 index c1b4cbe34..000000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptTimeMapper.java +++ /dev/null @@ -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 { - - List getTypeListByMchId(@Param("mchId") Long mchId); - -} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptWindowMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptWindowMapper.java new file mode 100644 index 000000000..6f16e4672 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardApptWindowMapper.java @@ -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 { +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index 2f96b3b99..ace408dfe 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -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 listLegacyFlatOptions(Long mchId); - - List listLegacyOptions(Long mchId); - List listOptions(Long mchId); /** @@ -29,19 +23,19 @@ public interface HardApptReservationService { List 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) { } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java index 81d24c2ef..dcbe7dff5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java @@ -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 { Long create(HardApptDto hardAppt); /** - * 修改 + * 修改:windowList 为空时仅更新规则头字段(状态/备注) */ Long edit(HardApptDto hardAppt); @@ -28,6 +29,16 @@ public interface HardApptService extends IService { */ void delByIds(ArrayList list); + /** + * 窗口级修改:更新单个开放窗口(时间/免审退款截止/明细全量重建),商户级唯一性排除本窗口。 + */ + void updateWindow(HardApptWindowDto window); + + /** + * 窗口级删除:级联删除窗口明细;规则下无剩余窗口时连带删除规则。 + */ + void deleteWindow(Long windowId); + /** * 分页查询 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptTimeService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptTimeService.java deleted file mode 100755 index 2eba96b47..000000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptTimeService.java +++ /dev/null @@ -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 { - - List getTypeListByMchId(Long mchId); - -} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index ba3ede168..838b680f5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -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 listLegacyFlatOptions(Long mchId) { - HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); - if (merchantMode != HardApptReserveModeEnum.TIME_WINDOW) { - return List.of(); - } - return findEnabledAppts(mchId, HardApptReserveModeEnum.TIME_WINDOW); - } - - @Override - public List listLegacyOptions(Long mchId) { - HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); - if (merchantMode != HardApptReserveModeEnum.TIME_WINDOW) { - return List.of(); - } - List dynamicAppts = findEnabledAppts(mchId, merchantMode); - if (dynamicAppts.isEmpty()) { - return List.of(); - } - - Map> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts); - LocalDate today = nowInShanghai().toLocalDate(); - List result = new ArrayList<>(); - for (int day = 0; day <= HardApptTimeTypeEnum.AFTER_TOMORROW.getDay(); day++) { - HardApptTimeTypeEnum dateType = HardApptTimeTypeEnum.valueOfByDay(day); - Set mappedIds = apptIdsByDateType.getOrDefault(dateType.getType(), Set.of()); - List 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 listOptions(Long mchId) { return listOptions(mchId, nowInShanghai()); @@ -110,22 +68,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic List listOptions(Long mchId, LocalDateTime now) { HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); LocalDate today = now.toLocalDate(); - Map> optionsByDate = new TreeMap<>(); + Map> optionsByDate = new TreeMap<>(); if (merchantMode == HardApptReserveModeEnum.TIME_WINDOW) { - List dynamicAppts = findEnabledAppts(mchId, merchantMode); - Map> apptIdsByDateType = groupMappingsByDateType(mchId, dynamicAppts); - for (HardApptTimeTypeEnum dateType : HardApptTimeTypeEnum.values()) { - if (!HardApptTimeTypeEnum.isReservationDateType(dateType.getType())) { - continue; - } - Set 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> optionsByDate) { - List dynamicAppts = findEnabledAppts(mchId, HardApptReserveModeEnum.TIME_WINDOW); - Map> 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> 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 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() - .eq(HardApptTimeEntity::getMchId, mchId) - .eq(HardApptTimeEntity::getApptId, apptId) - .eq(HardApptTimeEntity::getType, dateType.getType())); - if (mappingCount == 0) { + Long itemCount = hardApptItemMapper.selectCount(new LambdaQueryWrapper() + .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 findEnabledWindows(Long mchId) { + List ruleIds = hardApptService.list(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream() + .map(HardApptEntity::getId).toList(); + if (ruleIds.isEmpty()) { + return List.of(); + } + List windows = hardApptWindowMapper.selectList( + new LambdaQueryWrapper() + .in(HardApptWindowEntity::getApptId, ruleIds) + .orderByAsc(HardApptWindowEntity::getStartTime)); + if (windows.isEmpty()) { + return List.of(); + } + Map> itemsByWindow = hardApptItemMapper.selectList( + new LambdaQueryWrapper() + .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 findEnabledAppts(Long mchId, HardApptReserveModeEnum reserveMode) { - return hardApptService.list(new LambdaQueryWrapper() - .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> groupMappingsByDateType(Long mchId, List appts) { - if (apptListIsEmpty(appts)) { - return Map.of(); - } - Set apptIds = appts.stream().map(HardApptEntity::getId) - .filter(ObjectUtil::isNotNull).collect(Collectors.toSet()); - if (apptIds.isEmpty()) { - return Map.of(); - } - List mappings = hardApptTimeService.list( - new LambdaQueryWrapper() - .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 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 items) { + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java index 5fe6501f8..557a939db 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java @@ -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 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 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 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 list) { if (ObjectUtil.isEmpty(list)) return; - hardApptTimeMapper.delete(new LambdaQueryWrapper() - .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() + .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() + .eq(HardApptItemEntity::getWindowId, windowId)); + hardApptWindowMapper.deleteById(windowId); + Long remaining = hardApptWindowMapper.selectCount(new LambdaQueryWrapper() + .eq(HardApptWindowEntity::getApptId, existing.getApptId())); + if (remaining != null && remaining == 0) { + baseMapper.deleteById(existing.getApptId()); + } + } + /** * 分页查询 */ @Override public Page scopePage(Page page, HardApptDto dto, DataScope of) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .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 wrapper = buildQueryWrapper(dto); Page pageList = baseMapper.selectPageByScope(page, wrapper, of); List hardApptVos = BeanUtil.copyToList(pageList.getRecords(), HardApptVo.class); pageList.setRecords(hardApptVos); - - if (ObjectUtil.isNotEmpty(pageList)) { - disposePageList(pageList.getRecords()); - } + disposeWindowList(hardApptVos); return pageList; } @Override public List scopeList(HardApptDto dto, DataScope of) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .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 list = baseMapper.selectListByScope(wrapper, of); - + List list = baseMapper.selectListByScope(buildQueryWrapper(dto), of); List 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 resultList = List.of(BeanUtil.toBean(hardAppt, HardApptVo.class)); - disposePageList(resultList); + disposeWindowList(resultList); return resultList.get(0); } - // 创建预约时间 - private void createApptTime(HardApptEntity apptEntity, List reserveTimeList) { - if (!ObjectUtil.isAllNotEmpty(apptEntity.getId(), apptEntity.getMchId())) { - throw new RuntimeException("预约餐段信息不完整"); - } - hardApptTimeMapper.delete(new LambdaQueryWrapper() - .eq(HardApptTimeEntity::getApptId, apptEntity.getId()) - ); - if (ObjectUtil.isEmpty(reserveTimeList)) { - return; - } - ArrayList 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 buildQueryWrapper(HardApptDto dto) { + return new LambdaQueryWrapper() + .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 validateAndNormalizeWindows(HardApptDto dto, Long selfRuleId) { + List 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 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 windowList, Long selfRuleId, Long selfWindowId) { + List existingWindows = findMerchantWindows(mchId, selfRuleId, selfWindowId); + if (existingWindows.isEmpty()) { return; } - - if (ObjectUtil.isEmpty(dto.getReserveTimeList())) { - throw new RuntimeException("可预约日期不能为空"); + Map> 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 windowDates = window.getItemList().stream() + .map(HardApptItemDto::getTargetDate).collect(Collectors.toSet()); + for (Map.Entry> entry : existingDates.entrySet()) { + LocalDate duplicatedDate = windowDates.stream() + .filter(entry.getValue()::contains).findFirst().orElse(null); + if (duplicatedDate != null) { + throw new RuntimeException("窗口 " + (i + 1) + " 可预约日期 " + duplicatedDate + " 已存在于商户其他窗口"); + } + } } - List reserveTimeList = dto.getReserveTimeList().stream() - .filter(ObjectUtil::isNotEmpty) - .distinct() + } + + private LocalDate findFirstDuplicatedDate(HardApptWindowDto left, HardApptWindowDto right) { + Set 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 findMerchantWindows(Long mchId, Long selfRuleId, Long selfWindowId) { + LambdaQueryWrapper ruleWrapper = new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) + .ne(selfRuleId != null, HardApptEntity::getId, selfRuleId); + List ruleIds = baseMapper.selectList(ruleWrapper).stream() + .map(HardApptEntity::getId).toList(); + if (ruleIds.isEmpty()) { + return List.of(); + } + return hardApptWindowMapper.selectList(new LambdaQueryWrapper() + .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> loadWindowDates(List windows) { + List windowIds = windows.stream().map(HardApptWindowEntity::getId).toList(); + return hardApptItemMapper.selectList(new LambdaQueryWrapper() + .in(HardApptItemEntity::getWindowId, windowIds)).stream() + .collect(Collectors.groupingBy(HardApptItemEntity::getWindowId, + Collectors.mapping(HardApptItemEntity::getTargetDate, Collectors.toSet()))); + } + + private void saveWindows(Long apptId, Long mchId, List 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 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 windows = hardApptWindowMapper.selectList( + new LambdaQueryWrapper().eq(HardApptWindowEntity::getApptId, apptId)); + if (!windows.isEmpty()) { + hardApptItemMapper.delete(new LambdaQueryWrapper() + .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() + .eq(HardApptWindowEntity::getApptId, apptId)); + } + + /** + * 为规则展示回填全部窗口及窗口内明细(窗口按开始时间升序,明细按日期+餐段排序)。 + */ + private void disposeWindowList(List records) { + if (ObjectUtil.isEmpty(records)) return; + List ruleIds = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).toList(); + if (ruleIds.isEmpty()) return; + List windows = hardApptWindowMapper.selectList( + new LambdaQueryWrapper() + .in(HardApptWindowEntity::getApptId, ruleIds) + .orderByAsc(HardApptWindowEntity::getStartTime)); + Map> itemsByWindow = windows.isEmpty() ? Map.of() + : hardApptItemMapper.selectList(new LambdaQueryWrapper() + .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() - .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 records) { - if (ObjectUtil.isEmpty(records)) return; - List ids = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).distinct().toList(); - if (ObjectUtil.isNotEmpty(ids)) { - List targetList = hardApptTimeMapper.selectList(new LambdaQueryWrapper().in(HardApptTimeEntity::getApptId, ids)); - for (HardApptVo item : records) { - List apptTimeList = targetList.stream().filter(v -> ObjectUtil.equals(item.getId(), v.getApptId())).toList(); - if (ObjectUtil.isNotEmpty(apptTimeList)) { - List typeList = apptTimeList.stream() - .map(HardApptTimeEntity::getType) - .filter(HardApptTimeTypeEnum::isReservationDateType) - .distinct() - .sorted(Comparator.comparingInt(type -> HardApptTimeTypeEnum.valueOfByType(type).getDay())) - .toList(); - item.setReserveTimeList(typeList); - } - } - } - } - } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptTimeServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptTimeServiceImpl.java deleted file mode 100755 index 259d48653..000000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptTimeServiceImpl.java +++ /dev/null @@ -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 implements HardApptTimeService { - - @Override - public List getTypeListByMchId(Long mchId) { - return baseMapper.getTypeListByMchId(mchId); - } - -} \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 1386cd6c6..b25e6d4fb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -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 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 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 findApptListFuture = CompletableFuture.runAsync(() -> { if (dto.getIsFindApptList()) { List apptList = list.stream().map(HardOrderVo::getApptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isEmpty(apptList)) return; - List apptOrderList = hardApptService.listByIds(apptList); - if (ObjectUtil.isEmpty(apptOrderList)) return; - Map apptMap = CollectionUtils.convertMap(apptOrderList, HardApptEntity::getId); + List apptWindowList = hardApptWindowMapper.selectBatchIds(apptList); + if (ObjectUtil.isEmpty(apptWindowList)) return; + Map 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()); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardApptTimeMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardApptTimeMapper.xml deleted file mode 100755 index 902c53572..000000000 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardApptTimeMapper.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index c25519292..89a38fe87 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -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 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 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 items = List.of( + windowItem(1L, LocalDate.of(2026, 7, 24), "1"), + windowItem(1L, LocalDate.of(2026, 8, 15), "1")); + + List 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.>any())) - .thenReturn(List.of(dynamic)); - when(timeService.list(org.mockito.ArgumentMatchers.>any())) - .thenReturn(List.of(mapping)); - - HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); - when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(cycleRule)); - List 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.>any())) - .thenReturn(List.of()); - HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); - when(cycleRuleService.listEnabledConfigurations(10L)).thenReturn(List.of(rule)); - List result = new HardApptReservationServiceImpl( - apptService, timeService, cycleRuleService, - merchantModeService(HardApptReserveModeEnum.WEEKLY_CYCLE)) + List 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.>any())) - .thenReturn(List.of(dynamic)); - when(timeService.list(org.mockito.ArgumentMatchers.>any())) - .thenReturn(List.of(mapping)); - HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class); - - List 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 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 rules, - List enabledAppts) { + private HardApptReservationServiceImpl dynamicService(HardApptEntity rule, HardApptWindowEntity window, + List 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.>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.>any())) + .thenReturn(List.of(window)); + } + when(itemMapper.selectList(org.mockito.ArgumentMatchers.>any())) + .thenReturn(items); + when(itemMapper.selectCount(org.mockito.ArgumentMatchers.>any())) + .thenReturn(itemCount); + return new HardApptReservationServiceImpl(apptService, windowMapper, itemMapper, + mock(HardApptCycleRuleService.class), merchantModeService(HardApptReserveModeEnum.TIME_WINDOW)); + } + + /** 周期模式的预约服务替身。 */ + private HardApptReservationServiceImpl cycleService(List 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.>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) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java new file mode 100644 index 000000000..12957c5cd --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java @@ -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 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 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)); + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index b50df70cb..ca96fe079 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -238,6 +238,16 @@ public interface RemoteUserService { @NoToken R> getUserIdListByRoleIdList(@RequestParam("roleIdList") List roleIdList); + /** + * 内部调用:根据角色编码查询当前有效用户ID列表(wallet 定时任务补建钱包检查用)。 + * + * @param code 角色编码 + * @return 该角色下当前仍存在的用户 ID 列表;角色不存在时返回空列表 + */ + @NoToken + @GetMapping("/user/inner/user-ids-by-role-code") + R> getInnerUserIdsByRoleCode(@RequestParam("code") String code); + /** * 根据部门ID列表获取用户ID列表接口 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index df21687fc..7060187aa 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -710,6 +710,29 @@ public class SysUserController { return R.ok(userService.listUserIdByRoleIds(CollUtil.toList(roleIdList))); } + /** + * 内部调用:根据角色编码查询当前有效用户ID列表(wallet 定时任务补建钱包检查用)。 + *

角色关系可能残留已删除用户,这里按用户表二次过滤,只返回仍存在的用户, + * 避免下游按无效用户创建钱包。

+ * + * @param code 角色编码 + * @return 该角色下当前仍存在的用户 ID 列表;角色不存在时返回空列表 + */ + @Inner + @GetMapping("/inner/user-ids-by-role-code") + public R> getInnerUserIdsByRoleCode(@RequestParam("code") String code) { + SysRole sysRole = sysRoleService.getOne(Wrappers.lambdaQuery().eq(SysRole::getRoleCode, code)); + if (sysRole == null) { + return R.ok(new ArrayList<>()); + } + List userIds = userService.listUserIdByRoleIds(CollUtil.toList(sysRole.getRoleId())); + if (CollUtil.isEmpty(userIds)) { + return R.ok(new ArrayList<>()); + } + return R.ok(userService.list(Wrappers.lambdaQuery().in(SysUser::getUserId, userIds)) + .stream().map(SysUser::getUserId).collect(Collectors.toList())); + } + /** * 根据部门ID列表获取用户ID列表接口 * diff --git a/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletManageTask.java b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletManageTask.java new file mode 100644 index 000000000..ab790b2d0 --- /dev/null +++ b/pjcloud-visual/pjcloud-daemon-quartz/src/main/java/com/bsd/pjcloud/daemon/quartz/task/WalletManageTask.java @@ -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 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(); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java index 6b3a4a8d0..94edb6bf0 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletActiveDto.java @@ -64,14 +64,14 @@ public class WalletActiveDto extends WalletActiveEntity implements BaseIsInclude private List excludeUserIds; /** - * 是否绑定用户 + * 是否绑定用户;1 表示已绑定,0 表示未绑定,空值表示不限制。 */ - private Boolean isBindUser; + private String isBindUser; /** - * 是否硬件激活 + * 是否硬件激活;1 表示已激活,0 表示未激活,空值表示不限制。 */ - private Boolean isMchActive; + private String isMchActive; /** * 卡号长度 diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java index 92e43ed85..12a143add 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java @@ -76,6 +76,11 @@ public interface RemoteWalletService { @GetMapping("/wallet-manage/create-wallet") R createWallet(@RequestParam Long userId); + /** 定时任务补漏:跨租户检查拥有 WALLET_AUTO_CREATE 角色但未创建任何钱包的用户并补建 */ + @NoToken + @PostMapping("/wallet-manage/inner/ensure-auto-create-wallets") + R ensureAutoCreateWallets(); + @NoToken @GetMapping("/wallet-manage/del-wallet") R delWallet(@RequestParam("userIds") List userIds); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index ea195eeb1..195f95a59 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -116,6 +116,15 @@ public class WalletManageController { return R.ok(); } + /** + * 定时任务入口:跨租户检查拥有自动开户角色但未创建任何钱包的用户并补建(daemon-quartz Feign 调用)。 + */ + @Inner + @PostMapping("/inner/ensure-auto-create-wallets") + public R ensureAutoCreateWallets() { + return R.ok(walletManageService.ensureAutoCreateWallets()); + } + @Inner @GetMapping("/get-wallet") public R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java index 515ad1ada..368a2e4eb 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java @@ -54,7 +54,7 @@ public interface WalletActiveMapper extends PjcloudBaseMapper { */ void createWallet(@NotNull Long userId); + /** + * 定时任务补漏:检查拥有自动开户角色(WALLET_AUTO_CREATE 参数配置的角色)但尚未创建任何钱包的用户并补建。 + * + *

跨租户遍历有效租户,逐租户读取自动开户角色参数、查询角色下有效用户, + * 过滤出完全没有钱包记录的用户后免角色校验补建(复用 {@link #createWallet(Long, Boolean)},内部幂等); + * 单个租户、单个用户失败均不影响其余处理,留待下一轮任务重试。

+ * + * @return 本轮补建成功的用户数 + */ + int ensureAutoCreateWallets(); + /** * 创建钱包的短事务实现,仅由 {@link #createWallet(Long, Boolean)} 在准备动作完成后经代理调用。 *

事务内只保留钱包占位查询、钱包插入和饭卡自动绑定,不包含任何远程调用; diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java index fe247c554..72e8cff59 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java @@ -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定时任务无用户上下文,先查全部有效租户,再逐租户恢复上下文执行 + * {@link #ensureAutoCreateWalletsInTenant()};角色参数与角色用户查询的 Feign 调用在租户上下文内发起, + * 租户头由 Feign 拦截器自动透传。单个租户失败不阻断其余租户,留待下一轮任务重试。

+ * + * @return 全部租户本轮补建成功的用户数 + */ + @Override + public int ensureAutoCreateWallets() { + List 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; + } + + /** + * 当前租户内补建:查询自动开户角色下完全没有钱包记录的用户并逐个补建。 + *

角色用户列表来自 UPMS 内部端点(已过滤无效用户);分批比对过滤出没有任何钱包记录的用户后, + * 免二次角色校验逐个补建(复用 {@link #createWallet(Long, Boolean)},内部按类型幂等、已有即跳过)。 + * 单个用户失败不影响其余用户,留待下一轮任务重试。

+ * + * @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> 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 roleUserIds = roleUserResult.getData(); + if (ObjectUtil.isEmpty(roleUserIds)) { + log.info("|{}|自动开户角色下无用户,roleCode:{}", TAG, autoWalletRoleCode); + return 0; + } + List 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 findUsersWithoutAnyWallet(List roleUserIds) { + List missingUserIds = new ArrayList<>(); + for (List batch : ListUtil.partition(roleUserIds, ENSURE_WALLET_USER_BATCH)) { + Set walletUserIds = baseMapper.selectList(new LambdaQueryWrapper() + .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);