diff --git a/db/20260805_add_third_aggregate_pay.sql b/db/20260805_add_third_aggregate_pay.sql
deleted file mode 100644
index 734e27f8..00000000
--- a/db/20260805_add_third_aggregate_pay.sql
+++ /dev/null
@@ -1,37 +0,0 @@
--- 支付宝与微信聚合订单码支付。
--- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。
--- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。
-
-CREATE TABLE `third_aggregate_pay_session`
-(
- `id` bigint NOT NULL COMMENT '聚合会话主键',
- `aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
- `access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
- `access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
- `biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
- `biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
- `user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
- `mch_id` bigint NOT NULL COMMENT '收款商户ID',
- `price` int NOT NULL COMMENT '应付金额,单位为人民币分',
- `status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
- `selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
- `pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
- `payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
- `expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
- `resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
- `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
- `create_time` datetime NOT NULL COMMENT '创建时间',
- `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
- `update_time` datetime NOT NULL COMMENT '修改时间',
- `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
- `tenant_id` bigint NOT NULL COMMENT '租户ID',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
- UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
- UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
- KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
- KEY `idx_third_aggregate_job` (`status`, `expire_time`)
-) ENGINE = InnoDB
- DEFAULT CHARSET = utf8mb4
- COLLATE = utf8mb4_general_ci
- COMMENT = '支付宝与微信聚合订单码支付会话';
diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql
index 344c3c96..b4421205 100644
--- a/db/20260823_add_hard_order_verification_time.sql
+++ b/db/20260823_add_hard_order_verification_time.sql
@@ -14,3 +14,5 @@ WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4'))
OR `pay_type` IN ('5', '6', '50'))
AND `verification_time` IS NULL
AND `update_time` IS NOT NULL;
+
+
diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql
deleted file mode 100644
index 31468234..00000000
--- a/db/20260824_add_third_payment_task.sql
+++ /dev/null
@@ -1,153 +0,0 @@
--- thirdparty 支付任务事实表与执行尝试表。
--- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。
-CREATE TABLE IF NOT EXISTS `third_payment_task`
-(
- `id` bigint NOT NULL COMMENT '任务主键',
- `tenant_id` bigint NOT NULL COMMENT '租户ID',
- `task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
- `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
- `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
- `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
- `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
- `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
- `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
- `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
- `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
- `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
- `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
- `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
- `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
- `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
- `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
- `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
- `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
- `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
- `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
- `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
- `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
- `create_time` datetime NOT NULL COMMENT '创建时间',
- `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
- `update_time` datetime NOT NULL COMMENT '更新时间',
- `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
- KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
- KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
- KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
- KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`)
-) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
-
-CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
-(
- `id` bigint NOT NULL COMMENT '尝试记录主键',
- `task_id` bigint NOT NULL COMMENT '任务主键',
- `tenant_id` bigint NOT NULL COMMENT '租户ID',
- `attempt_no` int NOT NULL COMMENT '任务内尝试序号',
- `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
- `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
- `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
- `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
- `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
- `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
- `error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
- `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
- `started_time` datetime NOT NULL COMMENT '开始时间',
- `finished_time` datetime DEFAULT NULL COMMENT '结束时间',
- PRIMARY KEY (`id`),
- KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
- KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
-) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
-
--- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。
--- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'),
- 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message',
- 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'),
- 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message',
- 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'),
- 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message',
- 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'),
- 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message',
- 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'),
- 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message',
- 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'),
- 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message',
- 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'),
- 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message',
- 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'),
- 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message',
- 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
-SET @index_ddl = IF(
- EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
- AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'),
- 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message',
- 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)'
-);
-PREPARE index_stmt FROM @index_ddl;
-EXECUTE index_stmt;
-DEALLOCATE PREPARE index_stmt;
-
--- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。
diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql
deleted file mode 100644
index 25115867..00000000
--- a/db/20260825_add_third_mch_fz_relation.sql
+++ /dev/null
@@ -1,79 +0,0 @@
-USE `pjcloudx_thirdparty`;
-
--- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。
--- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。
-CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
-(
- `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
- `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致',
- `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键',
- `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键',
- `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
- `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION',
- `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
- `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id',
- `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
- `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
- `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
- `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
- `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空',
- `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
- `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
- `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是',
- `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
- `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
- `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
- `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
- `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
- `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
- `create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
- `create_time` datetime DEFAULT NULL COMMENT '创建时间',
- `update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
- `update_time` datetime DEFAULT NULL COMMENT '更新时间',
- `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_third_mch_fz_relation_identity`
- (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
- `receiver_type`, `receiver_account`, `relation_scene`),
- KEY `idx_third_mch_fz_relation_default`
- (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
- KEY `idx_third_mch_fz_relation_task`
- (`status`, `is_default`, `update_time`, `id`)
-) ENGINE = InnoDB
- DEFAULT CHARSET = utf8mb4
- COLLATE = utf8mb4_general_ci
- COMMENT ='商户第三方支付分账关系';
-
--- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。
-SET @relation_task_column_exists = (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = DATABASE()
- AND TABLE_NAME = 'third_payment_task'
- AND COLUMN_NAME = 'fz_relation_id'
-);
-SET @relation_task_column_sql = IF(
- @relation_task_column_exists = 0,
- 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
- 'SELECT 1'
-);
-PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
-EXECUTE relation_task_column_stmt;
-DEALLOCATE PREPARE relation_task_column_stmt;
-
--- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
-SET @pay_relation_column_exists = (
- SELECT COUNT(1)
- FROM information_schema.COLUMNS
- WHERE TABLE_SCHEMA = DATABASE()
- AND TABLE_NAME = 'third_pay_order'
- AND COLUMN_NAME = 'fz_relation_id'
-);
-SET @pay_relation_column_sql = IF(
- @pay_relation_column_exists = 0,
- 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
- 'SELECT 1'
-);
-PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
-EXECUTE pay_relation_column_stmt;
-DEALLOCATE PREPARE pay_relation_column_stmt;
diff --git a/db/20260825_third_payment_schema.sql b/db/20260825_third_payment_schema.sql
new file mode 100644
index 00000000..9841ed27
--- /dev/null
+++ b/db/20260825_third_payment_schema.sql
@@ -0,0 +1,362 @@
+-- thirdparty 支付、聚合支付、任务补偿和商户分账关系统一迁移脚本。
+-- 线上只执行本文件;脚本可重复执行,兼容全新数据库和已经执行部分旧迁移的数据库。
+-- 本脚本不删除支付任务、订单、分账关系或历史配置数据;仅在迁移核对通过后清理旧配置字段。
+
+USE `pjcloudx_thirdparty`;
+
+-- 一、聚合订单码支付会话
+CREATE TABLE IF NOT EXISTS `third_aggregate_pay_session`
+(
+ `id` bigint NOT NULL COMMENT '聚合会话主键',
+ `aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
+ `access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
+ `access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
+ `biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
+ `biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
+ `user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
+ `mch_id` bigint NOT NULL COMMENT '收款商户ID',
+ `price` int NOT NULL COMMENT '应付金额,单位为人民币分',
+ `status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
+ `selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
+ `pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
+ `payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
+ `expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
+ `resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime NOT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime NOT NULL COMMENT '修改时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
+ UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
+ UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
+ KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
+ KEY `idx_third_aggregate_job` (`status`, `expire_time`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '支付宝与微信聚合订单码支付会话';
+
+-- 二、Quartz 触发的可恢复支付任务和执行尝试
+CREATE TABLE IF NOT EXISTS `third_payment_task`
+(
+ `id` bigint NOT NULL COMMENT '任务主键',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID',
+ `task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
+ `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
+ `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
+ `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
+ `fz_relation_id` bigint DEFAULT NULL COMMENT 'third_mch_fz_relation主键',
+ `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
+ `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
+ `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
+ `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
+ `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
+ `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
+ `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
+ `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
+ `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
+ `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
+ `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
+ `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
+ `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
+ `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
+ `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
+ `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime NOT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime NOT NULL COMMENT '更新时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
+ KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
+ KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
+ KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
+ KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`),
+ KEY `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
+
+CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
+(
+ `id` bigint NOT NULL COMMENT '尝试记录主键',
+ `task_id` bigint NOT NULL COMMENT '任务主键',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID',
+ `attempt_no` int NOT NULL COMMENT '任务内尝试序号',
+ `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
+ `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
+ `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
+ `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
+ `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
+ `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
+ `error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
+ `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
+ `started_time` datetime NOT NULL COMMENT '开始时间',
+ `finished_time` datetime DEFAULT NULL COMMENT '结束时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
+ KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
+
+-- 三、商户支付分账关系(微信、支付宝共用)
+CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
+(
+ `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID,与mch_manage所属租户一致',
+ `mch_id` bigint NOT NULL COMMENT 'mch_manage商户主键',
+ `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting支付配置主键',
+ `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
+ `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为DEFAULT_PLATFORM_COMMISSION',
+ `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
+ `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或历史渠道子商户标识快照',
+ `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
+ `app_auth_token` varchar(512) DEFAULT NULL COMMENT '支付宝第三方代调用授权令牌;仅服务端渠道调用使用',
+ `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
+ `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
+ `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
+ `fz_ratio` decimal(6,3) NOT NULL DEFAULT '0.000' COMMENT '平台抽佣比例,单位百分比,范围0到100,精确到0.001%',
+ `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝允许为空',
+ `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
+ `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
+ `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置默认关系:0否、1是',
+ `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
+ `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
+ `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
+ `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
+ `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
+ `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
+ `create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_mch_fz_relation_identity`
+ (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
+ `receiver_type`, `receiver_account`, `relation_scene`),
+ KEY `idx_third_mch_fz_relation_default`
+ (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
+ KEY `idx_third_mch_fz_relation_task`
+ (`status`, `is_default`, `update_time`, `id`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '商户第三方支付分账关系';
+
+-- 四、旧环境补齐关系引用字段。每个字段独立判断,避免一个缺表/缺列阻断后续迁移。
+SET @task_relation_column_exists = (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @task_relation_column_sql = IF(
+ @task_relation_column_exists = 0,
+ 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''third_mch_fz_relation主键''',
+ 'SELECT 1'
+);
+PREPARE task_relation_column_stmt FROM @task_relation_column_sql;
+EXECUTE task_relation_column_stmt;
+DEALLOCATE PREPARE task_relation_column_stmt;
+
+SET @pay_relation_column_exists = (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @pay_order_table_exists = (
+ SELECT COUNT(1) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order'
+);
+SET @pay_relation_column_sql = IF(
+ @pay_order_table_exists = 1 AND @pay_relation_column_exists = 0,
+ 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键''',
+ 'SELECT 1'
+);
+PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
+EXECUTE pay_relation_column_stmt;
+DEALLOCATE PREPARE pay_relation_column_stmt;
+
+SET @relation_auth_column_exists = (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'app_auth_token'
+);
+SET @relation_auth_column_sql = IF(
+ @relation_auth_column_exists = 0,
+ 'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `app_auth_token` varchar(512) DEFAULT NULL COMMENT ''支付宝第三方代调用授权令牌;仅服务端渠道调用使用''',
+ 'SELECT 1'
+);
+PREPARE relation_auth_column_stmt FROM @relation_auth_column_sql;
+EXECUTE relation_auth_column_stmt;
+DEALLOCATE PREPARE relation_auth_column_stmt;
+
+SET @relation_ratio_column_exists = (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME = 'fz_ratio'
+);
+SET @relation_ratio_column_sql = IF(
+ @relation_ratio_column_exists = 0,
+ 'ALTER TABLE `third_mch_fz_relation` ADD COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%''',
+ 'ALTER TABLE `third_mch_fz_relation` MODIFY COLUMN `fz_ratio` decimal(6,3) NOT NULL DEFAULT ''0.000'' COMMENT ''平台抽佣比例,单位百分比,范围0到100,精确到0.001%'''
+);
+PREPARE relation_ratio_column_stmt FROM @relation_ratio_column_sql;
+EXECUTE relation_ratio_column_stmt;
+DEALLOCATE PREPARE relation_ratio_column_stmt;
+
+-- 五、补齐任务和业务扫描索引。索引已存在时不重复创建。
+SET @index_ddl = IF(EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_payment_task' AND INDEX_NAME = 'idx_third_payment_task_relation'), 'SELECT 1', 'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+
+SET @pay_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'del_flag', 'pay_platform_type', 'update_time', 'id'));
+SET @pay_fz_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'enable_fz', 'del_flag', 'pay_time', 'id'));
+SET @pay_callback_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'is_biz_callback', 'del_flag', 'update_time', 'id'));
+SET @pay_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 5 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('status', 'biz_type', 'del_flag', 'create_time', 'id'));
+SET @pay_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
+SET @pay_relation_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 2 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND COLUMN_NAME IN ('tenant_id', 'fz_relation_id'));
+SET @fz_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
+SET @fz_tenant_no_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 3 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND COLUMN_NAME IN ('tenant_id', 'pay_no', 'del_flag'));
+SET @aggregate_task_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'update_time', 'id'));
+SET @aggregate_close_scan_ready = (SELECT COUNT(DISTINCT COLUMN_NAME) = 4 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND COLUMN_NAME IN ('status', 'del_flag', 'create_time', 'id'));
+
+SET @index_ddl = IF(@pay_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_task_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@pay_fz_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@pay_callback_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_callback_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@pay_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_close_scan'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@pay_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_tenant_pay_no'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@pay_relation_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_pay_order' AND INDEX_NAME = 'idx_third_pay_order_fz_relation'), 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@fz_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_task_scan'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@fz_tenant_no_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_fz_order' AND INDEX_NAME = 'idx_third_fz_order_tenant_pay_no'), 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@aggregate_task_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_task_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+SET @index_ddl = IF(@aggregate_close_scan_ready = 1 AND NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'third_aggregate_pay_session' AND INDEX_NAME = 'idx_third_aggregate_close_scan'), 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)', 'SELECT 1');
+PREPARE index_stmt FROM @index_ddl; EXECUTE index_stmt; DEALLOCATE PREPARE index_stmt;
+
+-- 六、迁移钱包历史配置。只有关系完整、JSON合法且数据已核对时,才清理旧配置键和旧比例列。
+SET @wallet_table_exists = (
+ SELECT COUNT(1) FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting'
+);
+SET @legacy_ratio_column_exists = (
+ SELECT COUNT(1) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = 'pjcloudx_wallet' AND TABLE_NAME = 'mch_pay_setting' AND COLUMN_NAME = 'fz_ratio'
+);
+SET @legacy_invalid_json_count = 0;
+SET @legacy_invalid_json_sql = IF(
+ @wallet_table_exists = 1,
+ 'SELECT COUNT(1) INTO @legacy_invalid_json_count FROM `pjcloudx_wallet`.`mch_pay_setting` WHERE platform_type IN (''10'', ''20'') AND setting_json IS NOT NULL AND TRIM(setting_json) <> '''' AND JSON_VALID(setting_json) = 0',
+ 'SELECT 1'
+);
+PREPARE legacy_invalid_json_stmt FROM @legacy_invalid_json_sql;
+EXECUTE legacy_invalid_json_stmt;
+DEALLOCATE PREPARE legacy_invalid_json_stmt;
+
+SET @legacy_channel_update_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
+ 'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.channel_sub_mch_id = CASE WHEN NULLIF(TRIM(r.channel_sub_mch_id), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') ELSE r.channel_sub_mch_id END, r.account_key = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.account_key), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') ELSE r.account_key END, r.app_auth_token = CASE WHEN s.platform_type = ''20'' AND NULLIF(TRIM(r.app_auth_token), '''') IS NULL THEN NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') ELSE r.app_auth_token END WHERE s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND r.del_flag = ''0'' AND r.is_default = ''1''',
+ 'SELECT 1'
+);
+PREPARE legacy_channel_update_stmt FROM @legacy_channel_update_sql;
+EXECUTE legacy_channel_update_stmt;
+DEALLOCATE PREPARE legacy_channel_update_stmt;
+
+SET @legacy_ratio_update_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
+ 'UPDATE `pjcloudx_thirdparty`.`third_mch_fz_relation` r JOIN `pjcloudx_wallet`.`mch_pay_setting` s ON s.id = r.pay_setting_id AND s.mch_id = r.mch_id AND s.tenant_id = r.tenant_id SET r.fz_ratio = LEAST(GREATEST(COALESCE(s.fz_ratio, 0), 0), 100) WHERE s.del_flag = ''0'' AND r.del_flag = ''0'' AND r.is_default = ''1'' AND COALESCE(s.fz_ratio, 0) > 0 AND COALESCE(r.fz_ratio, 0) = 0',
+ 'SELECT 1'
+);
+PREPARE legacy_ratio_update_stmt FROM @legacy_ratio_update_sql;
+EXECUTE legacy_ratio_update_stmt;
+DEALLOCATE PREPARE legacy_ratio_update_stmt;
+
+SET @legacy_channel_unmigrated_count = 0;
+SET @legacy_channel_unmigrated_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0,
+ 'SELECT COUNT(1) INTO @legacy_channel_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE s.del_flag = ''0'' AND s.platform_type IN (''10'', ''20'') AND JSON_VALID(s.setting_json) = 1 AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NOT NULL OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.platform_type = s.platform_type AND r.is_default = ''1'' AND r.del_flag = ''0'' AND (NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'') IS NULL OR r.channel_sub_mch_id = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.subMchid''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'') IS NULL OR r.account_key = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.accountKey''))), ''''), ''null'')) AND (s.platform_type <> ''20'' OR NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'') IS NULL OR r.app_auth_token = NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(s.setting_json, ''$.appAuthToken''))), ''''), ''null'')))',
+ 'SELECT 1'
+);
+PREPARE legacy_channel_unmigrated_stmt FROM @legacy_channel_unmigrated_sql;
+EXECUTE legacy_channel_unmigrated_stmt;
+DEALLOCATE PREPARE legacy_channel_unmigrated_stmt;
+
+SET @legacy_ratio_unmigrated_count = 0;
+SET @legacy_ratio_unmigrated_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1,
+ 'SELECT COUNT(1) INTO @legacy_ratio_unmigrated_count FROM `pjcloudx_wallet`.`mch_pay_setting` s WHERE COALESCE(s.fz_ratio, 0) > 0 AND s.del_flag = ''0'' AND NOT EXISTS (SELECT 1 FROM `pjcloudx_thirdparty`.`third_mch_fz_relation` r WHERE r.tenant_id = s.tenant_id AND r.mch_id = s.mch_id AND r.pay_setting_id = s.id AND r.is_default = ''1'' AND r.fz_ratio > 0 AND r.del_flag = ''0'')',
+ 'SELECT 1'
+);
+PREPARE legacy_ratio_unmigrated_stmt FROM @legacy_ratio_unmigrated_sql;
+EXECUTE legacy_ratio_unmigrated_stmt;
+DEALLOCATE PREPARE legacy_ratio_unmigrated_stmt;
+
+SET @legacy_json_cleanup_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
+ 'UPDATE `pjcloudx_wallet`.`mch_pay_setting` SET setting_json = CASE WHEN JSON_LENGTH(JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'')) = 0 THEN NULL ELSE JSON_REMOVE(setting_json, ''$.subMchid'', ''$.accountKey'', ''$.appAuthToken'') END WHERE platform_type IN (''10'', ''20'') AND JSON_VALID(setting_json) = 1',
+ 'SELECT 1'
+);
+PREPARE legacy_json_cleanup_stmt FROM @legacy_json_cleanup_sql;
+EXECUTE legacy_json_cleanup_stmt;
+DEALLOCATE PREPARE legacy_json_cleanup_stmt;
+
+SET @drop_legacy_ratio_sql = IF(
+ @wallet_table_exists = 1 AND @legacy_ratio_column_exists = 1 AND @legacy_invalid_json_count = 0 AND @legacy_channel_unmigrated_count = 0 AND @legacy_ratio_unmigrated_count = 0,
+ 'ALTER TABLE `pjcloudx_wallet`.`mch_pay_setting` DROP COLUMN `fz_ratio`',
+ 'SELECT 1'
+);
+PREPARE drop_legacy_ratio_stmt FROM @drop_legacy_ratio_sql;
+EXECUTE drop_legacy_ratio_stmt;
+DEALLOCATE PREPARE drop_legacy_ratio_stmt;
+
+-- 七、输出迁移结果。出现BLOCKED时不要上线新代码,先处理对应数据或数据库前置问题。
+SET @required_table_count = (
+ SELECT COUNT(DISTINCT TABLE_NAME)
+ FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME IN ('third_aggregate_pay_session', 'third_payment_task',
+ 'third_payment_task_attempt', 'third_mch_fz_relation',
+ 'third_pay_order', 'third_fz_order')
+);
+SET @required_column_count = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND ((TABLE_NAME = 'third_mch_fz_relation' AND COLUMN_NAME IN ('app_auth_token', 'fz_ratio'))
+ OR (TABLE_NAME = 'third_payment_task' AND COLUMN_NAME = 'fz_relation_id')
+ OR (TABLE_NAME = 'third_pay_order' AND COLUMN_NAME = 'fz_relation_id'))
+);
+SET @skipped_scan_index_count =
+ (@pay_task_scan_ready = 0) + (@pay_fz_scan_ready = 0) + (@pay_callback_scan_ready = 0)
+ + (@pay_close_scan_ready = 0) + (@pay_tenant_no_ready = 0) + (@pay_relation_ready = 0)
+ + (@fz_task_scan_ready = 0) + (@fz_tenant_no_ready = 0)
+ + (@aggregate_task_scan_ready = 0) + (@aggregate_close_scan_ready = 0);
+
+SELECT CASE
+ WHEN @required_table_count <> 6 THEN 'BLOCKED_REQUIRED_THIRD_TABLE_NOT_FOUND'
+ WHEN @required_column_count <> 4 THEN 'BLOCKED_REQUIRED_THIRD_COLUMN_NOT_FOUND'
+ WHEN @skipped_scan_index_count > 0 THEN 'BLOCKED_SCAN_INDEX_SOURCE_COLUMN_NOT_FOUND'
+ WHEN @wallet_table_exists = 0 THEN 'BLOCKED_WALLET_TABLE_NOT_FOUND'
+ WHEN @legacy_invalid_json_count > 0 THEN 'BLOCKED_INVALID_SETTING_JSON'
+ WHEN @legacy_channel_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_CHANNEL_CONFIG'
+ WHEN @legacy_ratio_unmigrated_count > 0 THEN 'BLOCKED_UNMIGRATED_POSITIVE_RATIO'
+ ELSE 'SUCCESS_THIRD_PAYMENT_SCHEMA_MIGRATED'
+ END AS migration_status,
+ @legacy_invalid_json_count AS invalid_setting_json_count,
+ @legacy_channel_unmigrated_count AS unmigrated_channel_config_count,
+ @legacy_ratio_unmigrated_count AS unmigrated_positive_ratio_count,
+ @required_table_count AS required_third_table_count,
+ @required_column_count AS required_third_column_count,
+ @skipped_scan_index_count AS skipped_scan_index_count;
+
+SELECT table_name, column_name, column_type
+FROM information_schema.COLUMNS
+WHERE table_schema = DATABASE()
+ AND ((table_name = 'third_mch_fz_relation' AND column_name IN ('app_auth_token', 'fz_ratio'))
+ OR (table_name = 'third_payment_task' AND column_name = 'fz_relation_id')
+ OR (table_name = 'third_pay_order' AND column_name = 'fz_relation_id'))
+ORDER BY table_name, ordinal_position;
diff --git a/db/20260827_add_third_mch_fz_relation_enable_fz.sql b/db/20260827_add_third_mch_fz_relation_enable_fz.sql
new file mode 100644
index 00000000..fb086bcd
--- /dev/null
+++ b/db/20260827_add_third_mch_fz_relation_enable_fz.sql
@@ -0,0 +1,3 @@
+-- 为商户渠道关系增加分账开关;默认开启以保持历史关系原有的分账行为。
+ALTER TABLE `pjcloudx_thirdparty`.`third_mch_fz_relation`
+ ADD COLUMN `enable_fz` char(1) NOT NULL DEFAULT '1' COMMENT '是否开启分账:0仅提供渠道商户路由,1绑定接收方并执行平台抽佣' AFTER `fz_ratio`;
diff --git a/db/20260829_add_mch_manage_top_up_config.sql b/db/20260829_add_mch_manage_top_up_config.sql
new file mode 100644
index 00000000..f33e566f
--- /dev/null
+++ b/db/20260829_add_mch_manage_top_up_config.sql
@@ -0,0 +1,4 @@
+-- 为商户增加钱包充值配置;历史商户保持为空,表示不限制可充值的人员身份。
+ALTER TABLE `pjcloudx_wallet`.`mch_manage`
+ ADD COLUMN `top_up_config` json DEFAULT NULL
+ COMMENT '钱包充值配置,JSON对象;userIdentityTypeList为空表示不限制人员身份' AFTER `table_reservation_conf`;
diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql
index 462e9448..deffa17b 100644
--- a/db/800pjcloudx_wallet.sql
+++ b/db/800pjcloudx_wallet.sql
@@ -164,9 +164,6 @@ CREATE TABLE mch_pay_setting
use_scope varchar(256) NOT NULL COMMENT '使用范围(直接支付,钱包充值)',
setting_json text DEFAULT NULL COMMENT '支付json配置',
- fz_ratio int NOT NULL COMMENT '分账比例(最高100%即分给对方商户的%)',
-
-
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java
index 69767655..44157f29 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java
@@ -6,7 +6,10 @@ import lombok.Data;
import java.math.BigDecimal;
/**
- * 每日菜谱规范导入模板行
+ * 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
+ *
列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、菜品SPU ID、填写说明
+ *
配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU
+ *
菜谱ID / 菜品SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度导致再导入报「不存在」。
*/
@Data
public class CanteenDailyMenuImportExcel {
@@ -23,18 +26,42 @@ public class CanteenDailyMenuImportExcel {
@ExcelProperty(value = "菜品名", index = 3)
private String dishName;
- @ExcelProperty(value = "配料", index = 4)
- private String ingredients;
+ /** 分类名称;新建菜品时优先按此匹配,匹配不到再用默认分类 */
+ @ExcelProperty(value = "分类", index = 4)
+ private String categoryName;
/** 菜品自身单价;不填默认 0 */
- @ExcelProperty(value = "菜品单价(元)", index = 5)
+ @ExcelProperty(value = "单价(元)", index = 5)
private BigDecimal dishPriceYuan;
- /** 套餐售价(套餐首行填写,可与套餐名合并);单品可留空,用「菜品单价」 */
+ /** 套餐售价(套餐首行填写);单品可留空 */
@ExcelProperty(value = "套餐价(元)", index = 6)
private BigDecimal priceYuan;
+ /**
+ * 可售库存。单品填本行;套餐填首行(挂在套餐 SPU 上)。
+ * 不填:新建默认 9999,已存在则不改。
+ */
+ @ExcelProperty(value = "库存", index = 7)
+ private Integer stock;
+
+ @ExcelProperty(value = "配料", index = 8)
+ private String ingredients;
+
+ /**
+ * 每日菜谱记录 ID(文本)。导入时有则更新,无则新增。
+ * 勿改成数字格式,否则 Excel 会丢精度。
+ */
+ @ExcelProperty(value = "菜谱ID", index = 9)
+ private String id;
+
+ /**
+ * 关联菜品/套餐 SPU ID(文本,仅导出参考,导入不依赖此列)。
+ */
+ @ExcelProperty(value = "菜品SPU ID", index = 10)
+ private String spuId;
+
/** 仅示例说明用,导入逻辑忽略 */
- @ExcelProperty(value = "填写说明", index = 7)
+ @ExcelProperty(value = "填写说明", index = 11)
private String tip;
}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuBatchPicDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuBatchPicDTO.java
new file mode 100644
index 00000000..1313e19b
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuBatchPicDTO.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.canteen.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 菜品列表:批量赋值封面图
+ */
+@Data
+@Schema(description = "批量赋值菜品封面图")
+public class CanteenSpuBatchPicDTO {
+
+ @NotEmpty
+ @Schema(description = "菜品 SPU ID 列表")
+ private List ids;
+
+ @NotBlank
+ @Schema(description = "封面图地址(上传或图库)")
+ private String picUrl;
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
index 2bf372e2..577bd995 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
@@ -109,4 +109,9 @@ public class CanteenDailyMenuEntity extends Model {
@TableField(exist = false)
private List spuIds;
+ /** 多餐段排菜:优先于 hourType;为空则回退单餐段 hourType */
+ @Schema(description = "归属时间列表 1早餐 2午餐 3晚餐")
+ @TableField(exist = false)
+ private List hourTypes;
+
}
\ No newline at end of file
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java
index 15ed0133..b7664650 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java
@@ -210,6 +210,13 @@ public class CanteenSpuEntity extends Model {
@Schema(description="是否显示")
private String isShow;
+ /**
+ * 列表筛选:餐段(按分类 categoryType 过滤,非表字段)
+ */
+ @TableField(exist = false)
+ @Schema(description = "餐段筛选 1早餐 2午餐 3晚餐")
+ private Integer hourType;
+
/**
* 套餐组成(仅 spuType=2 时使用)
*/
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java
new file mode 100644
index 00000000..9acc862b
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java
@@ -0,0 +1,55 @@
+package com.bsd.pjcloud.canteen.vo;
+
+import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * PC 管理端每日菜谱列表行
+ */
+@Data
+public class DailyMenuAdminVO {
+
+ private Long id;
+
+ private LocalDate startTime;
+
+ /** 星期文案,如 周四 */
+ private String weekdayLabel;
+
+ private Integer hourType;
+
+ private Long merchantId;
+
+ private Long spuId;
+
+ private String name;
+
+ /** 套餐标签(canteen_spu.introduction),导出套餐名优先用此字段,避免名称中的 `-` 被截断 */
+ private String introduction;
+
+ private String picUrl;
+
+ private Long categoryId;
+
+ private String categoryName;
+
+ /** 配料(来自菜品 description) */
+ private String ingredients;
+
+ private Integer sort;
+
+ private Integer stock;
+
+ /** 当前剩余可售库存,与 stock 同源 */
+ private Integer remainStock;
+
+ private Integer price;
+
+ private Integer spuType;
+
+ private List comboItems;
+
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java
index c7fff071..93bfea5d 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java
@@ -86,6 +86,27 @@ public class CanteenDailyMenuController {
return dailyMenuImportHelper.templateRows();
}
+ /**
+ * 导出日期段内的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID)
+ */
+ @Operation(summary = "导出菜谱(可再导入更新)", description = "按 startDate~endDate 导出,最多 62 天")
+ @ResponseExcel(name = "每日菜谱", writeHandler = CanteenDailyMenuTemplateMergeHandler.class)
+ @GetMapping("/import/export")
+ @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_export')")
+ public List exportImportExcel(@RequestParam("merchantId") Long merchantId,
+ @RequestParam(value = "startDate", required = false) String startDate,
+ @RequestParam(value = "endDate", required = false) String endDate,
+ @RequestParam(value = "bizDate", required = false) String bizDate) {
+ String rangeStart = StrUtil.blankToDefault(startDate, bizDate);
+ String rangeEnd = StrUtil.blankToDefault(endDate, bizDate);
+ List rows = dailyMenuImportHelper.exportRows(merchantId, rangeStart, rangeEnd);
+ // @ResponseExcel 空 List 不会落盘,导致前端收到空 blob;至少写一行空对象以输出表头
+ if (rows == null || rows.isEmpty()) {
+ return List.of(new CanteenDailyMenuImportExcel());
+ }
+ return rows;
+ }
+
/**
* 通过id查询每日菜谱
*
@@ -149,7 +170,7 @@ public class CanteenDailyMenuController {
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_add')")
public R importExcel(@RequestParam("file") MultipartFile file,
@RequestParam("merchantId") Long merchantId,
- @RequestParam("categoryId") Long categoryId,
+ @RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "replaceDay", defaultValue = "true") boolean replaceDay) throws Exception {
try {
return dailyMenuImportHelper.importExcel(file, merchantId, categoryId, replaceDay);
@@ -157,6 +178,15 @@ public class CanteenDailyMenuController {
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
+ catch (org.springframework.transaction.UnexpectedRollbackException ex) {
+ // 兜底:避免把 rollback-only 原文甩给前端
+ Throwable root = ex.getMostSpecificCause() != null ? ex.getMostSpecificCause() : ex;
+ String tip = root.getMessage();
+ if (tip != null && tip.contains("rollback-only")) {
+ tip = "导入失败并已回滚,请检查套餐组成、分类、菜名是否完整后重试";
+ }
+ return R.failed(tip);
+ }
}
/**
@@ -228,6 +258,22 @@ public class CanteenDailyMenuController {
return R.ok(canteenDailyMenuService.selectTimeFood(time, merchantId, categoryId));
}
+ /**
+ * PC 管理端每日菜谱列表(含排序、库存、机器可用、预约/核销/现场购买统计)
+ */
+ @Operation(summary = "管理端每日菜谱列表")
+ @GetMapping("/admin/list")
+ @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')")
+ public R> selectAdminList(
+ @RequestParam("time") String time,
+ @RequestParam("merchantId") Long merchantId,
+ @RequestParam(value = "categoryId", required = false) Long categoryId) {
+ if (ObjectUtil.isEmpty(time)) {
+ time = DateUtil.today();
+ }
+ return R.ok(canteenDailyMenuService.selectAdminList(time, merchantId, categoryId));
+ }
+
// ---------AD------------
/**
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
index c9ac9e02..a2b18a94 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.constant.StockTypeEnum;
import com.bsd.pjcloud.canteen.dto.CanteenDailySpuDTO;
+import com.bsd.pjcloud.canteen.dto.CanteenSpuBatchPicDTO;
import com.bsd.pjcloud.canteen.dto.CanteenSpuExportQuery;
import com.bsd.pjcloud.canteen.dto.CanteenSpuImportExcel;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
@@ -209,6 +210,27 @@ public class CanteenSpuController {
}
}
+ /**
+ * 批量赋值菜品封面图(导入无图后,从图库或上传一张图批量套用)
+ */
+ @Operation(summary = "批量赋值菜品封面图", description = "批量赋值菜品封面图")
+ @SysLog("批量赋值菜品封面图")
+ @PutMapping("/batchPic")
+ @PreAuthorize("@pms.hasPermission('canteen_canteenSpu_edit')")
+ public R batchPic(@RequestBody CanteenSpuBatchPicDTO dto) {
+ if (CollUtil.isEmpty(dto.getIds())) {
+ return R.failed("请选择菜品");
+ }
+ if (StrUtil.isBlank(dto.getPicUrl())) {
+ return R.failed("请选择或上传封面图");
+ }
+ String picUrl = dto.getPicUrl().trim();
+ boolean ok = canteenSpuService.update(Wrappers.lambdaUpdate()
+ .set(CanteenSpuEntity::getPicUrl, picUrl)
+ .in(CanteenSpuEntity::getId, dto.getIds()));
+ return ok ? R.ok(null, "已更新 " + dto.getIds().size() + " 道菜封面") : R.failed("更新失败");
+ }
+
/**
* 修改菜品spu
*
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java
index a24bc362..ade33a4f 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java
@@ -4,6 +4,7 @@ import com.alibaba.excel.write.handler.WorkbookWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
@@ -11,12 +12,21 @@ import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
/**
- * 每日菜谱导入模板:套餐多行合并日期/餐段/套餐名/套餐价/说明;菜品单价不合并。
- * 列:0日期 1餐段 2套餐名 3菜品名 4配料 5菜品单价 6套餐价 7填写说明
- *
数据行(表头第 0 行):1-3 A套餐,4-5 B套餐,6 午餐单品,7-8 早餐单品
+ * 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、说明。
+ *
列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7库存 8配料 9菜谱ID 10SPU ID 11填写说明
*/
public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandler {
+ private static final int COL_DATE = 0;
+ private static final int COL_MEAL = 1;
+ private static final int COL_COMBO = 2;
+ private static final int COL_COMBO_PRICE = 6;
+ private static final int COL_STOCK = 7;
+ private static final int COL_INGREDIENTS = 8;
+ private static final int COL_ID = 9;
+ private static final int COL_SPU_ID = 10;
+ private static final int COL_TIP = 11;
+
@Override
public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) {
Sheet sheet = writeWorkbookHolder.getWorkbook().getSheetAt(0);
@@ -24,52 +34,257 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
return;
}
- merge(sheet, 1, 8, 0, 0);
- merge(sheet, 1, 6, 1, 1);
- merge(sheet, 7, 8, 1, 1);
- // A 套餐:套餐名、套餐价、填写说明(菜品单价列不合并)
- merge(sheet, 1, 3, 2, 2);
- merge(sheet, 1, 3, 6, 6);
- merge(sheet, 1, 3, 7, 7);
- // B 套餐
- merge(sheet, 4, 5, 2, 2);
- merge(sheet, 4, 5, 6, 6);
- merge(sheet, 4, 5, 7, 7);
+ int lastDataRow = sheet.getLastRowNum();
+ if (lastDataRow < 1) {
+ setColumnWidths(sheet);
+ return;
+ }
CellStyle center = writeWorkbookHolder.getWorkbook().createCellStyle();
center.setAlignment(HorizontalAlignment.CENTER);
center.setVerticalAlignment(VerticalAlignment.CENTER);
- applyStyle(sheet, center, new int[] { 0, 1, 2, 6, 7 }, 1, 8);
+ mergeByRuns(sheet, center);
+ setColumnWidths(sheet);
+ }
+
+ /**
+ * 向下填充后,按连续相同值合并:日期 / 餐段 / 套餐块(套餐名+套餐价+ID+SPU+说明)
+ */
+ private static void mergeByRuns(Sheet sheet, CellStyle center) {
+ int lastRow = sheet.getLastRowNum();
+ String[] dates = new String[lastRow + 1];
+ String[] meals = new String[lastRow + 1];
+ String[] combos = new String[lastRow + 1];
+ String[] menuIds = new String[lastRow + 1];
+ boolean[] comboGroup = new boolean[lastRow + 1];
+
+ String lastDate = null;
+ String lastMeal = null;
+ String lastCombo = null;
+ String lastMenuId = null;
+ boolean inCombo = false;
+
+ for (int r = 1; r <= lastRow; r++) {
+ String date = cellText(sheet, r, COL_DATE);
+ String meal = cellText(sheet, r, COL_MEAL);
+ String combo = cellText(sheet, r, COL_COMBO);
+ String menuId = cellText(sheet, r, COL_ID);
+
+ if (isBlank(date)) {
+ date = lastDate;
+ }
+ else {
+ lastDate = date;
+ }
+ if (isBlank(meal)) {
+ meal = lastMeal;
+ }
+ else {
+ lastMeal = meal;
+ }
+
+ if (!isBlank(combo)) {
+ if (isSingleCombo(combo)) {
+ inCombo = false;
+ lastCombo = null;
+ lastMenuId = null;
+ combo = "无";
+ }
+ else {
+ inCombo = true;
+ lastCombo = combo;
+ if (!isBlank(menuId)) {
+ lastMenuId = menuId;
+ }
+ }
+ }
+ else if (inCombo) {
+ combo = lastCombo;
+ if (isBlank(menuId)) {
+ menuId = lastMenuId;
+ }
+ else {
+ // 新菜谱 ID 出现,视为新套餐组起点
+ lastMenuId = menuId;
+ }
+ }
+ else {
+ combo = "无";
+ lastMenuId = null;
+ }
+
+ dates[r] = date;
+ meals[r] = meal;
+ combos[r] = combo;
+ menuIds[r] = menuId;
+ comboGroup[r] = inCombo && !isSingleCombo(combo);
+ }
+
+ mergeEqualRuns(sheet, center, lastRow, dates, COL_DATE, null, null);
+ mergeEqualRuns(sheet, center, lastRow, meals, COL_MEAL, dates, null);
+
+ int runStart = 1;
+ for (int r = 2; r <= lastRow + 1; r++) {
+ boolean same = r <= lastRow
+ && comboGroup[runStart]
+ && comboGroup[r]
+ && eq(dates[r], dates[runStart])
+ && eq(meals[r], meals[runStart])
+ && eq(combos[r], combos[runStart])
+ && eq(menuIds[r], menuIds[runStart]);
+ if (!same) {
+ if (comboGroup[runStart] && runStart <= lastRow) {
+ int endRow = Math.min(r - 1, lastRow);
+ mergeRange(sheet, runStart, endRow, COL_COMBO, center);
+ mergeRange(sheet, runStart, endRow, COL_COMBO_PRICE, center);
+ mergeRange(sheet, runStart, endRow, COL_STOCK, center);
+ mergeRange(sheet, runStart, endRow, COL_INGREDIENTS, center);
+ mergeRange(sheet, runStart, endRow, COL_ID, center);
+ mergeRange(sheet, runStart, endRow, COL_SPU_ID, center);
+ mergeRange(sheet, runStart, endRow, COL_TIP, center);
+ }
+ runStart = r;
+ }
+ }
+ }
+
+ private static void mergeEqualRuns(Sheet sheet, CellStyle center, int lastRow, String[] values, int col,
+ String[] key1, String[] key2) {
+ int runStart = 1;
+ for (int r = 2; r <= lastRow + 1; r++) {
+ boolean same = r <= lastRow
+ && !isBlank(values[runStart])
+ && eq(values[r], values[runStart])
+ && (key1 == null || eq(key1[r], key1[runStart]))
+ && (key2 == null || eq(key2[r], key2[runStart]));
+ if (!same) {
+ mergeRange(sheet, runStart, Math.min(r - 1, lastRow), col, center);
+ runStart = r;
+ }
+ }
+ }
+
+ private static void mergeRange(Sheet sheet, int firstRow, int lastRow, int col, CellStyle center) {
+ if (firstRow > lastRow) {
+ return;
+ }
+ if (firstRow == lastRow) {
+ Cell cell = getCell(sheet, firstRow, col);
+ if (cell != null) {
+ cell.setCellStyle(center);
+ }
+ return;
+ }
+ sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, col, col));
+ for (int r = firstRow; r <= lastRow; r++) {
+ Cell cell = getCell(sheet, r, col);
+ if (cell != null) {
+ cell.setCellStyle(center);
+ }
+ }
+ }
+
+ private static Cell getCell(Sheet sheet, int rowIdx, int col) {
+ Row row = sheet.getRow(rowIdx);
+ return row == null ? null : row.getCell(col);
+ }
+
+ private static String cellText(Sheet sheet, int rowIdx, int col) {
+ Cell cell = getCell(sheet, rowIdx, col);
+ if (cell == null) {
+ return null;
+ }
+ CellType type = cell.getCellType();
+ if (type == CellType.STRING) {
+ return trimToNull(cell.getStringCellValue());
+ }
+ if (type == CellType.NUMERIC) {
+ double n = cell.getNumericCellValue();
+ if (n == Math.rint(n)) {
+ return String.valueOf((long) n);
+ }
+ return String.valueOf(n);
+ }
+ if (type == CellType.BOOLEAN) {
+ return String.valueOf(cell.getBooleanCellValue());
+ }
+ if (type == CellType.FORMULA) {
+ try {
+ return trimToNull(cell.getStringCellValue());
+ }
+ catch (Exception e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private static String trimToNull(String s) {
+ if (s == null) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.isBlank();
+ }
+
+ private static boolean eq(String a, String b) {
+ if (a == null) {
+ return b == null;
+ }
+ return a.equals(b);
+ }
+
+ private static boolean isSingleCombo(String combo) {
+ if (isBlank(combo)) {
+ return true;
+ }
+ String c = stripMarkerWrap(combo.trim());
+ if (c.isEmpty()) {
+ return true;
+ }
+ if ("无".equals(c) || "-".equals(c) || "无套餐".equals(c) || "不套餐".equals(c)) {
+ return true;
+ }
+ return c.startsWith("单品") || c.startsWith("单点");
+ }
+
+ private static String stripMarkerWrap(String s) {
+ String t = s;
+ while (t.length() >= 2) {
+ char a = t.charAt(0);
+ char b = t.charAt(t.length() - 1);
+ boolean wrap = (a == '(' && b == ')')
+ || (a == '(' && b == ')')
+ || (a == '【' && b == '】')
+ || (a == '[' && b == ']')
+ || (a == '「' && b == '」')
+ || (a == '"' && b == '"')
+ || (a == '\'' && b == '\'');
+ if (!wrap) {
+ break;
+ }
+ t = t.substring(1, t.length() - 1).trim();
+ }
+ return t;
+ }
+
+ private static void setColumnWidths(Sheet sheet) {
sheet.setColumnWidth(0, 12 * 256);
sheet.setColumnWidth(1, 8 * 256);
sheet.setColumnWidth(2, 10 * 256);
sheet.setColumnWidth(3, 16 * 256);
- sheet.setColumnWidth(4, 22 * 256);
- sheet.setColumnWidth(5, 14 * 256);
+ sheet.setColumnWidth(4, 12 * 256);
+ sheet.setColumnWidth(5, 12 * 256);
sheet.setColumnWidth(6, 12 * 256);
- sheet.setColumnWidth(7, 48 * 256);
- }
-
- private static void merge(Sheet sheet, int firstRow, int lastRow, int firstCol, int lastCol) {
- if (firstRow == lastRow && firstCol == lastCol) {
- return;
- }
- sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, firstCol, lastCol));
- }
-
- private static void applyStyle(Sheet sheet, CellStyle style, int[] cols, int firstRow, int lastRow) {
- for (int r = firstRow; r <= lastRow; r++) {
- Row row = sheet.getRow(r);
- if (row == null) {
- continue;
- }
- for (int c : cols) {
- Cell cell = row.getCell(c);
- if (cell != null) {
- cell.setCellStyle(style);
- }
- }
- }
+ sheet.setColumnWidth(7, 10 * 256);
+ sheet.setColumnWidth(8, 22 * 256);
+ sheet.setColumnWidth(9, 20 * 256);
+ sheet.setColumnWidth(10, 20 * 256);
+ sheet.setColumnWidth(11, 48 * 256);
}
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
index bbc60784..215f7cd2 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
+import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -44,4 +45,10 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper selectAdminList(@Param("time") String time, @Param("merchantId") Long merchantId,
+ @Param("categoryId") Long categoryId, DataScope dataScope);
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java
index 839ad25c..8e5a7f5c 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
+import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -69,4 +70,9 @@ public interface CanteenDailyMenuService extends IService selectAdminList(String time, Long merchantId, Long categoryId);
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java
index bdcdbfa9..4bc7963f 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java
@@ -3,14 +3,21 @@ package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.context.AnalysisContext;
+import com.alibaba.excel.read.listener.ReadListener;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportExcel;
+import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuComboItemEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
+import com.bsd.pjcloud.canteen.mapper.CanteenSpuComboItemMapper;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuService;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
+import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
+import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -24,10 +31,13 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
/**
@@ -39,37 +49,46 @@ public class CanteenDailyMenuImportHelper {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ /** 上传文件上限,防止大文件撑爆堆内存 */
+ private static final long MAX_FILE_BYTES = 5L * 1024 * 1024;
+
+ /** 数据行上限(不含表头);合并单元格场景仍需整表在内存,用行数硬顶防 OOM */
+ private static final int MAX_IMPORT_ROWS = 5000;
+
+ /** 排菜批量落库大小 */
+ private static final int MENU_BATCH_SIZE = 200;
+
private final CanteenSpuService canteenSpuService;
private final CanteenDailyMenuService canteenDailyMenuService;
private final CanteenMerchantScopeHelper merchantScopeHelper;
+ private final CanteenCategoryMapper canteenCategoryMapper;
+ private final CanteenSpuComboItemMapper canteenSpuComboItemMapper;
public List templateRows() {
List list = new ArrayList<>();
- // 午餐 A 套餐:套餐价 21;各菜可有自己的菜品单价
- list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "青莲仔排", "12", "21",
- "套餐价只填首行;菜品单价每行可填,不填默认0。套餐名/菜品名在菜品列表已存在则复用,不存在则自动新增"));
- list.add(row(null, null, null, "蒜苔炒腊肉", "蒜苔,腊肉,胡萝卜", "8", null, null));
- list.add(row(null, null, null, "清炒时蔬", "空心菜", "3", null, null));
- // 午餐 B 套餐
- list.add(row(null, null, "B套餐", "红烧肉", "五花肉", "10", "18",
+ list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "100",
+ "分类按名称匹配;套餐价/库存/配料只填首行。菜谱ID/SPU ID为文本勿改成数字,否则Excel会丢精度"));
+ list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", null, "8", null, null, null));
+ list.add(row(null, null, null, "清炒时蔬", "素菜", null, "3", null, null, null));
+ list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "80",
"换套餐名即另一套;名称已存在不新增"));
- list.add(row(null, null, null, "土豆丝", "土豆", "4", null, null));
- // 单品:套餐名「无」,单价写在菜品单价
- list.add(row(null, null, "无", "千张大骨汤", null, "0", null,
- "单品:套餐名填「无」;菜品已存在则复用"));
- list.add(row(null, "早餐", "无", "榨菜肉丝面", "榨菜,肉丝", "6.4", null,
- "早餐单品"));
- list.add(row(null, null, "无", "豆浆", null, "2", null, null));
+ list.add(row(null, null, null, "土豆丝", "素菜", null, "4", null, null, null));
+ list.add(row(null, null, "无", "千张大骨汤", "汤类", null, "0", null, "50",
+ "单品:套餐名填「无」「单品」「单点」或「单品-xxx/单点-xxx」;价格填单价列"));
+ list.add(row(null, "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30",
+ "单品-xxx / 单点-xxx 一律按单品,套餐价可留空"));
+ list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", null));
return list;
}
private static CanteenDailyMenuImportExcel row(String date, String meal, String combo, String dish,
- String ingredients, String dishPrice, String comboPrice, String tip) {
+ String categoryName, String ingredients, String dishPrice, String comboPrice, String stock, String tip) {
CanteenDailyMenuImportExcel r = new CanteenDailyMenuImportExcel();
r.setBizDate(date);
r.setMealName(meal);
r.setComboName(combo);
r.setDishName(dish);
+ r.setCategoryName(categoryName);
r.setIngredients(ingredients);
if (StrUtil.isNotBlank(dishPrice)) {
r.setDishPriceYuan(new BigDecimal(dishPrice));
@@ -77,43 +96,234 @@ public class CanteenDailyMenuImportHelper {
if (StrUtil.isNotBlank(comboPrice)) {
r.setPriceYuan(new BigDecimal(comboPrice));
}
+ if (StrUtil.isNotBlank(stock)) {
+ r.setStock(Integer.valueOf(stock));
+ }
r.setTip(tip);
return r;
}
+ /**
+ * 导出指定日期段的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID)
+ */
+ public List exportRows(Long merchantId, String startDate, String endDate) {
+ if (merchantId == null || StrUtil.isBlank(startDate) || StrUtil.isBlank(endDate)) {
+ return List.of();
+ }
+ LocalDate start = parseDate(startDate.trim());
+ LocalDate end = parseDate(endDate.trim());
+ if (start == null || end == null) {
+ return List.of();
+ }
+ if (end.isBefore(start)) {
+ LocalDate tmp = start;
+ start = end;
+ end = tmp;
+ }
+ long days = end.toEpochDay() - start.toEpochDay() + 1;
+ if (days > 62) {
+ throw new IllegalArgumentException("导出日期段不能超过 62 天");
+ }
+ List rows = new ArrayList<>();
+ for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
+ List menus = canteenDailyMenuService.selectAdminList(date.format(DATE_FMT), merchantId,
+ null);
+ if (CollUtil.isEmpty(menus)) {
+ continue;
+ }
+ for (DailyMenuAdminVO menu : menus) {
+ rows.addAll(toExportRows(menu));
+ }
+ }
+ return rows;
+ }
+
+ private List toExportRows(DailyMenuAdminVO menu) {
+ List rows = new ArrayList<>();
+ String dateStr = menu.getStartTime() != null ? menu.getStartTime().format(DATE_FMT) : "";
+ String meal = formatMeal(menu.getHourType());
+ boolean isCombo = menu.getSpuType() != null && menu.getSpuType() == 2
+ && CollUtil.isNotEmpty(menu.getComboItems());
+ if (isCombo) {
+ String comboLabel = resolveComboLabel(menu);
+ BigDecimal comboPrice = fenToYuan(menu.getPrice());
+ List items = menu.getComboItems();
+ Set exportedChildIds = new HashSet<>();
+ int written = 0;
+ for (OnlineSpuComboItemVO item : items) {
+ if (item == null) {
+ continue;
+ }
+ Long childId = item.getChildSpuId();
+ if (childId != null && !exportedChildIds.add(childId)) {
+ continue;
+ }
+ CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel();
+ if (written == 0) {
+ row.setId(menu.getId() == null ? null : String.valueOf(menu.getId()));
+ row.setSpuId(menu.getSpuId() == null ? null : String.valueOf(menu.getSpuId()));
+ row.setBizDate(dateStr);
+ row.setMealName(meal);
+ row.setComboName(comboLabel);
+ row.setPriceYuan(comboPrice);
+ row.setStock(menu.getStock());
+ row.setIngredients(toExcelIngredients(menu.getIngredients()));
+ }
+ // 每行都带分类,改菜名新建子菜时不丢分类
+ row.setCategoryName(menu.getCategoryName());
+ row.setDishName(StrUtil.blankToDefault(item.getName(), ""));
+ row.setDishPriceYuan(fenToYuan(item.getPrice()));
+ rows.add(row);
+ written++;
+ }
+ }
+ else {
+ CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel();
+ row.setId(menu.getId() == null ? null : String.valueOf(menu.getId()));
+ row.setSpuId(menu.getSpuId() == null ? null : String.valueOf(menu.getSpuId()));
+ row.setBizDate(dateStr);
+ row.setMealName(meal);
+ row.setComboName("无");
+ row.setDishName(menu.getName());
+ row.setCategoryName(menu.getCategoryName());
+ row.setDishPriceYuan(fenToYuan(menu.getPrice()));
+ row.setStock(menu.getStock());
+ row.setIngredients(toExcelIngredients(menu.getIngredients()));
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ /**
+ * 导出套餐名:优先 introduction(导入时写入的原始套餐标签,可含 -);
+ * 否则尝试按「套餐名-首道菜名」还原,避免把「套餐A-周二午」截成「套餐A」。
+ */
+ private static String resolveComboLabel(DailyMenuAdminVO menu) {
+ if (StrUtil.isNotBlank(menu.getIntroduction())) {
+ return menu.getIntroduction().trim();
+ }
+ String name = StrUtil.trim(menu.getName());
+ if (StrUtil.isBlank(name)) {
+ return "";
+ }
+ if (CollUtil.isNotEmpty(menu.getComboItems())) {
+ String firstDish = StrUtil.trim(menu.getComboItems().get(0).getName());
+ if (StrUtil.isNotBlank(firstDish)) {
+ String firstLine = firstDish.contains("\n") ? firstDish.substring(0, firstDish.indexOf('\n')).trim()
+ : firstDish;
+ for (String dishPart : new String[] { firstDish, firstLine }) {
+ if (StrUtil.isBlank(dishPart)) {
+ continue;
+ }
+ String suffix = "-" + dishPart;
+ if (name.endsWith(suffix) && name.length() > suffix.length()) {
+ return name.substring(0, name.length() - suffix.length());
+ }
+ }
+ }
+ }
+ return name;
+ }
+
+ private static String formatMeal(Integer hourType) {
+ if (hourType == null) {
+ return "";
+ }
+ return switch (hourType) {
+ case 1 -> "早餐";
+ case 2 -> "午餐";
+ default -> "晚餐";
+ };
+ }
+
+ /** dayHour 形如 2026-09-04|2 */
+ private static String describeDayHour(String dayHour) {
+ if (StrUtil.isBlank(dayHour) || !dayHour.contains("|")) {
+ return dayHour;
+ }
+ String[] p = dayHour.split("\\|", 2);
+ Integer hour = null;
+ try {
+ hour = Integer.parseInt(p[1]);
+ }
+ catch (Exception ignored) {
+ // ignore
+ }
+ return p[0] + " " + formatMeal(hour);
+ }
+
+ /** 普通校验错误:换行编号,便于前端弹窗阅读 */
+ private static String formatImportErrors(List errors) {
+ int show = Math.min(errors.size(), 20);
+ StringBuilder sb = new StringBuilder();
+ sb.append("导入校验未通过(").append(errors.size()).append(" 项):\n");
+ for (int i = 0; i < show; i++) {
+ sb.append(i + 1).append(". ").append(errors.get(i)).append('\n');
+ }
+ if (errors.size() > show) {
+ sb.append("…另有 ").append(errors.size() - show).append(" 项未列出");
+ }
+ return sb.toString().trim();
+ }
+
+ private static BigDecimal fenToYuan(Integer fen) {
+ if (fen == null) {
+ return null;
+ }
+ return BigDecimal.valueOf(fen).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
+ }
+
@Transactional(rollbackFor = Exception.class)
public R importExcel(MultipartFile file, Long merchantId, Long categoryId, boolean replaceDay)
throws IOException {
if (merchantId == null) {
return R.failed("请选择商户");
}
- if (categoryId == null) {
- return R.failed("请选择新建菜品默认分类");
- }
if (file == null || file.isEmpty()) {
return R.failed("请上传 Excel 文件");
}
- List rows = EasyExcel.read(file.getInputStream())
- .head(CanteenDailyMenuImportExcel.class)
- .sheet()
- .headRowNumber(1)
- .doReadSync();
+ if (file.getSize() > MAX_FILE_BYTES) {
+ return R.failed("文件过大,请控制在 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB 以内");
+ }
+
+ List rows;
+ try {
+ rows = readRowsStreaming(file);
+ }
+ catch (IllegalArgumentException ex) {
+ return R.failed(ex.getMessage());
+ }
if (CollUtil.isEmpty(rows)) {
return R.failed("导入数据为空");
}
normalizeMergedCells(rows);
Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId);
+ Map categoryMap = loadCategoryMap(merchantId);
- // groupKey = date|hourType|comboOrEmpty
- Map> groups = new LinkedHashMap<>();
List errors = new ArrayList<>();
- int line = 1;
+ List dataRows = new ArrayList<>();
for (CanteenDailyMenuImportExcel row : rows) {
- line++;
if (isBlankRow(row)) {
continue;
}
+ dataRows.add(row);
+ }
+ rows = null;
+ if (CollUtil.isEmpty(dataRows)) {
+ return R.failed("导入数据为空");
+ }
+ if (dataRows.size() > MAX_IMPORT_ROWS) {
+ return R.failed("有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行");
+ }
+
+ // groupKey = date|hourType|comboOrEmpty|menuIdOrNew
+ Map> groups = new LinkedHashMap<>();
+ Set involvedDates = new HashSet<>();
+ Set excelMenuIds = new HashSet<>();
+ int line = 1;
+ for (CanteenDailyMenuImportExcel row : dataRows) {
+ line++;
Integer hourType = parseMeal(row.getMealName());
LocalDate date = parseDate(row.getBizDate());
String dish = StrUtil.trim(row.getDishName());
@@ -129,40 +339,96 @@ public class CanteenDailyMenuImportHelper {
errors.add("第" + line + "行:菜品名不能为空");
continue;
}
+ involvedDates.add(date);
String combo = StrUtil.trim(row.getComboName());
- String key = date + "|" + hourType + "|" + StrUtil.blankToDefault(combo, "");
- groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
+ String comboKey = StrUtil.blankToDefault(combo, "");
+ Long menuId = parseExcelLong(row.getId());
+ if (menuId != null) {
+ excelMenuIds.add(menuId);
+ }
+ // 带上日期+餐段,避免同 ID 跨天被并成一组
+ String groupKey;
+ if (menuId != null) {
+ groupKey = "id:" + menuId + "|" + date + "|" + hourType;
+ }
+ else if (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey)) {
+ groupKey = "new:" + date + "|" + hourType + "|" + comboKey;
+ }
+ else {
+ groupKey = "new:" + date + "|" + hourType + "|" + dish;
+ }
+ groups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(row);
}
if (CollUtil.isNotEmpty(errors)) {
- return R.failed(String.join(";", errors.subList(0, Math.min(errors.size(), 20))));
+ return R.failed(formatImportErrors(errors));
}
+ ImportCache cache = buildImportCache(merchantId, involvedDates, excelMenuIds);
Set clearedDayHour = new LinkedHashSet<>();
+ List pendingMenus = new ArrayList<>();
+ int updated = 0;
int createdDish = 0;
int createdCombo = 0;
int scheduled = 0;
+ int idAsNew = 0;
for (Map.Entry> e : groups.entrySet()) {
- String[] parts = e.getKey().split("\\|", -1);
- LocalDate date = LocalDate.parse(parts[0]);
- Integer hourType = Integer.valueOf(parts[1]);
- String comboName = parts[2];
List items = e.getValue();
+ CanteenDailyMenuImportExcel firstRow = items.get(0);
+ LocalDate groupDate = parseDate(firstRow.getBizDate());
+ Integer groupHour = parseMeal(firstRow.getMealName());
+ Long menuId = items.stream().map(CanteenDailyMenuImportExcel::getId).map(CanteenDailyMenuImportHelper::parseExcelLong)
+ .filter(Objects::nonNull).findFirst().orElse(null);
+ if (menuId != null) {
+ CanteenDailyMenuEntity existMenu = resolveMenu(menuId, cache);
+ boolean matched = existMenu != null
+ && merchantId.equals(existMenu.getMerchantId())
+ && (groupDate == null || existMenu.getStartTime() == null
+ || groupDate.equals(existMenu.getStartTime()))
+ && (groupHour == null || existMenu.getHourType() == null
+ || groupHour.equals(existMenu.getHourType()));
+ if (!matched) {
+ // 空已走新增;ID 不存在 / 商户或日期餐段对不上 → 当新增排菜
+ idAsNew++;
+ items.forEach(r -> r.setId(null));
+ menuId = null;
+ }
+ }
+ if (menuId != null) {
+ String err = upsertGroupById(menuId, items, merchantId, deptId, categoryId, categoryMap, cache);
+ if (err != null) {
+ // 事务内勿 return R.failed:内层已写库/抛错会标 rollback-only,提交会报 UnexpectedRollback
+ throw new IllegalArgumentException(err);
+ }
+ updated++;
+ continue;
+ }
- if (StrUtil.isBlank(comboName)) {
+ CanteenDailyMenuImportExcel first = firstRow;
+ LocalDate date = groupDate;
+ Integer hourType = groupHour;
+ String comboName = StrUtil.trim(first.getComboName());
+
+ if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
for (CanteenDailyMenuImportExcel item : items) {
- FindOrCreate r = findOrCreateDish(merchantId, deptId, categoryId, item.getDishName(),
- item.getIngredients(), resolveDishPriceFen(item));
+ FindOrCreate r = findOrCreateDish(merchantId, deptId,
+ item.getCategoryName(), categoryId, categoryMap,
+ item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), item.getStock(),
+ cache);
if (r.created) {
createdDish++;
}
- schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour);
- scheduled++;
+ if (schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour,
+ cache, pendingMenus)) {
+ scheduled++;
+ }
}
}
else {
List children = new ArrayList<>();
Integer comboPrice = null;
+ Integer comboStock = first.getStock();
+ Set seenDishKeys = new LinkedHashSet<>();
for (int i = 0; i < items.size(); i++) {
CanteenDailyMenuImportExcel item = items.get(i);
if (i == 0) {
@@ -171,33 +437,311 @@ public class CanteenDailyMenuImportHelper {
comboPrice = fen;
}
}
- FindOrCreate r = findOrCreateDish(merchantId, deptId, categoryId, item.getDishName(),
- item.getIngredients(), resolveDishPriceFen(item));
+ String dishKey = cleanDishName(item.getDishName());
+ if (StrUtil.isBlank(dishKey) || !seenDishKeys.add(dishKey)) {
+ // 同套餐块内同名组成跳过(常见于跨日串组或导出重复)
+ continue;
+ }
+ FindOrCreate r = findOrCreateDish(merchantId, deptId,
+ item.getCategoryName(), categoryId, categoryMap,
+ item.getDishName(), null, resolveDishPriceFen(item), null, cache);
if (r.created) {
createdDish++;
}
children.add(r.spu);
}
+ children = dedupeComboChildren(children);
+ if (children.isEmpty()) {
+ throw new IllegalArgumentException(String.format("%s %s 套餐「%s」没有有效组成菜,请检查 Excel",
+ date, formatMeal(hourType), comboName));
+ }
if (comboPrice == null) {
comboPrice = 0;
}
String mainName = children.get(0).getName();
String fullComboName = comboName + "-" + mainName;
- FindOrCreate combo = findOrCreateCombo(merchantId, deptId, categoryId, fullComboName, comboName,
- comboPrice, children);
+ FindOrCreate combo = findOrCreateCombo(merchantId, deptId,
+ first.getCategoryName(), categoryId, categoryMap,
+ fullComboName, comboName, comboPrice, comboStock, children,
+ resolveComboIngredients(first), cache);
if (combo.created) {
createdCombo++;
}
- schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour);
- scheduled++;
+ if (schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour,
+ cache, pendingMenus)) {
+ scheduled++;
+ }
+ }
+ if (pendingMenus.size() >= MENU_BATCH_SIZE) {
+ flushPendingMenus(pendingMenus);
}
}
+ flushPendingMenus(pendingMenus);
- return R.ok(null, String.format("导入成功:新建单品 %d,新建套餐 %d,排菜 %d", createdDish, createdCombo, scheduled));
+ String msg = String.format("导入成功:更新 %d,新建单品 %d,新建套餐 %d,新增排菜 %d",
+ updated, createdDish, createdCombo, scheduled);
+ if (idAsNew > 0) {
+ msg += String.format("(其中 %d 组菜谱ID未匹配,已按新增排菜)", idAsNew);
+ }
+ return R.ok(null, msg);
}
- private void schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId,
- boolean replaceDay, Set clearedDayHour) {
+ /**
+ * 解析 Excel 中的雪花 ID。必须用字符串/BigDecimal,禁止经 Double,否则超过 15 位会失真。
+ */
+ private static Long parseExcelLong(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return null;
+ }
+ String s = raw.trim();
+ if (s.endsWith(".0")) {
+ s = s.substring(0, s.length() - 2);
+ }
+ try {
+ if (s.contains("E") || s.contains("e")) {
+ return new BigDecimal(s).toBigInteger().longValueExact();
+ }
+ return Long.parseLong(s);
+ }
+ catch (Exception ex) {
+ try {
+ return new BigDecimal(s).toBigInteger().longValueExact();
+ }
+ catch (Exception ignored) {
+ return null;
+ }
+ }
+ }
+
+ /**
+ * EasyExcel 流式按行解析;合并单元格需整表,故仍收集 List,但用行数硬顶防 OOM。
+ */
+ private static List readRowsStreaming(MultipartFile file) throws IOException {
+ List rows = new ArrayList<>();
+ EasyExcel.read(file.getInputStream(), CanteenDailyMenuImportExcel.class,
+ new ReadListener() {
+ @Override
+ public void invoke(CanteenDailyMenuImportExcel data, AnalysisContext context) {
+ if (rows.size() >= MAX_IMPORT_ROWS) {
+ throw new IllegalArgumentException("导入行数不能超过 " + MAX_IMPORT_ROWS + " 行");
+ }
+ rows.add(data);
+ }
+
+ @Override
+ public void doAfterAllAnalysed(AnalysisContext context) {
+ // no-op
+ }
+ }).sheet().headRowNumber(1).doRead();
+ return rows;
+ }
+
+ private ImportCache buildImportCache(Long merchantId, Set dates, Set menuIds) {
+ ImportCache cache = new ImportCache();
+ // 只取导入比对所需字段;更新时用 UpdateWrapper,避免 pic_url 等 ALWAYS 字段被写成 null
+ List spus = canteenSpuService.list(Wrappers.lambdaQuery()
+ .eq(CanteenSpuEntity::getMerchantId, merchantId)
+ .select(CanteenSpuEntity::getId, CanteenSpuEntity::getName, CanteenSpuEntity::getSpuType,
+ CanteenSpuEntity::getPrice, CanteenSpuEntity::getMarketPrice, CanteenSpuEntity::getStock,
+ CanteenSpuEntity::getDescription, CanteenSpuEntity::getIntroduction,
+ CanteenSpuEntity::getCategoryId, CanteenSpuEntity::getMerchantId, CanteenSpuEntity::getDeptId,
+ CanteenSpuEntity::getPicUrl));
+ List comboIds = new ArrayList<>();
+ if (CollUtil.isNotEmpty(spus)) {
+ for (CanteenSpuEntity spu : spus) {
+ if (spu == null || StrUtil.isBlank(spu.getName())) {
+ continue;
+ }
+ if (spu.getPicUrl() == null) {
+ spu.setPicUrl("");
+ }
+ int type = spu.getSpuType() == null ? 1 : spu.getSpuType();
+ if (type == 2) {
+ cache.comboByName.putIfAbsent(spu.getName(), spu);
+ if (spu.getId() != null) {
+ comboIds.add(spu.getId());
+ }
+ }
+ else {
+ cache.dishByName.putIfAbsent(spu.getName(), spu);
+ }
+ }
+ }
+ if (CollUtil.isNotEmpty(comboIds)) {
+ List lines = canteenSpuComboItemMapper.selectList(
+ Wrappers.lambdaQuery()
+ .in(CanteenSpuComboItemEntity::getComboSpuId, comboIds)
+ .orderByAsc(CanteenSpuComboItemEntity::getSortOrder)
+ .orderByAsc(CanteenSpuComboItemEntity::getId)
+ .select(CanteenSpuComboItemEntity::getComboSpuId, CanteenSpuComboItemEntity::getChildSpuId,
+ CanteenSpuComboItemEntity::getSortOrder, CanteenSpuComboItemEntity::getId));
+ if (CollUtil.isNotEmpty(lines)) {
+ for (CanteenSpuComboItemEntity line : lines) {
+ if (line.getComboSpuId() == null || line.getChildSpuId() == null) {
+ continue;
+ }
+ cache.comboChildIds.computeIfAbsent(line.getComboSpuId(), k -> new ArrayList<>())
+ .add(line.getChildSpuId());
+ }
+ }
+ }
+ if (CollUtil.isNotEmpty(dates)) {
+ List menus = canteenDailyMenuService.list(
+ Wrappers.lambdaQuery()
+ .eq(CanteenDailyMenuEntity::getMerchantId, merchantId)
+ .in(CanteenDailyMenuEntity::getStartTime, dates)
+ .select(CanteenDailyMenuEntity::getStartTime, CanteenDailyMenuEntity::getHourType,
+ CanteenDailyMenuEntity::getSpuId));
+ if (CollUtil.isNotEmpty(menus)) {
+ for (CanteenDailyMenuEntity m : menus) {
+ if (m.getStartTime() != null && m.getHourType() != null && m.getSpuId() != null) {
+ cache.existingMenuKeys.add(menuKey(m.getStartTime(), m.getHourType(), m.getSpuId()));
+ }
+ }
+ }
+ }
+ if (CollUtil.isNotEmpty(menuIds)) {
+ List byIds = canteenDailyMenuService.listByIds(menuIds);
+ if (CollUtil.isNotEmpty(byIds)) {
+ for (CanteenDailyMenuEntity m : byIds) {
+ if (m != null && m.getId() != null) {
+ cache.menuById.put(m.getId(), m);
+ }
+ }
+ }
+ }
+ return cache;
+ }
+
+ private CanteenDailyMenuEntity resolveMenu(Long menuId, ImportCache cache) {
+ if (menuId == null) {
+ return null;
+ }
+ CanteenDailyMenuEntity cached = cache.menuById.get(menuId);
+ if (cached != null) {
+ return cached;
+ }
+ CanteenDailyMenuEntity db = canteenDailyMenuService.getById(menuId);
+ if (db != null) {
+ cache.menuById.put(menuId, db);
+ }
+ return db;
+ }
+
+ private void flushPendingMenus(List pendingMenus) {
+ if (CollUtil.isEmpty(pendingMenus)) {
+ return;
+ }
+ canteenDailyMenuService.saveBatch(pendingMenus, MENU_BATCH_SIZE);
+ pendingMenus.clear();
+ }
+
+ /**
+ * 按菜谱 ID 更新一组行(单品一行,套餐多行)
+ */
+ private String upsertGroupById(Long menuId, List items, Long merchantId, Long deptId,
+ Long defaultCategoryId, Map categoryMap, ImportCache cache) {
+ CanteenDailyMenuEntity menu = resolveMenu(menuId, cache);
+ if (menu == null) {
+ return "菜谱ID " + menuId + " 不存在(若由Excel导出再导入,多为ID被当成数字丢了末位精度,请重新导出且保持菜谱ID列为文本)";
+ }
+ if (!merchantId.equals(menu.getMerchantId())) {
+ return "菜谱ID " + menuId + " 不属于当前商户";
+ }
+ CanteenDailyMenuImportExcel first = items.get(0);
+ LocalDate date = parseDate(first.getBizDate());
+ Integer hourType = parseMeal(first.getMealName());
+ if (date == null) {
+ return "菜谱ID " + menuId + ":日期格式应为 yyyy-MM-dd";
+ }
+ if (hourType == null) {
+ return "菜谱ID " + menuId + ":餐段应为 早餐/午餐/晚餐";
+ }
+ String comboName = StrUtil.trim(first.getComboName());
+ try {
+ if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
+ return upsertSingleById(menu, first, merchantId, deptId, defaultCategoryId, categoryMap, date, hourType,
+ cache);
+ }
+ return upsertComboGroupById(menu, items, merchantId, deptId, defaultCategoryId, categoryMap, comboName, date,
+ hourType, cache);
+ }
+ catch (IllegalArgumentException ex) {
+ return ex.getMessage();
+ }
+ }
+
+ private String upsertSingleById(CanteenDailyMenuEntity menu, CanteenDailyMenuImportExcel row, Long merchantId,
+ Long deptId, Long defaultCategoryId, Map categoryMap, LocalDate date, Integer hourType,
+ ImportCache cache) {
+ String dish = StrUtil.trim(row.getDishName());
+ if (StrUtil.isBlank(dish)) {
+ return "菜谱ID " + menu.getId() + ":菜品名不能为空";
+ }
+ FindOrCreate created = findOrCreateDish(merchantId, deptId,
+ row.getCategoryName(), defaultCategoryId, categoryMap, dish, row.getIngredients(),
+ resolveDishPriceFen(row), row.getStock(), cache);
+ menu.setStartTime(date);
+ menu.setHourType(hourType);
+ menu.setSpuId(created.spu.getId());
+ canteenDailyMenuService.updateById(menu);
+ return null;
+ }
+
+ private String upsertComboGroupById(CanteenDailyMenuEntity menu, List items,
+ Long merchantId, Long deptId, Long defaultCategoryId, Map categoryMap, String comboName,
+ LocalDate date, Integer hourType, ImportCache cache) {
+ List children = new ArrayList<>();
+ Integer comboPrice = null;
+ Integer comboStock = items.get(0).getStock();
+ Set seenDishKeys = new LinkedHashSet<>();
+ for (int i = 0; i < items.size(); i++) {
+ CanteenDailyMenuImportExcel item = items.get(i);
+ if (i == 0) {
+ comboPrice = toFen(item.getPriceYuan());
+ }
+ String dish = StrUtil.trim(item.getDishName());
+ if (StrUtil.isBlank(dish)) {
+ return "菜谱ID " + menu.getId() + ":套餐组成菜品名不能为空";
+ }
+ String dishKey = cleanDishName(dish);
+ if (!seenDishKeys.add(dishKey)) {
+ continue;
+ }
+ FindOrCreate r = findOrCreateDish(merchantId, deptId,
+ item.getCategoryName(), defaultCategoryId, categoryMap, dish,
+ null, resolveDishPriceFen(item), null, cache);
+ children.add(r.spu);
+ }
+ children = dedupeComboChildren(children);
+ if (children.isEmpty()) {
+ return "菜谱ID " + menu.getId() + ":套餐没有有效组成菜";
+ }
+ if (comboPrice == null) {
+ CanteenSpuEntity existCombo = cache.comboByName.values().stream()
+ .filter(s -> Objects.equals(s.getId(), menu.getSpuId()))
+ .findFirst()
+ .orElseGet(() -> canteenSpuService.getById(menu.getSpuId()));
+ comboPrice = existCombo != null && existCombo.getPrice() != null ? existCombo.getPrice() : 0;
+ }
+ String mainName = children.get(0).getName();
+ String fullComboName = comboName + "-" + mainName;
+ FindOrCreate combo = findOrCreateCombo(merchantId, deptId,
+ items.get(0).getCategoryName(), defaultCategoryId, categoryMap, fullComboName,
+ comboName, comboPrice, comboStock, children, resolveComboIngredients(items.get(0)), cache);
+ menu.setStartTime(date);
+ menu.setHourType(hourType);
+ menu.setSpuId(combo.spu.getId());
+ canteenDailyMenuService.updateById(menu);
+ return null;
+ }
+
+ /**
+ * @return true 表示新增了一条排菜
+ */
+ private boolean schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId,
+ boolean replaceDay, Set clearedDayHour, ImportCache cache,
+ List pendingMenus) {
String dh = date + "|" + hourType;
if (replaceDay && !clearedDayHour.contains(dh)) {
canteenDailyMenuService.remove(Wrappers.lambdaQuery()
@@ -205,14 +749,11 @@ public class CanteenDailyMenuImportHelper {
.eq(CanteenDailyMenuEntity::getStartTime, date)
.eq(CanteenDailyMenuEntity::getHourType, hourType));
clearedDayHour.add(dh);
+ cache.existingMenuKeys.removeIf(k -> k.startsWith(date + "|" + hourType + "|"));
}
- boolean exists = canteenDailyMenuService.count(Wrappers.lambdaQuery()
- .eq(CanteenDailyMenuEntity::getMerchantId, merchantId)
- .eq(CanteenDailyMenuEntity::getStartTime, date)
- .eq(CanteenDailyMenuEntity::getHourType, hourType)
- .eq(CanteenDailyMenuEntity::getSpuId, spuId)) > 0;
- if (exists) {
- return;
+ String key = menuKey(date, hourType, spuId);
+ if (cache.existingMenuKeys.contains(key)) {
+ return false;
}
CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity();
menu.setMerchantId(merchantId);
@@ -220,73 +761,304 @@ public class CanteenDailyMenuImportHelper {
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(spuId);
- canteenDailyMenuService.save(menu);
+ pendingMenus.add(menu);
+ cache.existingMenuKeys.add(key);
+ return true;
}
- private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, Long categoryId, String name,
- String ingredients, Integer priceFen) {
+ private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, String categoryName, Long defaultCategoryId,
+ Map categoryMap, String name, String ingredients, Integer priceFen, Integer stock,
+ ImportCache cache) {
String clean = cleanDishName(name);
- CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery()
- .eq(CanteenSpuEntity::getMerchantId, merchantId)
- .eq(CanteenSpuEntity::getName, clean)
- .eq(CanteenSpuEntity::getSpuType, 1)
- .last("limit 1"), false);
+ CanteenSpuEntity exist = cache.dishByName.get(clean);
if (exist != null) {
- boolean needUpdate = false;
- if (StrUtil.isNotBlank(ingredients) && StrUtil.isBlank(exist.getDescription())) {
- exist.setDescription(ingredients.trim());
- needUpdate = true;
+ String importIngredients = normalizeImportIngredients(ingredients);
+ boolean changeDesc = false;
+ boolean changePrice = false;
+ boolean changeStock = false;
+ boolean changeCategory = false;
+ if (StrUtil.isNotBlank(importIngredients)) {
+ String existPlain = toPlainIngredients(exist.getDescription());
+ if (!importIngredients.equals(existPlain)) {
+ exist.setDescription(importIngredients);
+ changeDesc = true;
+ }
}
- if (priceFen != null && priceFen > 0 && (exist.getPrice() == null || exist.getPrice() == 0)) {
+ if (priceFen != null && priceFen > 0 && !Objects.equals(exist.getPrice(), priceFen)) {
exist.setPrice(priceFen);
exist.setMarketPrice(priceFen);
- needUpdate = true;
+ changePrice = true;
}
- if (needUpdate) {
- canteenSpuService.updateById(exist);
+ if (stock != null && stock >= 0 && !Objects.equals(exist.getStock(), stock)) {
+ exist.setStock(stock);
+ changeStock = true;
+ }
+ // Excel 明确填了分类才改;空着不覆盖已有分类
+ if (StrUtil.isNotBlank(normalizeCategoryName(categoryName))) {
+ Long newCatId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId,
+ deptId);
+ if (newCatId != null && !Objects.equals(exist.getCategoryId(), newCatId)) {
+ exist.setCategoryId(newCatId);
+ changeCategory = true;
+ }
+ }
+ if (changeDesc || changePrice || changeStock || changeCategory) {
+ // 禁止 updateById(半截实体):pic_url 等为 ALWAYS,null 会写库报错
+ var uw = Wrappers.lambdaUpdate().eq(CanteenSpuEntity::getId, exist.getId());
+ if (changeDesc) {
+ uw.set(CanteenSpuEntity::getDescription, exist.getDescription());
+ }
+ if (changePrice) {
+ uw.set(CanteenSpuEntity::getPrice, exist.getPrice());
+ uw.set(CanteenSpuEntity::getMarketPrice, exist.getMarketPrice());
+ }
+ if (changeStock) {
+ uw.set(CanteenSpuEntity::getStock, exist.getStock());
+ }
+ if (changeCategory) {
+ uw.set(CanteenSpuEntity::getCategoryId, exist.getCategoryId());
+ }
+ canteenSpuService.update(uw);
}
return new FindOrCreate(exist, false);
}
+ Long categoryId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, deptId);
+ if (categoryId == null) {
+ throw new IllegalArgumentException(missingCategoryMessage("新建菜品「" + clean + "」", categoryName, categoryMap));
+ }
CanteenSpuEntity spu = newBaseSpu(merchantId, deptId, categoryId, clean, 1);
- spu.setDescription(StrUtil.trim(ingredients));
+ spu.setDescription(normalizeImportIngredients(ingredients));
spu.setPrice(priceFen == null ? 0 : priceFen);
spu.setMarketPrice(spu.getPrice());
+ if (stock != null && stock >= 0) {
+ spu.setStock(stock);
+ }
canteenSpuService.save(spu);
+ cache.dishByName.put(clean, spu);
return new FindOrCreate(spu, true);
}
- private FindOrCreate findOrCreateCombo(Long merchantId, Long deptId, Long categoryId, String fullName,
- String pkgLabel, Integer priceFen, List children) {
- CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery()
- .eq(CanteenSpuEntity::getMerchantId, merchantId)
- .eq(CanteenSpuEntity::getName, fullName)
- .eq(CanteenSpuEntity::getSpuType, 2)
- .last("limit 1"), false);
+ private FindOrCreate findOrCreateCombo(Long merchantId, Long deptId, String categoryName, Long defaultCategoryId,
+ Map categoryMap, String fullName, String pkgLabel, Integer priceFen, Integer stock,
+ List children, String ingredients, ImportCache cache) {
+ CanteenSpuEntity exist = cache.comboByName.get(fullName);
List items = new ArrayList<>();
int sort = 0;
+ List newChildIds = new ArrayList<>(children.size());
for (CanteenSpuEntity child : children) {
CanteenSpuComboItemEntity line = new CanteenSpuComboItemEntity();
line.setChildSpuId(child.getId());
line.setQuantity(1);
line.setSortOrder(sort++);
items.add(line);
+ newChildIds.add(child.getId());
}
if (exist != null) {
- exist.setPrice(priceFen);
- exist.setMarketPrice(priceFen);
- exist.setComboItems(items);
- canteenSpuService.updateDeep(exist);
+ boolean changePrice = priceFen != null && !Objects.equals(exist.getPrice(), priceFen);
+ if (changePrice) {
+ exist.setPrice(priceFen);
+ exist.setMarketPrice(priceFen);
+ }
+ boolean changeStock = stock != null && stock >= 0 && !Objects.equals(exist.getStock(), stock);
+ if (changeStock) {
+ exist.setStock(stock);
+ }
+ boolean changeIntro = StrUtil.isNotBlank(pkgLabel) && !Objects.equals(exist.getIntroduction(), pkgLabel);
+ if (changeIntro) {
+ exist.setIntroduction(pkgLabel);
+ }
+ boolean changeCategory = false;
+ if (StrUtil.isNotBlank(normalizeCategoryName(categoryName))) {
+ Long newCatId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId,
+ deptId);
+ if (newCatId != null && !Objects.equals(exist.getCategoryId(), newCatId)) {
+ exist.setCategoryId(newCatId);
+ changeCategory = true;
+ }
+ }
+ String beforeDesc = exist.getDescription();
+ applyComboIngredients(exist, ingredients);
+ boolean changeDesc = !Objects.equals(beforeDesc, exist.getDescription());
+ List oldChildIds = cache.comboChildIds.getOrDefault(exist.getId(), List.of());
+ boolean itemsChanged = !newChildIds.equals(oldChildIds);
+ if (itemsChanged) {
+ exist.setComboItems(items);
+ if (exist.getDeptId() == null) {
+ exist.setDeptId(deptId);
+ }
+ if (exist.getPicUrl() == null) {
+ exist.setPicUrl("");
+ }
+ canteenSpuService.updateDeep(exist);
+ cache.comboChildIds.put(exist.getId(), new ArrayList<>(newChildIds));
+ }
+ else if (changePrice || changeStock || changeIntro || changeDesc || changeCategory) {
+ var uw = Wrappers.lambdaUpdate().eq(CanteenSpuEntity::getId, exist.getId());
+ if (changePrice) {
+ uw.set(CanteenSpuEntity::getPrice, exist.getPrice());
+ uw.set(CanteenSpuEntity::getMarketPrice, exist.getMarketPrice());
+ }
+ if (changeStock) {
+ uw.set(CanteenSpuEntity::getStock, exist.getStock());
+ }
+ if (changeIntro) {
+ uw.set(CanteenSpuEntity::getIntroduction, exist.getIntroduction());
+ }
+ if (changeDesc) {
+ uw.set(CanteenSpuEntity::getDescription, exist.getDescription());
+ }
+ if (changeCategory) {
+ uw.set(CanteenSpuEntity::getCategoryId, exist.getCategoryId());
+ }
+ canteenSpuService.update(uw);
+ }
+ cache.comboByName.put(fullName, exist);
return new FindOrCreate(exist, false);
}
+ Long categoryId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, deptId);
+ if (categoryId == null) {
+ throw new IllegalArgumentException(missingCategoryMessage("新建套餐「" + fullName + "」", categoryName, categoryMap));
+ }
CanteenSpuEntity combo = newBaseSpu(merchantId, deptId, categoryId, fullName, 2);
combo.setIntroduction(pkgLabel);
+ applyComboIngredients(combo, ingredients);
combo.setPrice(priceFen);
combo.setMarketPrice(priceFen);
+ if (stock != null && stock >= 0) {
+ combo.setStock(stock);
+ }
combo.setComboItems(items);
canteenSpuService.saveDeep(combo);
+ cache.comboByName.put(fullName, combo);
+ if (combo.getId() != null) {
+ cache.comboChildIds.put(combo.getId(), new ArrayList<>(newChildIds));
+ }
return new FindOrCreate(combo, true);
}
+ private static String menuKey(LocalDate date, Integer hourType, Long spuId) {
+ return date + "|" + hourType + "|" + spuId;
+ }
+
+ /** Excel 同一套餐块内菜名/SPU 去重,保留首次出现 */
+ private static List dedupeComboChildren(List children) {
+ if (CollUtil.isEmpty(children)) {
+ return children;
+ }
+ Set ids = new HashSet<>();
+ List out = new ArrayList<>(children.size());
+ for (CanteenSpuEntity child : children) {
+ if (child == null || child.getId() == null) {
+ continue;
+ }
+ if (ids.add(child.getId())) {
+ out.add(child);
+ }
+ }
+ return out;
+ }
+
+ private static void applyComboIngredients(CanteenSpuEntity combo, String ingredients) {
+ String importIngredients = normalizeImportIngredients(ingredients);
+ if (StrUtil.isBlank(importIngredients)) {
+ return;
+ }
+ String existPlain = toPlainIngredients(combo.getDescription());
+ if (!importIngredients.equals(existPlain)) {
+ combo.setDescription(importIngredients);
+ }
+ }
+
+ private static String resolveComboIngredients(CanteenDailyMenuImportExcel first) {
+ return first == null ? "" : normalizeImportIngredients(first.getIngredients());
+ }
+
+ private Map loadCategoryMap(Long merchantId) {
+ List list = canteenCategoryMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CanteenCategoryEntity::getMerchantId, merchantId));
+ Map map = new HashMap<>();
+ if (CollUtil.isEmpty(list)) {
+ return map;
+ }
+ for (CanteenCategoryEntity c : list) {
+ if (c == null || c.getId() == null || StrUtil.isBlank(c.getName())) {
+ continue;
+ }
+ String raw = c.getName().trim();
+ String normalized = normalizeCategoryName(raw);
+ map.putIfAbsent(raw, c.getId());
+ if (StrUtil.isNotBlank(normalized)) {
+ map.putIfAbsent(normalized, c.getId());
+ }
+ }
+ return map;
+ }
+
+ /**
+ * Excel 分类名:商户下已有则引用;没有则自动新建;分类名为空时用兜底分类。
+ */
+ private Long resolveOrCreateCategoryId(String categoryName, Long defaultCategoryId, Map categoryMap,
+ Long merchantId, Long deptId) {
+ String key = normalizeCategoryName(categoryName);
+ if (StrUtil.isNotBlank(key) && categoryMap != null) {
+ Long id = categoryMap.get(key);
+ if (id == null) {
+ id = categoryMap.get(StrUtil.trim(categoryName));
+ }
+ if (id != null) {
+ return id;
+ }
+ CanteenCategoryEntity cat = new CanteenCategoryEntity();
+ cat.setName(key);
+ cat.setMerchantId(merchantId);
+ cat.setDeptId(deptId);
+ cat.setParentId(0L);
+ cat.setSort(0);
+ cat.setStatus(1);
+ cat.setPicUrl("");
+ // 批量导入直接 insert,避免每条都打 MQTT
+ canteenCategoryMapper.insert(cat);
+ Long newId = cat.getId();
+ categoryMap.put(key, newId);
+ String raw = StrUtil.trim(categoryName);
+ if (StrUtil.isNotBlank(raw)) {
+ categoryMap.putIfAbsent(raw, newId);
+ }
+ return newId;
+ }
+ return defaultCategoryId;
+ }
+
+ private static String normalizeCategoryName(String name) {
+ if (name == null) {
+ return "";
+ }
+ StringBuilder sb = new StringBuilder(name.length());
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if (c == '\r' || c == '\n' || c == '\t' || c == '\u00A0' || c == '\u3000') {
+ sb.append(' ');
+ continue;
+ }
+ // 全角数字 → 半角,避免「荤菜1」匹配不上「荤菜1」
+ if (c >= '0' && c <= '9') {
+ sb.append((char) ('0' + (c - '0')));
+ continue;
+ }
+ sb.append(c);
+ }
+ return sb.toString().replaceAll(" +", " ").trim();
+ }
+
+ private static String missingCategoryMessage(String prefix, String excelCategory, Map categoryMap) {
+ String excel = normalizeCategoryName(excelCategory);
+ if (StrUtil.isNotBlank(excel)) {
+ // 理论上走自动建分类后不会到这里;保留兜底文案
+ return prefix + ":分类「" + excel + "」处理失败,请重试或选择兜底分类";
+ }
+ return prefix + ":缺少分类,请在 Excel「分类」列填写,或导入时选择兜底分类";
+ }
+
private CanteenSpuEntity newBaseSpu(Long merchantId, Long deptId, Long categoryId, String name, int spuType) {
CanteenSpuEntity spu = new CanteenSpuEntity();
spu.setName(name);
@@ -309,54 +1081,140 @@ public class CanteenDailyMenuImportHelper {
}
private static boolean isBlankRow(CanteenDailyMenuImportExcel row) {
- return StrUtil.isBlank(row.getBizDate()) && StrUtil.isBlank(row.getDishName())
+ return StrUtil.isBlank(row.getId())
+ && StrUtil.isBlank(row.getBizDate()) && StrUtil.isBlank(row.getDishName())
&& StrUtil.isBlank(row.getMealName()) && StrUtil.isBlank(row.getComboName());
}
/**
- * 合并单元格模板读入后,非首行日期/餐段/套餐名为空,需向下填充。
- * 套餐名填「无」「-」「单品」表示单品,不参与套餐继承。
+ * 合并单元格模板读入后,非首行日期/餐段/套餐名/分类为空,需向下填充。
+ * 套餐名填「无」「单品」「单点」或「单品-xxx / 单点-xxx」表示单品,不参与套餐继承。
+ * 换日/换餐段时必须清空菜谱ID继承,否则「同名套餐」跨天会被合成一组,组成菜看起来像重复。
*/
private static void normalizeMergedCells(List rows) {
String lastDate = null;
String lastMeal = null;
String lastCombo = null;
+ String lastCategory = null;
+ String lastMenuId = null;
+ String lastSpuId = null;
for (CanteenDailyMenuImportExcel row : rows) {
if (isBlankRow(row)) {
continue;
}
- if (StrUtil.isBlank(row.getBizDate())) {
+ String incomingDate = StrUtil.trim(row.getBizDate());
+ if (StrUtil.isBlank(incomingDate)) {
row.setBizDate(lastDate);
}
else {
- lastDate = StrUtil.trim(row.getBizDate());
+ if (lastDate != null && !incomingDate.equals(lastDate)) {
+ lastMenuId = null;
+ lastSpuId = null;
+ }
+ lastDate = incomingDate;
row.setBizDate(lastDate);
}
- if (StrUtil.isBlank(row.getMealName())) {
+ String incomingMeal = StrUtil.trim(row.getMealName());
+ if (StrUtil.isBlank(incomingMeal)) {
row.setMealName(lastMeal);
}
else {
- lastMeal = StrUtil.trim(row.getMealName());
+ if (lastMeal != null && !incomingMeal.equals(lastMeal)) {
+ lastMenuId = null;
+ lastSpuId = null;
+ }
+ lastMeal = incomingMeal;
row.setMealName(lastMeal);
}
+ if (StrUtil.isBlank(row.getCategoryName())) {
+ row.setCategoryName(lastCategory);
+ }
+ else {
+ lastCategory = normalizeCategoryName(row.getCategoryName());
+ row.setCategoryName(lastCategory);
+ }
String combo = StrUtil.trim(row.getComboName());
if (StrUtil.isBlank(combo)) {
row.setComboName(lastCombo);
+ combo = lastCombo;
}
else if (isSingleComboMarker(combo)) {
row.setComboName(null);
lastCombo = null;
+ lastMenuId = null;
+ lastSpuId = null;
+ combo = null;
}
else {
+ if (!combo.equals(lastCombo)) {
+ lastMenuId = null;
+ lastSpuId = null;
+ }
lastCombo = combo;
row.setComboName(combo);
}
+ if (StrUtil.isNotBlank(row.getId())) {
+ lastMenuId = StrUtil.trim(row.getId());
+ row.setId(lastMenuId);
+ if (StrUtil.isNotBlank(row.getSpuId())) {
+ lastSpuId = StrUtil.trim(row.getSpuId());
+ row.setSpuId(lastSpuId);
+ }
+ }
+ else if (lastMenuId != null && StrUtil.isNotBlank(lastCombo)) {
+ row.setId(lastMenuId);
+ if (lastSpuId != null) {
+ row.setSpuId(lastSpuId);
+ }
+ }
}
}
+ /**
+ * 单品标记(不按套餐组块处理):
+ *
+ * - 无 / - / (无) / (无)
+ * - 单品、单品-xxx、单品1、【单品】等以「单品」开头
+ * - 单点、单点-xxx、单点1、【单点】等以「单点」开头
+ *
+ */
private static boolean isSingleComboMarker(String combo) {
- return "无".equals(combo) || "-".equals(combo) || "单品".equals(combo)
- || "(无)".equals(combo) || "(无)".equals(combo);
+ if (StrUtil.isBlank(combo)) {
+ return false;
+ }
+ String c = stripMarkerWrap(combo.trim());
+ if (c.isEmpty()) {
+ return false;
+ }
+ if ("无".equals(c) || "-".equals(c) || "无套餐".equals(c) || "不套餐".equals(c)) {
+ return true;
+ }
+ return c.startsWith("单品") || c.startsWith("单点");
+ }
+
+ /** 去掉首尾常见括号/书名号,便于识别【单品】、(单点-1)等 */
+ private static String stripMarkerWrap(String s) {
+ String t = s;
+ while (t.length() >= 2) {
+ char a = t.charAt(0);
+ char b = t.charAt(t.length() - 1);
+ boolean wrap = (a == '(' && b == ')')
+ || (a == '(' && b == ')')
+ || (a == '【' && b == '】')
+ || (a == '[' && b == ']')
+ || (a == '「' && b == '」')
+ || (a == '"' && b == '"')
+ || (a == '\'' && b == '\'');
+ if (!wrap) {
+ break;
+ }
+ t = t.substring(1, t.length() - 1).trim();
+ }
+ // 兼容表头写成「(无)」整段
+ if ("无".equals(t) || "单品".equals(t) || "单点".equals(t)) {
+ return t;
+ }
+ return t;
}
private static Integer parseMeal(String meal) {
@@ -396,7 +1254,7 @@ public class CanteenDailyMenuImportHelper {
}
/**
- * 菜品单价:优先「菜品单价(元)」;未填则单品可回退「套餐价(元)」兼容旧模板;都无则 0。
+ * 菜品单价:优先「单价(元)」;单品未填单价时回退「套餐价(元)」兼容旧表。
*/
private static int resolveDishPriceFen(CanteenDailyMenuImportExcel item) {
Integer dishFen = toFen(item.getDishPriceYuan());
@@ -404,25 +1262,68 @@ public class CanteenDailyMenuImportHelper {
return Math.max(dishFen, 0);
}
Integer legacy = toFen(item.getPriceYuan());
- if (legacy != null && StrUtil.isBlank(item.getComboName())) {
+ if (legacy != null && (StrUtil.isBlank(item.getComboName()) || isSingleComboMarker(item.getComboName()))) {
return Math.max(legacy, 0);
}
return 0;
}
+ /**
+ * 规范化菜品名:保留中英文及换行(Excel 内多行英文说明不再截断)。
+ */
static String cleanDishName(String raw) {
if (raw == null) {
return "";
}
- String s = raw.replace("\r", "\n").trim();
- String first = s.split("\n")[0].trim();
- first = first.replaceAll("[\\p{So}\\p{Cn}]", "").trim();
- String cn = first.replaceAll("[A-Za-z].*$", "").trim();
- cn = cn.replaceAll("[@#%&&]+$", "").replaceAll("\\s+", "");
- if (cn.length() >= 2) {
- return cn;
+ String normalized = raw.replace("\r\n", "\n").replace('\r', '\n').trim();
+ if (StrUtil.isBlank(normalized)) {
+ return "";
}
- return first.replaceAll("\\s+", " ").trim();
+ StringBuilder sb = new StringBuilder();
+ for (String line : normalized.split("\n")) {
+ String trimmed = StrUtil.trim(line).replaceAll("\\s+", " ");
+ if (StrUtil.isBlank(trimmed)) {
+ continue;
+ }
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(trimmed);
+ }
+ return sb.toString();
+ }
+
+ /** 富文本配料转 Excel 纯文本 */
+ private static String toExcelIngredients(String raw) {
+ return toPlainIngredients(raw);
+ }
+
+ private static String normalizeImportIngredients(String raw) {
+ return toPlainIngredients(raw);
+ }
+
+ /**
+ * 去掉 HTML 标签与常见空白实体,避免编辑器空内容 {@code
} 被当成“已有配料”。
+ */
+ private static String toPlainIngredients(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return "";
+ }
+ String text = raw.replaceAll("(?is)
", "\n")
+ .replaceAll("(?is)
", "\n")
+ .replaceAll("(?is)]*>", "")
+ .replaceAll("(?is)<[^>]+>", "");
+ text = text.replace(" ", " ")
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\r", "\n");
+ text = text.lines()
+ .map(String::trim)
+ .filter(StrUtil::isNotBlank)
+ .reduce((a, b) -> a + "," + b)
+ .orElse("");
+ return StrUtil.trim(text);
}
private static class FindOrCreate {
@@ -434,4 +1335,15 @@ public class CanteenDailyMenuImportHelper {
this.created = created;
}
}
+
+ /** 导入过程内存缓存:避免每行菜品/排菜都打库 */
+ private static class ImportCache {
+ final Map dishByName = new HashMap<>();
+ final Map comboByName = new HashMap<>();
+ final Set existingMenuKeys = new HashSet<>();
+ /** 套餐组成 childSpuId 有序列表,用于判断是否需要 updateDeep */
+ final Map> comboChildIds = new HashMap<>();
+ /** Excel 菜谱 ID → 实体,避免每组 getById */
+ final Map menuById = new HashMap<>();
+ }
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
index 37edcf84..9440ae8e 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
@@ -11,6 +11,7 @@ import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
+import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
@@ -23,6 +24,7 @@ import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -42,6 +44,7 @@ import java.util.stream.Collectors;
*/
@Service
@AllArgsConstructor
+@Slf4j
public class CanteenDailyMenuServiceImpl extends ServiceImpl implements CanteenDailyMenuService {
private final CanteenSpuService canteenSpuService;
@@ -101,29 +104,50 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl spuIds = Optional.ofNullable(canteenDailyMenu.getSpuIds()).orElse(Collections.emptyList());
- if (spuIds.isEmpty()) return;
-
- LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
- .eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId())
- .eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime())
- .eq(CanteenDailyMenuEntity::getHourType, canteenDailyMenu.getHourType())
- .select(CanteenDailyMenuEntity::getSpuId); // 仅查询必要字段
-
- List existSpuIds = baseMapper.selectList(queryWrapper)
+ if (spuIds.isEmpty()) {
+ return;
+ }
+ List hourTypes = Optional.ofNullable(canteenDailyMenu.getHourTypes()).orElse(Collections.emptyList())
.stream()
- .map(CanteenDailyMenuEntity::getSpuId)
+ .filter(Objects::nonNull)
+ .distinct()
.collect(Collectors.toList());
+ if (hourTypes.isEmpty() && canteenDailyMenu.getHourType() != null) {
+ hourTypes = Collections.singletonList(canteenDailyMenu.getHourType());
+ }
+ if (hourTypes.isEmpty()) {
+ return;
+ }
- List insertList = spuIds.stream()
- .filter(id -> !existSpuIds.contains(id))
- .map(id -> {
- CanteenDailyMenuEntity entity = new CanteenDailyMenuEntity();
- BeanUtils.copyProperties(canteenDailyMenu, entity);
- entity.setSpuId(id);
- return entity;
- })
- .collect(Collectors.toList());
- saveBatch(insertList);
+ List insertList = new ArrayList<>();
+ for (Integer hourType : hourTypes) {
+ LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper()
+ .eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId())
+ .eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime())
+ .eq(CanteenDailyMenuEntity::getHourType, hourType)
+ .select(CanteenDailyMenuEntity::getSpuId);
+
+ List existSpuIds = baseMapper.selectList(queryWrapper)
+ .stream()
+ .map(CanteenDailyMenuEntity::getSpuId)
+ .collect(Collectors.toList());
+
+ for (Long spuId : spuIds) {
+ if (existSpuIds.contains(spuId)) {
+ continue;
+ }
+ CanteenDailyMenuEntity entity = new CanteenDailyMenuEntity();
+ BeanUtils.copyProperties(canteenDailyMenu, entity);
+ entity.setSpuId(spuId);
+ entity.setHourType(hourType);
+ entity.setHourTypes(null);
+ entity.setSpuIds(null);
+ insertList.add(entity);
+ }
+ }
+ if (!insertList.isEmpty()) {
+ saveBatch(insertList);
+ }
// 发送mq消息
sendMqttMsg(canteenDailyMenu.getMerchantId());
@@ -175,6 +199,57 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl selectAdminList(String time, Long merchantId, Long categoryId) {
+ List rows = baseMapper.selectAdminList(time, merchantId, categoryId, DataScope.of().only(true));
+ if (CollectionUtils.isEmpty(rows)) {
+ return rows;
+ }
+ fillDailyMenuAdminCombo(rows);
+ for (DailyMenuAdminVO row : rows) {
+ if (row.getStartTime() != null) {
+ row.setWeekdayLabel(weekdayLabel(row.getStartTime().getDayOfWeek()));
+ }
+ row.setRemainStock(row.getStock());
+ }
+ return rows;
+ }
+
+ private static String weekdayLabel(DayOfWeek dayOfWeek) {
+ return switch (dayOfWeek) {
+ case MONDAY -> "周一";
+ case TUESDAY -> "周二";
+ case WEDNESDAY -> "周三";
+ case THURSDAY -> "周四";
+ case FRIDAY -> "周五";
+ case SATURDAY -> "周六";
+ case SUNDAY -> "周日";
+ };
+ }
+
+ private void fillDailyMenuAdminCombo(List list) {
+ if (CollectionUtils.isEmpty(list)) {
+ return;
+ }
+ List comboIds = list.stream()
+ .filter(v -> Objects.equals(v.getSpuType(), 2) && v.getSpuId() != null)
+ .map(DailyMenuAdminVO::getSpuId)
+ .distinct()
+ .collect(Collectors.toList());
+ Map> comboMap =
+ canteenSpuService.loadComboItemVos(comboIds);
+ for (DailyMenuAdminVO vo : list) {
+ int type = vo.getSpuType() == null ? 1 : vo.getSpuType();
+ vo.setSpuType(type);
+ if (type == 2) {
+ vo.setComboItems(comboMap.getOrDefault(vo.getSpuId(), Collections.emptyList()));
+ }
+ else {
+ vo.setComboItems(Collections.emptyList());
+ }
+ }
+ }
+
private void fillDailyMenuCombo(List list) {
if (CollectionUtils.isEmpty(list)) {
return;
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
index dd0c5266..e2da544c 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenOnlineSpuServiceImpl.java
@@ -202,7 +202,10 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl DyWrapper = Wrappers.lambdaQuery();
- DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime());
- DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType());
- DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId());
- List list = canteenDailyMenuMapper.selectList(DyWrapper);
+ // 单餐段:排除当日该餐段已选菜品,并按分类餐段过滤;多餐段/未指定则不过滤(保存时按餐段去重)
+ if (canteenDailySpuDTO.getHourType() != null) {
+ LambdaQueryWrapper DyWrapper = Wrappers.lambdaQuery();
+ DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime());
+ DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType());
+ DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId());
+ List list = canteenDailyMenuMapper.selectList(DyWrapper);
- List spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList());
- wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds);
+ List spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList());
+ wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds);
- //再次分类餐段过滤
- LambdaQueryWrapper canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
- canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType());
- List canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper);
- List caIds = canteenCategoryEntities.stream().map(CanteenCategoryEntity::getId).collect(Collectors.toList());
+ LambdaQueryWrapper canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
+ canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType());
+ List canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper);
+ List caIds = canteenCategoryEntities.stream().map(CanteenCategoryEntity::getId).collect(Collectors.toList());
- wrapper.in(!CollectionUtils.isEmpty(caIds), CanteenSpuEntity::getCategoryId, caIds);
+ wrapper.in(!CollectionUtils.isEmpty(caIds), CanteenSpuEntity::getCategoryId, caIds);
+ }
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenDailySpuDTO.getMerchantId(), CanteenSpuEntity::getMerchantId);
-// wrapper.orderByAsc(CanteenSpuEntity::getSort);
return baseMapper.selectPageByScope(page, wrapper, DataScope.of().only(true));
}
@@ -422,6 +422,7 @@ public class CanteenSpuServiceImpl extends ServiceImpl wrapper = new LambdaQueryWrapper<>();
wrapper.like(StrUtil.isNotBlank(canteenSpu.getName()), CanteenSpuEntity::getName, canteenSpu.getName());
wrapper.eq(Objects.nonNull(canteenSpu.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenSpu.getCategoryId());
+ applyHourTypeCategoryFilter(wrapper, canteenSpu.getHourType());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenSpu.getMerchantId(), CanteenSpuEntity::getMerchantId);
wrapper.orderByAsc(CanteenSpuEntity::getSort).orderByDesc(CanteenSpuEntity::getId);
@@ -438,6 +439,23 @@ public class CanteenSpuServiceImpl extends ServiceImpl wrapper, Integer hourType) {
+ if (hourType == null) {
+ return;
+ }
+ List caIds = canteenCategoryMapper.selectList(Wrappers.lambdaQuery()
+ .like(CanteenCategoryEntity::getCategoryType, hourType))
+ .stream()
+ .map(CanteenCategoryEntity::getId)
+ .collect(Collectors.toList());
+ if (CollectionUtils.isEmpty(caIds)) {
+ wrapper.eq(CanteenSpuEntity::getId, -1L);
+ return;
+ }
+ wrapper.in(CanteenSpuEntity::getCategoryId, caIds);
+ }
+
@Override
public CanteenSpuEntity getDetail(Long id) {
CanteenSpuEntity entity = baseMapper.selectById(id);
@@ -467,7 +485,10 @@ public class CanteenSpuServiceImpl extends ServiceImpllambdaQuery()
.eq(CanteenSpuComboItemEntity::getComboSpuId, entity.getId()));
@@ -531,7 +554,10 @@ public class CanteenSpuServiceImpl extends ServiceImpl
+
+
diff --git a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java
index 293b6a47..913f1931 100644
--- a/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java
+++ b/pjcloud-common/pjcloud-common-data/src/main/java/com/bsd/pjcloud/common/data/datascope/DataScopeInnerInterceptor.java
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.common.data.datascope;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
-import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
@@ -53,19 +52,23 @@ public class DataScopeInnerInterceptor implements DataScopeInterceptor {
List deptIds = dataScope.getDeptList();
- // 1.无数据权限限制,则直接返回 0 条数据
- if (CollUtil.isEmpty(deptIds) && StrUtil.isBlank(dataScope.getUsername())) {
+ boolean hasDeptScope = CollUtil.isNotEmpty(deptIds);
+ boolean hasUserScope = StrUtil.isNotBlank(dataScope.getUsername())
+ && StrUtil.isNotBlank(dataScope.getScopeUserName());
+
+ // 1.无可用权限条件时直接返回 0 条数据,避免生成非法的 IN (),也不能因本人字段被禁用而放开查询。
+ if (!hasDeptScope && !hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE 1 = 2",
dataScope.getFunc().getType(), originalSql);
}
// 2.如果为本人权限 + 部门权限控制
- else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName()) && CollUtil.isNotEmpty(deptIds)) {
+ else if (hasUserScope && hasDeptScope) {
String join = CollectionUtil.join(deptIds, ",");
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s' OR temp_data_scope.%s IN (%s)",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername(), dataScope.getScopeDeptName(), join);
}
// 3. 如果为本人
- else if (StrUtil.isNotBlank(dataScope.getUsername()) && ObjectUtil.isNotEmpty(dataScope.getScopeUserName())) {
+ else if (hasUserScope) {
originalSql = String.format("SELECT %s FROM (%s) temp_data_scope WHERE temp_data_scope.%s = '%s'",
dataScope.getFunc().getType(), originalSql, dataScope.getScopeUserName(), dataScope.getUsername());
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
index d90891ce..541e4bcf 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
@@ -18,6 +18,9 @@ public enum HardAfterSaleOperateTypeEnum {
/** 商户审核拒绝退款申请。 */
ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"),
+ /** 后台将人工审核完成的退款冲正并退回审核中。 */
+ ADMIN_RETURN_TO_REVIEW("13", "商家将退款退回审核中"),
+
/** 会员补充退货物流信息。 */
MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"),
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
index b28ea530..6633dcee 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
@@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
- EXPIRED_MANUAL("40", "后台逐单过期退款", false);
+ EXPIRED_MANUAL("40", "后台逐单过期退款", false),
+
+ /**
+ * 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
+ */
+ EXPIRED_RESCAN("50", "后台立即重新扫描", true);
/**
* 数据库存储的稳定触发编码。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java
index 2eac5ae6..4ea972c0 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardAuthDto.java
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import lombok.Data;
import java.time.LocalDate;
+import java.util.List;
@Data
-public class HardAuthDto extends HardAuthEntity {
+public class HardAuthDto extends HardAuthEntity implements BaseUserIdentityQueryInterface {
+
+ /** 身份预筛选后用于授权记录查询的用户主键集合。 */
+ private List userIds;
+
+ /** UPMS 用户身份协议编码集合;为空时不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
private LocalDate[] createTimeList;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java
index 52af6d62..f15ab986 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardBroadcastDto.java
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import lombok.Data;
import java.time.LocalDate;
+import java.util.List;
@Data
-public class HardBroadcastDto extends HardBroadcastEntity {
+public class HardBroadcastDto extends HardBroadcastEntity implements BaseUserIdentityQueryInterface {
+
+ /** 身份预筛选后用于广播记录查询的用户主键集合。 */
+ private List userIds;
+
+ /** UPMS 用户身份协议编码集合;为空时不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
private LocalDate[] createTimeList;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java
index 37ec0c8f..b2c852e4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardDoorFlowDto.java
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import lombok.Data;
import java.time.LocalDate;
+import java.util.List;
@Data
-public class HardDoorFlowDto extends HardDoorFlowEntity {
+public class HardDoorFlowDto extends HardDoorFlowEntity implements BaseUserIdentityQueryInterface {
+
+ /** 身份预筛选后用于开门流程查询的用户主键集合。 */
+ private List userIds;
+
+ /** UPMS 用户身份协议编码集合;为空时不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
private LocalDate[] createTimeList;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java
index 28d6ec52..e90e5bf4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardInvoiceDto.java
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import lombok.Data;
@@ -7,12 +10,24 @@ import java.time.LocalDate;
import java.util.List;
@Data
-public class HardInvoiceDto extends HardInvoiceEntity {
+public class HardInvoiceDto extends HardInvoiceEntity implements BaseUserIdentityQueryInterface {
private List deptIds;
private List userIds;
+ /** UPMS 用户身份协议编码集合;为空时不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
+
private List merchantIds;
private String hardType;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java
index 10450a42..e113b130 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAfterSaleDto.java
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto.order;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import lombok.Data;
@@ -7,13 +10,41 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
+/** 硬件售后订单后台查询条件,订单侧字段仅用于关联查询,不对应售后表持久化列。 */
@Data
-public class HardAfterSaleDto extends HardAfterSaleEntity {
+public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
+ /** 售后申请用户主键集合;为空时不限制申请人。 */
private List userIds;
+ /** UPMS 用户身份协议编码集合;为空时不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
+
+ /** 售后申请日期闭区间;为空时不限制申请日期。 */
private LocalDate[] createTimeList;
+ /** 当前账号数据范围内允许查询的订单商户主键集合;为空时不限制商户。 */
private List mchIds;
+ /** 关联订单的状态字典编码集合;为空时不限制订单状态。 */
+ private List orderStatusCodes;
+
+ /** 关联订单的类型字典编码;为空时不限制订单类型。 */
+ private String orderType;
+
+ /** 关联订单的用餐餐段字典编码集合;为空时不限制餐段。 */
+ private List hourTypeCodes;
+
+ /** 关联订单预计使用日期闭区间,页面按开始日零点至结束日最后一秒传入。 */
+ private LocalDateTime[] apptUseDateTimeList;
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java
new file mode 100644
index 00000000..a0e120b2
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundPreviewDto.java
@@ -0,0 +1,25 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 后台批量退款预览请求,只接收列表页已经勾选的订单主键。
+ */
+@Data
+@Schema(description = "后台批量退款预览请求")
+public class HardBatchRefundPreviewDto {
+
+ /**
+ * 待预览的订单主键,服务端会去重并重新校验数据权限和退款资格,单次最多 1000 笔。
+ */
+ @NotEmpty(message = "请选择需要退款的订单")
+ @Size(max = 1000, message = "单次最多选择1000个订单")
+ @Schema(description = "待退款订单主键,单次最多1000笔")
+ private List<@NotNull(message = "订单id不能为空") Long> orderIds;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java
new file mode 100644
index 00000000..bac72a75
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardBatchRefundSubmitDto.java
@@ -0,0 +1,24 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 后台批量退款提交请求,在预览订单集合之外补充本批次统一备注。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "后台批量退款提交请求")
+public class HardBatchRefundSubmitDto extends HardBatchRefundPreviewDto {
+
+ /**
+ * 本批次退款备注,同时写入每笔售后单的申请原因和补充描述,去除首尾空白后不能为空。
+ */
+ @NotBlank(message = "请输入退款备注")
+ @Size(max = 200, message = "退款备注不能超过200个字符")
+ @Schema(description = "本批次统一退款备注,最多200个字符")
+ private String remark;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
index d16c4ae4..b0c91866 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
@@ -22,10 +22,13 @@ import java.util.List;
@Schema(description = "食堂用户预约查询条件")
public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
- /** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */
+ /**
+ * 统计日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。
+ * 核销状态为 VERIFIED 时匹配实际核销时间,其他状态匹配预约用餐日期。
+ */
@NotNull(message = "用餐日期范围不能为空")
@Size(min = 2, max = 2, message = "用餐日期范围必须包含开始和结束日期")
- @Schema(description = "用餐日期范围,固定为开始日期和结束日期", requiredMode = Schema.RequiredMode.REQUIRED)
+ @Schema(description = "统计日期范围,已核销取核销日期,其他状态取预约用餐日期", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDate[] apptUseDateList;
/** 用户姓名、登录账号或手机号的模糊查询关键字;为空时不限制。 */
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
index d5699377..37a48164 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
@@ -52,6 +52,9 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
private List userDeptIds;
+ /** 用户主键筛选集合;为空表示不限制用户范围,供菜品统计及明细查询复用。 */
+ private List userIds;
+
private Boolean isInclude;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
index a25062a1..612703dd 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import lombok.Data;
@@ -9,8 +12,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
+/** 硬件订单后台查询条件,身份字段用于按订单用户的身份资料筛选。 */
@Data
-public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface {
+public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY;
@@ -34,6 +38,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List mchTypeCodes;
+ /** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */
+ private List merchantTypeCodes;
+
private List orderStatusCodes;
private LocalDateTime[] createTimeList;
@@ -78,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List hardTypeCodes;
+ /** 用餐时间段字典编码集合;为空表示不限制餐段。 */
+ private List hourTypeCodes;
+
private List refundStatusCodes;
/**
@@ -131,4 +141,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
Boolean apptIdOrApptRuleIdNotNull = false;
+
+ /** 用户身份协议编码集合;为空表示不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展筛选条件。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展筛选条件。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
+ private String hasFace;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java
index 94e3622f..eebb0bd5 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/spu/HardSpuRepertoryDto.java
@@ -1,18 +1,37 @@
package com.bsd.pjcloud.hard.api.dto.spu;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
+/** 硬件商品库存更新记录查询条件,支持按上架用户身份快速筛选。 */
@Data
-public class HardSpuRepertoryDto extends HardSpuRepertoryEntity {
+public class HardSpuRepertoryDto extends HardSpuRepertoryEntity implements BaseUserIdentityQueryInterface {
+ /** 库存更新记录创建时间闭区间;数组长度为 2 时生效。 */
private LocalDateTime[] createTimeList;
+ /** 身份预筛选后用于库存记录查询的上架用户主键集合。 */
private List userIds;
+ /** 目标柜门主键;为空时不限制柜门。 */
private Long cabinetId;
+ /** UPMS 用户身份协议编码集合;为空表示不限制身份。 */
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
+ private String hasFace;
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
index d124723d..1665a719 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
@@ -163,11 +163,11 @@ public class HardOrderEntity extends Model {
@Schema(description = "过期预约退款配置ID快照")
private Long expiredRefundConfigId;
- /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
- @Schema(description = "过期预约餐段实际结束时间快照")
+ /** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */
+ @Schema(description = "过期预约用餐日核销结束时间快照")
private LocalDateTime expiredRefundUsageEndTime;
- /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
+ /** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */
@Schema(description = "过期预约退款触发时间")
private LocalDateTime expiredRefundDeadline;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java
index 4baf063e..30347ec5 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardAuthVo.java
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
-public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface {
+public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,10 +29,22 @@ public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, Bas
private String userMobile;
+ /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
+ private String phone;
+
private String userAvatar;
private Long userDeptId;
private String userDeptName;
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java
index 22967938..12d3a11e 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardBroadcastVo.java
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
-public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface {
+public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,12 +29,24 @@ public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInte
private String userMobile;
+ /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
+ private String phone;
+
private String userAvatar;
private Long userDeptId;
private String userDeptName;
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
/**
* 订单相关
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java
index ec71266b..f0974aa8 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardDoorFlowVo.java
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
@@ -9,7 +12,7 @@ import lombok.Data;
import java.util.List;
@Data
-public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface {
+public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -27,12 +30,24 @@ public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterf
private String userMobile;
+ /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
+ private String phone;
+
private String userAvatar;
private Long userDeptId;
private String userDeptName;
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
private HardAuthEntity hardAuth;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java
index 0cd57feb..77a0a1d1 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardInvoiceVo.java
@@ -1,7 +1,10 @@
package com.bsd.pjcloud.hard.api.vo;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
-import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
@@ -9,9 +12,10 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
import java.time.LocalDateTime;
+import java.util.List;
@Data
-public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface, BaseUserInterface {
+public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface {
private Long historyId;
@@ -72,6 +76,24 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterfac
private String userMobile;
+ /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
+ private String phone;
+
private String userAvatar;
+ /** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
+ private Long userDeptId;
+
+ /** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
+ private String userDeptName;
+
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java
index c977c212..d1dc7286 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleLogVo.java
@@ -1,8 +1,21 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import lombok.EqualsAndHashCode;
+/**
+ * App 售后进度记录,在持久化日志之外补充当前节点对应的退款原因。
+ */
@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "App 售后进度记录")
public class HardAfterSaleLogVo extends HardAfterSaleLogEntity {
+
+ /**
+ * 当前进度节点对应的退款或拒绝原因;无可展示原因时为空,不写入售后日志表。
+ */
+ @Schema(description = "当前进度节点对应的退款原因")
+ private String reason;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
index 49b3e4ad..837f2905 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
@@ -1,15 +1,27 @@
package com.bsd.pjcloud.hard.api.vo.order;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
+import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
import lombok.Data;
+import java.time.LocalDate;
import java.time.LocalDateTime;
+import java.util.List;
+/**
+ * 硬件订单售后详情。
+ *
+ * 同时保留历史用户、用户部门和用户身份协议,兼容售后详情的旧用户装配逻辑及管理端身份展示。
+ */
@Data
-public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface {
+public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface {
/**
* 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。
*/
@@ -40,8 +52,26 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
private String userMobile;
+ /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
+ private String phone;
+
private String userAvatar;
+ /** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
+ private Long userDeptId;
+
+ /** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
+ private String userDeptName;
+
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
/**
* 部门相关
*/
@@ -71,4 +101,14 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
private Integer orderPayPrice;
private LocalDateTime orderPayTime;
+
+ /**
+ * 原订单预约用餐日期,来源于硬件订单已有的预约日期字段;非预约订单为空,不对应售后表持久化字段。
+ */
+ private LocalDate orderApptUseDate;
+
+ /**
+ * 当前售后单申请时保存的商品快照,用于审批详情展示本次退款商品及份数;历史未保存明细时为空列表。
+ */
+ private List carList;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java
new file mode 100644
index 00000000..3ff8bac2
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardBatchRefundPreviewVo.java
@@ -0,0 +1,156 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 后台批量退款预览结果,按订单展示服务端实时核定的剩余可退商品和金额。
+ */
+@Data
+@Schema(description = "后台批量退款预览结果")
+public class HardBatchRefundPreviewVo {
+
+ /**
+ * 当前预览范围的订单、人员、商品和金额汇总,不包含无权限或不存在的订单。
+ */
+ @Schema(description = "批量退款汇总")
+ private Summary summary;
+
+ /**
+ * 按服务端订单排序返回的预览明细;不可退款订单保留原因但不会进入实际退款任务。
+ */
+ @Schema(description = "批量退款订单明细")
+ private List orders;
+
+ /**
+ * 批量退款预览汇总,数量和金额仅累计当前仍可退款的订单。
+ */
+ @Data
+ @Schema(description = "批量退款预览汇总")
+ public static class Summary {
+
+ /** 当前请求中通过数据权限校验的订单数量。 */
+ @Schema(description = "预览订单数")
+ private Integer selectedOrderCount;
+
+ /** 当前校验通过并会进入退款任务的订单数量。 */
+ @Schema(description = "可退款订单数")
+ private Integer refundableOrderCount;
+
+ /** 因状态、渠道或剩余金额不满足要求而跳过的订单数量。 */
+ @Schema(description = "不可退款订单数")
+ private Integer unavailableOrderCount;
+
+ /** 可退款订单中的去重订餐人数,未知用户不计入。 */
+ @Schema(description = "订餐人数")
+ private Integer userCount;
+
+ /** 可退款订单中按商品主键去重后的菜品种类数。 */
+ @Schema(description = "菜品种类数")
+ private Integer productKindCount;
+
+ /** 可退款数量商品的剩余总份数,不包含称重商品。 */
+ @Schema(description = "剩余可退商品总份数")
+ private Long productCount;
+
+ /** 可退款称重商品的剩余总重量,单位沿用购物车重量协议。 */
+ @Schema(description = "剩余可退商品总重量")
+ private BigDecimal productWeight;
+
+ /** 可退款订单的剩余可退总金额,单位为分。 */
+ @Schema(description = "剩余可退总金额,单位分")
+ private Long refundPrice;
+ }
+
+ /**
+ * 单笔订单的批量退款预览信息。
+ */
+ @Data
+ @Schema(description = "批量退款订单预览明细")
+ public static class OrderItem {
+
+ /** Hardware 订单主键,也是异步任务逐笔退款的稳定业务标识。 */
+ @Schema(description = "订单主键")
+ private Long orderId;
+
+ /** 订单业务编号;历史数据可能为空。 */
+ @Schema(description = "订单业务编号")
+ private String orderNo;
+
+ /** 优先取预约用餐日期,其次取支付日期,最后取订单创建日期。 */
+ @Schema(description = "退款预览业务日期")
+ private LocalDate businessDate;
+
+ /** 用餐餐段字典编码;非餐饮或历史订单可能为空。 */
+ @Schema(description = "用餐餐段字典编码")
+ private String hourType;
+
+ /** 实际订餐用户主键;匿名或历史订单可能为空。 */
+ @Schema(description = "订餐用户主键")
+ private Long userId;
+
+ /** 实际订餐人姓名;无法关联用户资料时为空。 */
+ @Schema(description = "订餐人姓名")
+ private String username;
+
+ /** 订餐人下单时关联部门名称;历史数据可能为空。 */
+ @Schema(description = "订餐人部门名称")
+ private String userDeptName;
+
+ /** 订单所属商户名称;无法关联商户资料时为空。 */
+ @Schema(description = "商户名称")
+ private String merchantName;
+
+ /** 当前已完成退款后剩余可退金额,单位为分。 */
+ @Schema(description = "剩余可退金额,单位分")
+ private Integer refundPrice;
+
+ /** 是否通过当前批量退款资格校验。 */
+ @Schema(description = "是否可以退款")
+ private Boolean refundable;
+
+ /** 不可退款原因;可退款时为空。 */
+ @Schema(description = "不可退款原因")
+ private String unavailableReason;
+
+ /** 当前订单扣除待审核和已完成售后占用后的剩余商品。 */
+ @Schema(description = "剩余可退商品")
+ private List products;
+ }
+
+ /**
+ * 单个商品的剩余可退数量、重量和商品金额。
+ */
+ @Data
+ @Schema(description = "批量退款商品预览明细")
+ public static class ProductItem {
+
+ /** 原订单购物车快照中的商品主键。 */
+ @Schema(description = "商品主键")
+ private Long productId;
+
+ /** 原订单购物车快照中的商品名称,不回查可能已改名的商品主数据。 */
+ @Schema(description = "商品名称")
+ private String productName;
+
+ /** 商品性质字典编码,用于区分数量商品和称重商品。 */
+ @Schema(description = "商品性质字典编码")
+ private String productNature;
+
+ /** 数量商品的剩余可退份数;称重商品固定为零。 */
+ @Schema(description = "剩余可退份数")
+ private Integer productCount;
+
+ /** 称重商品的剩余可退重量;数量商品固定为零。 */
+ @Schema(description = "剩余可退重量")
+ private BigDecimal productWeight;
+
+ /** 原购物车商品总价扣除有效售后占用后的剩余可退金额,单位为分。 */
+ @Schema(description = "剩余可退商品金额,单位分")
+ private Long refundPrice;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
index 96f057a1..843d866e 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
@@ -11,6 +11,11 @@ import lombok.Data;
@Data
public class HardCanteenProductApptExportRecord {
+ /**
+ * 导出聚合层级,固定为 SUMMARY、MERCHANT 或 PRODUCT;仅用于一次查询后拆分三级结果,不持久化。
+ */
+ private String recordLevel;
+
/** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
private String statisticsDate;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java
index 2fbd187d..3e7e1f5b 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java
@@ -3,8 +3,13 @@ package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import java.util.ArrayList;
+import java.util.List;
+
/**
- * 用户预约按餐段和菜品快照聚合的统计数据。
+ * 用户预约按餐段、商品 ID 和下单菜品名称快照聚合的统计数据。
+ *
+ * 商品 ID 相同但名称快照不同的历史订单会拆分展示,避免商品 ID 被菜谱复用后串入错误名称。
*/
@Data
@Schema(description = "食堂用户预约菜品统计")
@@ -18,6 +23,14 @@ public class HardCanteenUserApptDishStatisticsVo {
@Schema(description = "菜品名称")
private String productName;
+ /** 当前名称快照统计项实际涉及的商品 ID;正常数据仅有一个,历史商品 ID 为空时不写入集合。 */
+ @Schema(description = "商品ID集合")
+ private List productIds = new ArrayList<>();
+
+ /** 下单时固化的菜品图片地址;历史订单未保存图片时为空。 */
+ @Schema(description = "菜品图片地址")
+ private String productPic;
+
/** 菜品快照的总份数。 */
@Schema(description = "菜品份数")
private Long productCount = 0L;
@@ -29,4 +42,8 @@ public class HardCanteenUserApptDishStatisticsVo {
/** 该菜品快照总价,单位为分。 */
@Schema(description = "菜品总价,单位为分")
private Long totalPrice = 0L;
+
+ /** 点过该菜品的用户及各自净份数;按当前 UPMS 权限用户顺序返回。 */
+ @Schema(description = "菜品点餐人员明细")
+ private List users = new ArrayList<>();
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java
new file mode 100644
index 00000000..88cd8b4b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishUserStatisticsVo.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 用户在指定餐段和菜品统计项下的点餐份数。
+ */
+@Data
+@Schema(description = "食堂用户预约菜品点餐人员统计")
+public class HardCanteenUserApptDishUserStatisticsVo {
+
+ /** 系统用户主键,来源于当前操作者数据权限范围内的 UPMS 用户。 */
+ @Schema(description = "用户主键")
+ private Long userId;
+
+ /** 页面展示姓名,依次取姓名、昵称和登录账号。 */
+ @Schema(description = "用户姓名")
+ private String name;
+
+ /** 用户登录账号,用于同名人员识别;未维护时为空。 */
+ @Schema(description = "登录账号")
+ private String username;
+
+ /** 用户当前所属部门名称;未分配部门时为空。 */
+ @Schema(description = "部门名称")
+ private String deptName;
+
+ /** 该用户在当前餐段和菜品统计项下完成退款扣减后的净份数。 */
+ @Schema(description = "用户点餐份数")
+ private Long productCount = 0L;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
index 9e12c442..2f52cb63 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
+import java.time.LocalDate;
import java.time.LocalDateTime;
/**
@@ -20,6 +21,10 @@ public class HardCanteenUserApptOrderVo {
@Schema(description = "用餐时间段字典编码")
private String hourType;
+ /** 订单预约用餐日期;已核销按核销日查询时用于保留原预约日期,历史未记录时为空。 */
+ @Schema(description = "预约用餐日期")
+ private LocalDate apptUseDate;
+
/** 下单时保存的商品描述;历史数据未记录时为空。 */
@Schema(description = "订餐内容")
private String productDesc;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java
index 0c85a200..ff08760a 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java
@@ -47,7 +47,7 @@ public class HardCanteenUserApptStatisticsSummaryVo {
@Schema(description = "餐段统计")
private List meals = new ArrayList<>();
- /** 按餐段和菜品名称聚合的菜品份数。 */
+ /** 按餐段、商品 ID 和下单名称快照聚合的菜品份数;历史商品 ID 缺失时按菜品名称兜底。 */
@Schema(description = "菜品统计")
private List dishes = new ArrayList<>();
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java
index 032c03cc..50718668 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java
@@ -92,8 +92,8 @@ public class HardCanteenUserApptVo implements BaseUserDeptInterface, BaseUserIde
@Schema(description = "老师身份扩展信息")
private SysUserTeacherDto teacherInfo;
- /** 本行对应的用餐日期。 */
- @Schema(description = "用餐日期")
+ /** 本行统计日期;已核销筛选取实际核销日期,其他筛选取预约用餐日期。 */
+ @Schema(description = "统计日期,已核销取核销日期,其他状态取预约用餐日期")
private LocalDate apptUseDate;
/** 订餐状态,RESERVED 表示当前筛选口径存在有效预约,NOT_RESERVED 表示不存在。 */
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java
new file mode 100644
index 00000000..dcfc0c56
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardExpiredRefundRescanResultVo.java
@@ -0,0 +1,36 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 商户过期预约退款立即重扫结果,供管理端展示本轮任务补建和退款执行数量。
+ */
+@Data
+@Schema(description = "过期预约退款立即重扫结果")
+public class HardExpiredRefundRescanResultVo {
+
+ /** 本轮读取的已支付、未核销每日菜谱订单数量。 */
+ @Schema(description = "扫描订单数量")
+ private Integer scannedOrderCount;
+
+ /** 本轮首次补建的过期预约退款任务数量。 */
+ @Schema(description = "新建任务数量")
+ private Integer createdTaskCount;
+
+ /** 本轮按当前延迟和退库存配置刷新的未尝试任务数量。 */
+ @Schema(description = "刷新任务数量")
+ private Integer refreshedTaskCount;
+
+ /** 本轮已到达当前触发时间并立即进入退款状态机的任务数量。 */
+ @Schema(description = "立即执行任务数量")
+ private Integer executedTaskCount;
+
+ /** 本轮立即执行后已完成钱包退款和 Hardware 状态更新的任务数量。 */
+ @Schema(description = "退款成功任务数量")
+ private Integer successTaskCount;
+
+ /** 因餐段时间无法解析、已有资金尝试或存在冲突业务状态而未同步的订单数量。 */
+ @Schema(description = "跳过订单数量")
+ private Integer skippedOrderCount;
+}
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 ffe257dd..1fe36132 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
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
@@ -11,8 +14,9 @@ import lombok.Data;
import java.time.LocalTime;
import java.util.List;
+/** 硬件订单管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
-public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface {
+public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface, BaseHardInterface {
/** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */
private String saleSource;
@@ -26,8 +30,20 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
private String userMobile;
+ /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
+ private String phone;
+
private String userAvatar;
+ /** 用户身份协议编码集合;没有身份时为空集合或空值。 */
+ private List identityTypes;
+
+ /** 学生身份扩展资料;没有学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展资料;没有老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
/**
* 部门相关
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java
index c4cb50a6..74fabef3 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java
@@ -9,7 +9,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
/**
- * 用户和用餐日期维度的食堂预约导出行。
+ * 用户和统计日期维度的食堂预约导出行。
*/
@Data
@ColumnWidth(22)
@@ -63,8 +63,8 @@ public class HardCanteenUserApptExcelVo {
@ExcelProperty({"老师身份", "老师学号"})
private String teacherNo;
- /** 查询指定的用餐日期。 */
- @ExcelProperty({"预约信息", "用餐日期"})
+ /** 查询指定的统计日期;已核销筛选取核销日期,其他状态取预约用餐日期。 */
+ @ExcelProperty({"预约信息", "统计日期"})
private LocalDate apptUseDate;
/** 是否存在符合当前食堂、餐段筛选的有效预约。 */
@@ -103,12 +103,12 @@ public class HardCanteenUserApptExcelVo {
@ExcelProperty({"金额统计", "餐段总金额(元)"})
private String mealSegmentTotalPrices;
- /** 用户本行日期内有效预约商品总价,单位为元并保留两位小数。 */
- @ExcelProperty({"金额统计", "当日总金额(元)"})
+ /** 用户本行统计日期内完成退款扣减后的预约商品净额,单位为元并保留两位小数。 */
+ @ExcelProperty({"金额统计", "统计日净额(元)"})
@NumberFormat("#,##0.00")
private BigDecimal totalPrice;
- /** 用户当天有效预约的整体核销状态。 */
+ /** 用户在本行统计日期和当前筛选口径内有效预约的整体核销状态。 */
@ExcelProperty({"核销信息", "核销状态"})
private String verificationStatus;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java
new file mode 100644
index 00000000..8f4a4c03
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java
@@ -0,0 +1,166 @@
+package com.bsd.pjcloud.hard.api.vo.order.excel;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.alibaba.excel.annotation.format.NumberFormat;
+import com.alibaba.excel.annotation.write.style.ColumnWidth;
+import com.alibaba.excel.annotation.write.style.ContentFontStyle;
+import com.alibaba.excel.annotation.write.style.HeadFontStyle;
+import com.alibaba.excel.annotation.write.style.HeadRowHeight;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 硬件订单明细导出行,按订单业务域组织二级表头。
+ *
+ * 仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段;
+ * 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。
+ */
+@Data
+@ColumnWidth(15)
+@HeadRowHeight(15)
+@HeadFontStyle(fontHeightInPoints = 10)
+@ContentFontStyle(fontHeightInPoints = 10)
+public class HardOrderExportExcelVo {
+
+ /** 当前订单在本次导出结果中的一基序号;合计行为空。 */
+ @ExcelProperty({"订单", "序号"})
+ private String lineNumber;
+
+ /** 订单类型中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "订单类型"})
+ private String orderType;
+
+ /** 订单状态中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "订单状态"})
+ private String orderStatus;
+
+ /** 统计场景中的订单来源名称;不属于统计来源时为空。 */
+ @ExcelProperty({"订单", "订单来源"})
+ private String saleSourceName;
+
+ /** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"订单", "下单时间"})
+ @ColumnWidth(22)
+ private String createTime;
+
+ /** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和订单历史展示名。 */
+ @ExcelProperty({"用户", "姓名"})
+ private String userName;
+
+ /** 用户默认身份手机号;UPMS 资料缺失时回退订单历史手机号。 */
+ @ExcelProperty({"用户", "手机号"})
+ private String phone;
+
+ /** 用户当前所属部门名称;UPMS 资料缺失时回退订单历史部门名称。 */
+ @ExcelProperty({"用户", "部门"})
+ private String deptName;
+
+ /** 用户岗位名称,多个岗位使用中文逗号分隔。 */
+ @ExcelProperty({"用户", "岗位"})
+ private String postNames;
+
+ /** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
+ @ExcelProperty({"身份", "身份类型"})
+ private String identityTypeNames;
+
+ /** 学生身份学号;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "学号"})
+ private String studentNo;
+
+ /** 学生身份学校名称;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "学校"})
+ private String schoolName;
+
+ /** 学生身份班级名称;没有学生身份时为空。 */
+ @ExcelProperty({"身份", "班级"})
+ private String className;
+
+ /** 学生身份监护人手机号,多个号码沿用业务存储格式。 */
+ @ExcelProperty({"身份", "监护人手机号"})
+ private String guardianPhones;
+
+ /** 老师身份工号或学号;没有老师身份时为空。 */
+ @ExcelProperty({"身份", "老师学号"})
+ private String teacherNo;
+
+ /** 钱包服务返回的商户名称;历史商户不存在时回退订单查询结果。 */
+ @ExcelProperty({"商户设备", "商户"})
+ private String merchantName;
+
+ /** 订单关联设备名称;预约核销订单中用于识别核销机器。 */
+ @ExcelProperty({"商户设备", "核销机器"})
+ private String hardName;
+
+ /** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"商户设备", "设备类型"})
+ private String hardType;
+
+ /** 订单商品摘要,来源于下单时保存的商品描述。 */
+ @ExcelProperty({"订单", "商品内容"})
+ @ColumnWidth(24)
+ private String productDesc;
+
+ /** 用餐时间段中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"订单", "用餐时段"})
+ private String hourType;
+
+ /** 预约目标用餐日期;非预约订单可能为下单日期或空值,页面统一称为预约时间。 */
+ @ExcelProperty({"订单", "预约时间"})
+ @ColumnWidth(22)
+ private LocalDate apptUseDate;
+
+ /** 支付方式中文名称;未知历史编码保留原始值。 */
+ @ExcelProperty({"支付", "支付方式"})
+ private String payType;
+
+ /** 商品总价,单位为元并保留两位小数。 */
+ @ExcelProperty({"支付", "商品总价(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal totalPrice;
+
+ /** 额外费用,单位为元并保留两位小数。 */
+ @ExcelProperty({"支付", "额外支付(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal extraPrice;
+
+ /** 配送费用,单位为元并保留两位小数。 */
+ @ExcelProperty({"支付", "配送费(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal deliveryPrice;
+
+ /** 实际支付金额,单位为元并保留两位小数。 */
+ @ExcelProperty({"支付", "实际支付(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal payPrice;
+
+ /** 订单实际金额,单位为元,按实际支付金额扣除退款金额计算。 */
+ @ExcelProperty({"支付", "订单实际(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal orderActualPrice;
+
+ /** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */
+ @ExcelProperty({"支付", "支付时间"})
+ @ColumnWidth(22)
+ private String payTime;
+
+ /** 订单实际核销完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"核销", "核销时间"})
+ @ColumnWidth(22)
+ private String verificationTime;
+
+ /** 订单退款状态中文名称;未记录时为空,未知历史编码保留原始值。 */
+ @ExcelProperty({"退款", "退款状态"})
+ private String refundStatus;
+
+ /** 已退款金额,单位为元并保留两位小数。 */
+ @ExcelProperty({"退款", "退款金额(元)"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal refundPrice;
+
+ /** 退款完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
+ @ExcelProperty({"退款", "退款完成时间"})
+ @ColumnWidth(22)
+ private String refundTime;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java
index 052836be..58a369c3 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/spu/HardSpuRepertoryVo.java
@@ -1,59 +1,128 @@
package com.bsd.pjcloud.hard.api.vo.spu;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
+import java.util.List;
+
+/** 硬件商品库存更新记录展示对象,包含上架用户主资料、部门和身份信息。 */
@Data
-public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseHardInterface {
+public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseHardInterface {
/**
* 商品相关
*/
+ /** 本次更新对应的商品名称。 */
private String spuName;
+ /** 商品分类主键。 */
private Long spuCategoryId;
+ /** 商品所属商户主键。 */
private Long spuMchId;
+ /** 商品所在硬件柜门数量。 */
private Long hardDoorNumber;
/**
* 分类相关
*/
+ /** 商品分类名称。 */
private String spuCategoryName;
/**
* 商户相关
*/
+ /** 商品所属商户主键,供公共商户展示契约读取。 */
private Long merchantId;
+ /** 商品所属商户名称。 */
private String merchantName;
+ /** 商品所属商户 Logo 地址。 */
private String logoPic;
+ /** 商品所属商户类型协议编码。 */
private String merchantType;
/**
* 用户相关
*/
+ /** 上架用户姓名,保留历史接口字段名。 */
private String username;
+ /** 上架用户手机号,保留历史接口字段名。 */
private String userMobile;
+ /** 上架用户头像地址,保留历史接口字段名。 */
private String userAvatar;
+ /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
+ private String phone;
+
+ /** 用户当前所属部门主键,由 UPMS 用户资料回填。 */
+ private Long userDeptId;
+
+ /** 用户当前所属部门名称,由 UPMS 用户资料回填。 */
+ private String userDeptName;
+
+ /** 用户所属部门展示名称,供用户信息单元格读取。 */
+ private String deptName;
+
+ /** 用户拥有的 UPMS 身份协议编码集合。 */
+ private List identityTypes;
+
+ /** 用户的学生身份扩展资料;无学生身份时为空。 */
+ private SysUserStudentDto studentInfo;
+
+ /** 用户的老师身份扩展资料;无老师身份时为空。 */
+ private SysUserTeacherDto teacherInfo;
+
+ /** 获取 UPMS 返回的用户当前部门主键。 */
+ @Override
+ public Long getUserDeptId() {
+ return userDeptId;
+ }
+
+ /** 保存 UPMS 返回的用户当前部门主键。 */
+ @Override
+ public void setUserDeptId(Long userDeptId) {
+ this.userDeptId = userDeptId;
+ }
+
+ /** 获取 UPMS 返回的用户当前部门名称。 */
+ @Override
+ public String getUserDeptName() {
+ return userDeptName;
+ }
+
+ /** 保存 UPMS 返回的用户当前部门名称,并同步公共展示字段。 */
+ @Override
+ public void setUserDeptName(String userDeptName) {
+ this.userDeptName = userDeptName;
+ this.deptName = userDeptName;
+ }
+
/**
* 设备相关
*/
+ /** 执行库存更新的硬件主键。 */
private Long hardId;
+ /** 执行库存更新的硬件名称。 */
private String hardName;
+ /** 执行库存更新的硬件类型协议编码。 */
private String hardType;
+ /** 执行库存更新的硬件设备编号。 */
private String deviceCode;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
index 639db090..9f8b3a75 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAfterSaleController.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.collection.CollUtil;
@@ -19,9 +20,12 @@ import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleLogEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleLogVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAfterSaleVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
+import com.bsd.pjcloud.hard.service.HardBatchRefundService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -51,6 +55,9 @@ public class HardAfterSaleController {
private final HardAfterSaleService hardAfterSaleService;
+ /** 批量退款服务,负责权限预览和最多 1000 笔订单的异步逐笔退款。 */
+ private final HardBatchRefundService hardBatchRefundService;
+
private final RemoteMchService remoteMchService;
/**
@@ -99,12 +106,18 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.cancelAfterSale(user.getId(), id));
}
+ /**
+ * 查询当前 App 用户可见的售后进度,并在对应业务节点返回退款原因。
+ *
+ * @param id 售后单 ID
+ * @return 带节点退款原因的售后进度
+ */
@ApiCryptoAes
@GetMapping(value = "/app/history")
@Operation(summary = "app查询售后进度")
- public R> appHistory(@RequestParam Long id) {
+ public R> appHistory(@RequestParam Long id) {
hardAfterSaleService.validateAppAfterSaleAccess(id, SecurityUtils.getUser().getId());
- return R.ok(hardAfterSaleService.getHistory(id));
+ return R.ok(hardAfterSaleService.getAppHistory(id));
}
@ApiCryptoAes
@@ -168,6 +181,34 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.adminReinforceRefund(dto, user.getId()));
}
+ /**
+ * 按当前后台用户的数据范围预览勾选订单的日期、餐段、订餐人、剩余商品和退款金额。
+ *
+ * @param dto 列表页跨页勾选的订单主键,单次最多 1000 笔
+ * @return 当前时点服务端重新核定的批量退款预览
+ */
+ @PostMapping(value = "/batch-refund/preview")
+ @Operation(summary = "后台批量退款预览")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R batchRefundPreview(@Valid @RequestBody HardBatchRefundPreviewDto dto) {
+ return R.ok(hardBatchRefundService.preview(dto, SecurityUtils.getUser()));
+ }
+
+ /**
+ * 提交批量退款异步任务,每笔订单沿用现有整单退款订单锁和独立事务。
+ *
+ * @param dto 订单主键和本批次必填退款备注
+ * @return 可通过 Hardware 通用进度接口轮询的初始任务状态
+ */
+ @PostMapping(value = "/batch-refund/task")
+ @Operation(summary = "后台批量退款")
+ @SysLog("后台批量退款")
+ @Idempotent(expireTime = 5, info = "批量退款任务正在提交,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) {
+ return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交");
+ }
+
@GetMapping(value = "/history")
@Operation(summary = "查询售后进度")
public R> history(@RequestParam Long id) {
@@ -200,6 +241,20 @@ public class HardAfterSaleController {
return R.ok(hardAfterSaleService.disagreeRefund(user.getId(), dto));
}
+ /**
+ * 将误同意的人工钱包退款退回审核中,并同步冲正钱包入账和退款库存恢复。
+ *
+ * @param id 已完成人工审核的售后单 ID
+ * @return 资金、库存和售后状态均恢复成功时返回 {@code true}
+ */
+ @PutMapping("/return-to-review")
+ @Operation(summary = "退回到审核前")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_change')")
+ @Idempotent(expireTime = 10, key = "'/hard-after-sale/return-to-review' + #id", info = "正在处理中,请勿重复操作")
+ public R returnToReview(@RequestParam Long id) {
+ return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id));
+ }
+
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
index faecc1da..fa5149d9 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
@@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardAutoRefundTaskVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardExpiredRefundRescanResultVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppAutoRefundTaskVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardAutoRefundTaskService;
@@ -62,7 +63,7 @@ public class HardAutoRefundTaskController {
* 按当前用户商户数据范围分页查询自动退款任务。
*
* @param page 分页参数
- * @param dto 筛选条件
+ * @param dto 筛选条件
* @return 任务分页
*/
@GetMapping("/page")
@@ -81,24 +82,40 @@ public class HardAutoRefundTaskController {
}
/**
- * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
+ * 按商户详情页传入的精确商户 ID 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
+ *
+ * @param page 分页参数
+ * @param dto 候选查询条件,其中商户 ID 必填
+ * @return 当前商户的待过期退款订单分页
*/
@GetMapping("/expired-candidates")
@Operation(summary = "查询待过期预约退款订单")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
- public R> expiredCandidates(@ParameterObject Page page,
- @ParameterObject HardAutoRefundTaskPageDto dto) {
- List scopedMchIds = listScopedMerchantIds();
- if (scopedMchIds.isEmpty()) {
- Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
- empty.setRecords(List.of());
- return R.ok(empty);
- }
- dto.setMchIds(scopedMchIds);
+ public R> expiredCandidates(@ParameterObject Page page, @ParameterObject HardAutoRefundTaskPageDto dto) {
+ validateMerchantScope(dto.getMchId());
+ // 详情接口只能查询一个已校验商户,禁止客户端利用内部批量字段扩大查询范围。
+ dto.setMchIds(null);
return R.ok(taskService.pageExpiredCandidates(page, dto));
}
- /** 管理端确认后逐单执行过期预约整单退款。 */
+ /**
+ * 按商户当前配置立即重扫历史过期预约订单;开关关闭时只同步暂停任务,开关开启时执行已到期任务。
+ *
+ * @param mchId 商户详情页传入的精确商户 ID
+ * @return 本轮扫描、任务同步和退款执行数量
+ */
+ @PostMapping("/expired-rescan")
+ @Operation(summary = "立即重新扫描过期预约退款订单")
+ @Idempotent(expireTime = 5, info = "正在重新扫描,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R expiredRescan(@RequestParam Long mchId) {
+ validateMerchantScope(mchId);
+ return R.ok(taskService.rescanExpiredReserveOrders(mchId, SecurityUtils.getUser().getId()));
+ }
+
+ /**
+ * 管理端确认后逐单执行过期预约整单退款。
+ */
@PostMapping("/{id}/expired-refund")
@Operation(summary = "过期预约订单退款")
@Idempotent(expireTime = 5, info = "正在退款,请勿重复操作")
@@ -168,6 +185,26 @@ public class HardAutoRefundTaskController {
}
}
+ /**
+ * 校验单个商户属于当前管理用户数据范围,供商户详情页的精确查询使用。
+ *
+ * @param mchId 详情页传入的商户 ID
+ */
+ private void validateMerchantScope(Long mchId) {
+ if (mchId == null) {
+ throw new RuntimeException("商户不能为空");
+ }
+ MchManageDto query = MchManageDto.builder()
+ .ids(List.of(mchId))
+ .typesList(List.of(MerchantTypeEnum.CANTEEN.getType()))
+ .build();
+ R> response = remoteMchService.scopeList(query);
+ if (response == null || !response.isOk() || response.getData() == null
+ || response.getData().stream().noneMatch(item -> item != null && Objects.equals(item.getId(), mchId))) {
+ throw new RuntimeException("无权访问当前商户");
+ }
+ }
+
/**
* 查询当前管理用户可访问的食堂商户 ID;远程异常拒绝请求,不能降级为全量任务。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
index 26048912..eb5be4a0 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
@@ -15,14 +15,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
-import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
-import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -55,9 +53,6 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCanteenApptTotalController {
- /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */
- private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
-
/**
* 食堂预约统计服务,负责菜品和用户维度的预约聚合。
*/
@@ -68,11 +63,6 @@ public class HardCanteenApptTotalController {
*/
private final HardCanteenApptTotalExportService canteenApptTotalExportService;
- /**
- * 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。
- */
- private final HardCanteenTotalService canteenTotalService;
-
/**
* 商户远程服务,用于限定 App 食堂范围。
*/
@@ -104,49 +94,59 @@ public class HardCanteenApptTotalController {
return R.ok(canteenApptTotalService.productReservTotal(page, dto, null));
}
- /* ************************************* end app end ************************************* */
-
/**
- * 按预约使用日期统计食堂预约部门。
+ * 查询 App 当前可管理食堂范围内的预约菜品及点餐人员统计。
*
- * @param dto 预约统计筛选条件,其中 {@code apptUseDateTimeList} 表示预约使用日期范围
- * @return 按食堂聚合并包含部门、用餐时间段明细的预约统计结果
+ * 食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单,
+ * 并按已完成退款扣减后的净菜品份数聚合。
+ *
+ * @param query 预约日期、餐段和核销状态筛选条件
+ * @return 当前账号可管理食堂内与 PC 核销状态口径一致的菜品和点餐人员汇总
*/
- @Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门")
+ @Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数")
@ApiCryptoAes
- @PostMapping("/list-by-dept")
- public R> listByDept(@RequestBody HardOrderDto dto) {
- // 预约统计必须以预约使用日期为唯一时间口径,避免调用方残留支付时间条件导致交叉筛选。
- dto.setPayTimeList(null);
- dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
- dto.setTotalIsFindChildren(true);
- dto.setApptIdOrApptRuleIdNotNull(true);
- PjcloudUser user = SecurityUtils.getUser();
- return R.ok(canteenTotalService.listByDept(dto, user, null));
+ @PostMapping("/app/user-reserv-statistics")
+ public R appUserReservationStatistics(
+ @Valid @RequestBody HardCanteenUserApptQueryDto query) {
+ return R.ok(canteenApptTotalService.appUserReservationStatistics(query, resolveAppCanteenMerchantIds()));
}
/**
- * 分页查询后台食堂菜品预约统计。
+ * 查询 App 当前可管理食堂范围内按日拆分的预约统计。
*
- * @param page 分页参数
- * @param dto 预约订单筛选条件
- * @return 菜品预约统计分页
+ * @param query 预约日期、餐段和核销状态筛选条件
+ * @return 每日预约统计和查询范围总计;核销状态及日期口径与 PC 人员预约页一致
*/
- @Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计")
- @PostMapping("/proudct-reserv-total")
- public R> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
- // 查询商户
- MchManageDto mchManageDto = new MchManageDto();
- mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
- List mchList = remoteMchService.scopeList(mchManageDto).getData();
- if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
- dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
- }
- dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
- dto.setApptIdOrApptRuleIdNotNull(true);
- return R.ok(canteenApptTotalService.productReservTotal(page, dto, null));
+ @Operation(summary = "App每日预约统计", description = "按当前账号可管理食堂及核销状态统计每日预约")
+ @ApiCryptoAes
+ @PostMapping("/app/user-reserv-daily-statistics")
+ public R appUserReservationDailyStatistics(
+ @Valid @RequestBody HardCanteenUserApptQueryDto query) {
+ return R.ok(canteenApptTotalService.appUserReservationDailyStatistics(query, resolveAppCanteenMerchantIds()));
}
+ /**
+ * 解析当前 App 商户管理员可管理的食堂主键,客户端提交的食堂范围不参与授权判定。
+ *
+ * @return 当前账号可管理的食堂主键;没有可管理食堂时返回空集合
+ */
+ private List resolveAppCanteenMerchantIds() {
+ PjcloudUser user = SecurityUtils.getUser();
+ if (user == null || user.getId() == null) {
+ throw new IllegalStateException("未获取到当前登录用户");
+ }
+ R> merchantResponse = remoteMchService
+ .getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN));
+ if (merchantResponse == null || !merchantResponse.isOk()) {
+ String message = merchantResponse == null ? null : merchantResponse.getMsg();
+ throw new IllegalStateException(ObjectUtil.isEmpty(message) ? "查询可管理食堂范围失败" : message);
+ }
+ List merchantIds = merchantResponse.getData();
+ return ObjectUtil.isEmpty(merchantIds) ? List.of() : merchantIds;
+ }
+
+ /* ************************************* end app end ************************************* */
+
/**
* 分页查询用户在指定用餐日期范围内每天的预约情况。
*
@@ -228,67 +228,4 @@ public class HardCanteenApptTotalController {
return canteenApptTotalExportService.userReservationExport(query);
}
- /**
- * 提交菜品预约统计异步导出任务。
- *
- * @param query 菜品预约日期、餐段、食堂和部门筛选条件
- * @return 供前端轮询的初始任务进度
- */
- @Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel")
- @SysLog("菜品预约统计异步导出")
- @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
- @PostMapping("/product-reserv-export-task")
- public R submitProductReservationExport(@RequestBody HardOrderDto query) {
- applyCanteenMerchantScope(query);
- return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
- }
-
- /**
- * 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
- *
- * @param query 菜品预约统计筛选条件
- * @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
- */
- @Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
- @SysLog("菜品预约统计导出")
- @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
- @ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
- @PostMapping("/product-reserv-export")
- public List productReservationExport(@RequestBody HardOrderDto query) {
- applyCanteenMerchantScope(query);
- return canteenApptTotalExportService.productReservationExport(query);
- }
-
- /**
- * 为后台菜品预约导出补充当前操作者可见的食堂范围。
- *
- * 分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
- * 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。
- *
- * @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
- */
- private void applyCanteenMerchantScope(HardOrderDto query) {
- if (query == null) {
- return;
- }
- MchManageDto mchManageDto = new MchManageDto();
- mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
- R> scopeResponse = remoteMchService.scopeList(mchManageDto);
- List mchList = scopeResponse == null ? null : scopeResponse.getData();
- List visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
- : mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
- if (ObjectUtil.isEmpty(visibleMerchantIds)) {
- query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
- return;
- }
- if (ObjectUtil.isEmpty(query.getMchIds())) {
- query.setMchIds(visibleMerchantIds);
- return;
- }
- // 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
- List allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
- query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
- ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
- }
-
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
index 0b509485..1841b6cc 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
@@ -10,7 +11,8 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
+import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -58,11 +60,25 @@ public class HardCanteenDeptTotalController {
@Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计")
@SysLog("食堂菜品部门统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
- @ResponseExcel(name = "食堂菜品部门统计")
+ @ResponseExcel(name = "食堂菜品部门统计", inMemory = true,
+ writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-dept")
- public List exportDept(@RequestBody HardOrderDto dto) {
- PjcloudUser user = SecurityUtils.getUser();
- return canteenTotalService.exportDept(dto, user, null);
+ public List exportDept(@RequestBody HardOrderDto dto) {
+ return canteenTotalService.exportSales(dto, null);
+ }
+
+ /**
+ * 提交食堂部门销售统计异步导出任务。
+ *
+ * @param dto 来源统计日期、来源、食堂、餐段和部门筛选
+ * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
+ */
+ @Operation(summary = "食堂菜品部门统计异步导出", description = "按自然日分批查询并流式生成部门销售统计 Excel")
+ @SysLog("食堂菜品部门统计异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
+ @PostMapping("/export-dept-task")
+ public R submitDepartmentSalesExport(@RequestBody HardOrderDto dto) {
+ return R.ok(canteenTotalService.submitDepartmentSalesExport(dto), "食堂菜品部门统计导出任务已提交");
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
index d0ae0980..ea59db71 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -23,7 +24,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
+import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -113,6 +115,67 @@ public class HardCanteenTotalController {
return R.ok(canteenTotalService.proudctMchQuery(page, dto));
}
+ /**
+ * 按销售发生时间分页查询 App 食堂综合销售统计。
+ *
+ * 线下购买按支付时间统计,预约订单按核销时间统计;返回结构与 PC 菜品销售统计一致,
+ * 并且只允许查询当前商户管理员可见的食堂。
+ *
+ * @param page 按自然日分页的分页参数
+ * @param dto 统计日期、来源和餐段筛选条件
+ * @return 日期、餐段和来源多维销售统计分页
+ */
+ @ApiCryptoAes
+ @Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计")
+ @PostMapping("/app/proudct-total")
+ public R> appProductTotal(@ParameterObject Page page,
+ @RequestBody HardOrderDto dto) {
+ if (!applyAppCanteenMerchantScope(dto)) {
+ return R.ok(new Page());
+ }
+ return R.ok(canteenTotalService.productTotal(page, dto, null));
+ }
+
+ /**
+ * 查询 App 食堂综合销售统计中指定日期、餐段和来源的部门明细。
+ *
+ * @param page 保留的分页参数,与 PC 同名接口保持调用形式一致
+ * @param dto 统计日期、来源、餐段和部门筛选条件
+ * @return 当前用户可见食堂范围内的部门销售汇总
+ */
+ @ApiCryptoAes
+ @Operation(summary = "App食堂综合销售部门明细", description = "查询日期餐段来源对应的部门销售统计")
+ @PostMapping("/app/proudct-total-by-dept")
+ public R> appProductTotalByDept(@ParameterObject Page page,
+ @RequestBody HardOrderDto dto) {
+ if (!applyAppCanteenMerchantScope(dto)) {
+ return R.ok(List.of());
+ }
+ return R.ok(canteenTotalService.productTotalByDept(page, dto, null));
+ }
+
+ /**
+ * 将 App 统计查询的食堂条件收敛到当前商户管理员的数据范围。
+ *
+ * @param dto 待补全食堂范围的统计请求,已有食堂条件会与可见范围取交集
+ * @return 是否仍存在可查询的食堂;false 表示调用方应直接返回空数据
+ */
+ private boolean applyAppCanteenMerchantScope(HardOrderDto dto) {
+ PjcloudUser user = SecurityUtils.getUser();
+ List allowedMchIds = remoteMchService
+ .getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
+ if (ObjectUtil.isEmpty(allowedMchIds)) {
+ dto.setMchIds(List.of());
+ return false;
+ }
+ List requestedMchIds = dto.getMchIds();
+ List scopedMchIds = ObjectUtil.isEmpty(requestedMchIds)
+ ? allowedMchIds
+ : requestedMchIds.stream().filter(allowedMchIds::contains).distinct().toList();
+ dto.setMchIds(scopedMchIds);
+ return ObjectUtil.isNotEmpty(scopedMchIds);
+ }
+
/* ************************************* start app start ************************************* */
@Operation(summary = "根据部门 食堂菜品购买统计", description = "根据部门 食堂菜品购买统计")
@PostMapping("/proudct-total-by-dept")
@@ -150,17 +213,28 @@ public class HardCanteenTotalController {
*/
@Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计")
@SysLog("食堂菜品销售统计导出")
- @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')")
- @ResponseExcel(name = "食堂菜品销售统计")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ + "@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @ResponseExcel(name = "食堂菜品销售统计", inMemory = true,
+ writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-product")
- public List exportProduct(@RequestBody HardOrderDto dto) {
- MchManageDto mchManageDto = new MchManageDto();
- mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
- List mchList = remoteMchService.scopeList(mchManageDto).getData();
- if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
- dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
- }
- return canteenTotalService.exportProduct(dto, null);
+ public List exportProduct(@RequestBody HardOrderDto dto) {
+ return canteenTotalService.exportSales(dto, null);
+ }
+
+ /**
+ * 提交食堂菜品销售统计异步导出任务。
+ *
+ * @param dto 来源统计日期、来源、食堂、餐段和部门筛选
+ * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
+ */
+ @Operation(summary = "食堂菜品销售统计异步导出", description = "按自然日分批查询并流式生成销售统计 Excel")
+ @SysLog("食堂菜品销售统计异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ + "@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @PostMapping("/export-product-task")
+ public R submitProductSalesExport(@RequestBody HardOrderDto dto) {
+ return R.ok(canteenTotalService.submitProductSalesExport(dto), "食堂菜品销售统计导出任务已提交");
}
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java
index bb84f2d4..03c6fdea 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCarController.java
@@ -136,6 +136,13 @@ public class HardCarController {
}
/* ************************************* end app end ************************************* */
+ /**
+ * 分页查询订单原购物车商品,并补充已完成退款、申请中退款和当前剩余份数。
+ *
+ * @param page 分页参数
+ * @param orderId 原订单主键
+ * @return 原购物车商品分页;订单不存在或未关联购物车时返回空数据
+ */
@GetMapping("/order-id")
@Operation(summary = "根据订单id查询购物车")
public R> orderId(@ParameterObject Page page, @RequestParam Long orderId) {
@@ -144,7 +151,9 @@ public class HardCarController {
if (ObjectUtil.isEmpty(order) || ObjectUtil.isEmpty(order.getParentCarId())) return R.ok(null);
HardCarDto dto = new HardCarDto();
dto.setParentCarId(order.getParentCarId());
- return R.ok(hardCarService.pageJoin(page, dto));
+ Page carPage = hardCarService.pageJoin(page, dto);
+ hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), carPage.getRecords());
+ return R.ok(carPage);
}
@GetMapping("/refund-car")
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 b9936a98..ebf7559f 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
@@ -2,30 +2,23 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
-import cn.hutool.core.date.DatePattern;
-import cn.hutool.core.date.LocalDateTimeUtil;
-import cn.hutool.core.io.resource.ResourceUtil;
import cn.hutool.core.util.ObjectUtil;
-import com.alibaba.excel.EasyExcel;
-import com.alibaba.excel.ExcelWriter;
-import com.alibaba.excel.write.metadata.WriteSheet;
-import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
-import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.idempotent.annotation.Idempotent;
+import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
-import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
@@ -53,7 +46,6 @@ import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardTakeOutDeliveryTimeOptionVO;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.api.vo.order.HardOnlineCanteenStatsVo;
import com.bsd.pjcloud.hard.service.*;
@@ -74,14 +66,8 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.format.annotation.DateTimeFormat;
-import java.io.IOException;
import java.time.LocalDate;
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-import java.time.LocalDateTime;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
/**
* 订单表
@@ -100,6 +86,9 @@ public class HardOrderController {
private final HardOrderSyncService hardOrderSyncService;
private final HardCanteenTotalService canteenTotalService;
+ /** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */
+ private final HardOrderExportService hardOrderExportService;
+
private final HardOrderMapper hardOrderMapper;
private final RemoteMchService mchService;
@@ -214,47 +203,67 @@ public class HardOrderController {
return R.ok(hardwareOrderService.pagejoin(page, dto));
}
- @Operation(summary = "app查询食堂订餐的所有商户统计", description = "app查询食堂订餐的所有商户统计")
+ /**
+ * 按预约用餐时间统计当前商户管理员可见食堂的预约订单数量。
+ *
+ * @param dto 预约订单状态和预约用餐时间范围;创建、支付等其他时间条件会被忽略
+ * @return 各食堂在预约时间范围内的订单统计
+ */
+ @Operation(summary = "app查询食堂订餐的所有商户统计", description = "按预约用餐时间查询食堂订餐商户统计")
@GetMapping("/app/caneen-rserve-mch-total")
public R appCaneenRserveMchTotal(@ParameterObject HardOrderDto dto) {
DataScope dataScope = null;
-
PjcloudUser user = SecurityUtils.getUser();
+ normalizeAppReservationUseDateQuery(dto);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setTotalIsFindChildren(false);
dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenTotalService.listByDept(dto, user, dataScope));
}
- @Operation(summary = "app查询食堂订餐订单列表", description = "app查询食堂订餐订单列表")
+ /**
+ * 按预约用餐时间分页查询当前商户管理员可核销的食堂预约订单。
+ *
+ * @param page 分页参数
+ * @param dto 订单状态、食堂和预约用餐时间范围;创建、支付等其他时间条件会被忽略
+ * @return 按预约用餐日期倒序排列的预约订单分页
+ */
+ @Operation(summary = "app查询食堂订餐订单列表", description = "按预约用餐时间查询食堂订餐订单列表")
@GetMapping("/app/caneen-rserve-page-join")
public R appCaneenRservePageJoin(@ParameterObject Page page, @ParameterObject HardOrderDto dto) {
+ normalizeAppReservationUseDateQuery(dto);
dto.setIsFindCarList(true);
dto.setIsFindApptList(true);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setOrderStatusCodes(List.of(HardOrderStatusEnum.PAY.getType(), HardOrderStatusEnum.CANCEL.getType()));
dto.setApptIdOrApptRuleIdNotNull(true);
-
DataScope dataScope = null;
-
// 查询商户
- MchManageDto mchManageDto = new MchManageDto();
- mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
- List mchList = mchService.scopeList(mchManageDto).getData();
- if (ObjectUtil.isEmpty(mchList)) {
+ List mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
+ if (ObjectUtil.isEmpty(mchIdList)) {
return R.ok(new Page());
}
- List allowedMchIds = mchList.stream().map(MchManageVo::getId).toList();
- dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds())
- ? allowedMchIds
- : dto.getMchIds().stream().filter(allowedMchIds::contains).toList());
+ dto.setMchIds(ObjectUtil.isEmpty(dto.getMchIds()) ? mchIdList : dto.getMchIds().stream().filter(mchIdList::contains).toList());
if (ObjectUtil.isEmpty(dto.getMchIds())) {
return R.ok(new Page());
}
-
return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
}
+ /**
+ * 统一商户核销页面的预约时间口径,避免历史创建时间或支付时间参数与预约日期形成交叉筛选。
+ *
+ * @param dto 当前两个核销查询接口接收的订单筛选条件,方法会原地清除非预约日期条件
+ */
+ private void normalizeAppReservationUseDateQuery(HardOrderDto dto) {
+ dto.setCreateTimeList(null);
+ dto.setCreateTimeStr(null);
+ dto.setPayTimeList(null);
+ dto.setUpdateTimeList(null);
+ dto.setVerificationTimeList(null);
+ dto.setStatisticsDateTimeList(null);
+ }
+
@Operation(summary = "app查询食堂订单列表", description = "app查询订单列表")
@ApiCryptoAes
@GetMapping("/app/caneen-page-join")
@@ -652,77 +661,30 @@ public class HardOrderController {
return R.ok(hardwareOrderService.shipment(dto));
}
+ /**
+ * 提交订单异步分批导出任务。
+ *
+ * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
+ * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
+ */
+ @Operation(summary = "订单记录异步导出", description = "按订单筛选条件分批查询并流式生成 Excel")
+ @SysLog("订单记录异步导出")
+ @PostMapping("/export-task")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
+ public R submitExport(@RequestBody HardOrderDto dto) {
+ return R.ok(hardOrderExportService.submitExport(dto), "订单导出任务已提交");
+ }
+
+ /**
+ * 兼容仍使用旧下载方式的订单页面,同步响应内部也由独立导出服务分批生成。
+ *
+ * @param response 当前 HTTP 下载响应
+ * @param dto 订单页面当前筛选条件
+ */
+ @Operation(summary = "订单记录导出", description = "兼容旧页面的同步订单导出")
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_export')")
- public void export(HttpServletResponse response, HardOrderDto dto) throws IOException {
- DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
- dataScope.setScopeDeptName("merchant_dept_id");
- PjcloudUser user = SecurityUtils.getUser();
- CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user);
- dto.setMchTypeCodes(hardTypeInfo.getHardTypeList());
- // 查询商户和下面的子商户
- List mchDtoIds = dto.getMchIds();
- if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
- Long mchId = mchDtoIds.get(0);
- List mchList = mchService.getByParentId(mchId).getData();
- if (ObjectUtil.isNotEmpty(mchList)) {
- List mchIds = mchList.stream().map(MchManageVo::getId).toList();
- mchDtoIds.addAll(mchIds);
- dto.setMchIds(mchDtoIds);
- }
- }
- List list = hardwareOrderService.scopeList(dto, DataScope.of());
- List excelList = BeanUtils.toBean(list, HardOrderExcelVo.class);
- try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/订单交易汇总.xlsx")).build()) {
- WriteSheet writeSheet = EasyExcel.writerSheet().build();
- FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
- Map map = new HashMap();
- if (ObjectUtil.isNotEmpty(dto.getPayTimeList())) {
- map.put("timeSpan", " 到 ");
- map.put("startTime", LocalDateTimeUtil.format(dto.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
- map.put("endTime", LocalDateTimeUtil.format(dto.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
- }
- if (ObjectUtil.isNotEmpty(excelList)) {
- BigDecimal totalPriceSum = BigDecimal.ZERO;
- BigDecimal extraPriceSum = BigDecimal.ZERO;
- BigDecimal payPriceSum = BigDecimal.ZERO;
- Long lineNumber = 1L;
- for (HardOrderExcelVo v : excelList) {
- v.setLineNumber(lineNumber);
- lineNumber++;
- if (ObjectUtil.isNotEmpty(v.getTotalPrice())) {
- totalPriceSum = totalPriceSum.add(BigDecimal.valueOf(v.getTotalPrice()));
- v.setTotalPrice(BigDecimal.valueOf(v.getTotalPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getExtraPrice())) {
- extraPriceSum = extraPriceSum.add(BigDecimal.valueOf(v.getExtraPrice()));
- v.setExtraPrice(BigDecimal.valueOf(v.getExtraPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getPayPrice())) {
- payPriceSum = payPriceSum.add(BigDecimal.valueOf(v.getPayPrice()));
- v.setPayPrice(BigDecimal.valueOf(v.getPayPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).doubleValue());
- }
- if (ObjectUtil.isNotEmpty(v.getHourType())) {
- HardHourTypeEnum hardHourTypeEnum = HardHourTypeEnum.valueOfByType(v.getHourType());
- if (ObjectUtil.isNotEmpty(hardHourTypeEnum)) {
- v.setHourType(hardHourTypeEnum.getDescription());
- } else {
- v.setHourType("");
- }
- }
- if (ObjectUtil.isNotEmpty(v.getPayType())) {
- HardPayTypeEnum hardPayTypeEnum = HardPayTypeEnum.valueOfByType(v.getPayType());
- if (ObjectUtil.isNotEmpty(hardPayTypeEnum)) {
- v.setPayType(hardPayTypeEnum.getDescription());
- }
- }
- }
- map.put("totalPriceSum", totalPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("extraPriceSum", extraPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- map.put("payPriceSum", payPriceSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString());
- }
- excelWriter.fill(map, writeSheet);
- excelWriter.fill(excelList, fillConfig, writeSheet);
- }
+ public void export(HttpServletResponse response, HardOrderDto dto) {
+ hardOrderExportService.export(response, dto);
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
index db2cebc9..6451c2a5 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderTotalController.java
@@ -47,8 +47,10 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Set;
@RestController
@RequiredArgsConstructor
@@ -151,12 +153,70 @@ public class HardOrderTotalController {
return R.ok(hardOrderTotalService.listPayType(dto));
}
+ /**
+ * 查询当前用户所属食堂商户范围内的部门交易统计。
+ *
+ * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
+ * @return 当前用户有权查看的部门交易统计,无可见食堂或部门时返回空列表
+ */
@PostMapping("/page-list-dept")
@Operation(summary = "部门交易记录")
public R> pageListDept(@RequestBody HardDeptTotalDto dto) {
+ if (!applyCanteenDepartmentScope(dto)) {
+ return R.ok(List.of());
+ }
return R.ok(hardOrderTotalService.pageListDept(dto));
}
+ /**
+ * 将部门统计条件限制到当前用户可管理的食堂商户所属部门。
+ *
+ * 钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的
+ * 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除
+ * 条件导致底层统计退化为全量查询。
+ *
+ * @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合
+ * @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据
+ */
+ private boolean applyCanteenDepartmentScope(HardDeptTotalDto dto) {
+ PjcloudUser user = SecurityUtils.getUser();
+ if (user == null || user.getId() == null) {
+ return false;
+ }
+
+ R> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId(
+ user.getId(), List.of(MerchantTypeEnum.CANTEEN));
+ if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) {
+ return false;
+ }
+ List allowedDeptIds = departmentResponse.getData().stream()
+ .filter(id -> id != null && id > 0)
+ .distinct()
+ .toList();
+ if (ObjectUtil.isEmpty(allowedDeptIds)) {
+ return false;
+ }
+
+ List requestedDeptIds = ObjectUtil.isNotEmpty(dto.getDeptIds())
+ ? dto.getDeptIds()
+ : ObjectUtil.isNotEmpty(dto.getDeptId()) ? List.of(dto.getDeptId()) : List.of();
+ List scopedDeptIds = allowedDeptIds;
+ if (ObjectUtil.isNotEmpty(requestedDeptIds)) {
+ Set requestedDeptIdSet = new HashSet<>(requestedDeptIds);
+ scopedDeptIds = Boolean.FALSE.equals(dto.getIsInclude())
+ ? allowedDeptIds.stream().filter(id -> !requestedDeptIdSet.contains(id)).toList()
+ : allowedDeptIds.stream().filter(requestedDeptIdSet::contains).toList();
+ }
+ if (ObjectUtil.isEmpty(scopedDeptIds)) {
+ return false;
+ }
+
+ dto.setDeptId(null);
+ dto.setDeptIds(scopedDeptIds);
+ dto.setIsInclude(true);
+ return true;
+ }
+
@Operation(summary = "设备交易记录导出", description = "设备交易记录导出")
@PostMapping("/export-sb")
@SysLog("设备交易记录导出")
@@ -465,12 +525,21 @@ public class HardOrderTotalController {
}
}
+ /**
+ * 导出当前用户所属食堂商户范围内的部门交易统计。
+ *
+ * @param response Excel 文件响应
+ * @param dto 时间、钱包类型及部门树筛选条件;部门条件会与当前用户可管理的食堂部门取交集
+ * @throws IOException 写入 Excel 响应失败时抛出
+ */
@Operation(summary = "部门交易记录导出", description = "部门交易记录导出")
@PostMapping("/export-dept")
@SysLog("部门交易记录导出")
@PreAuthorize("@pms.hasPermission('hard_hardTotal_export_dept')")
public void exportDept(HttpServletResponse response, @RequestBody HardDeptTotalDto dto) throws IOException {
- List list = hardOrderTotalService.pageListDept(dto);
+ List list = applyCanteenDepartmentScope(dto)
+ ? hardOrderTotalService.pageListDept(dto)
+ : List.of();
try (ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).withTemplate(ResourceUtil.getStream("file/部门交易金额汇总.xlsx")).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
index fa9811af..7e2d5497 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
@@ -47,7 +47,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */
private static final BorderStyle DATE_GROUP_BORDER_STYLE = BorderStyle.MEDIUM;
- /** 日期分组边框颜色,按预约日期顺序交替使用蓝色和橙色。 */
+ /** 日期分组边框颜色,按统计日期顺序交替使用蓝色和橙色。 */
private static final byte[][] DATE_GROUP_BORDER_COLORS = {
{0x5B, (byte) 0x9B, (byte) 0xD5},
{(byte) 0xED, 0x7D, 0x31}
@@ -75,13 +75,13 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
private int lastDataRow = -1;
- /** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */
+ /** 当前统计日期分组序号,同一天的所有餐段和来源块共用一个序号和边框颜色。 */
private int dateGroupIndex = -1;
- /** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */
+ /** 当前统计日期的单元格值标识,用于判断新餐段或来源块是否仍属于同一天。 */
private String currentDateKey;
- /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
+ /** 当前导出工作表;本处理器只注册到菜品销售统计导出的单个工作表。 */
private Sheet currentSheet;
/**
@@ -203,14 +203,14 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
- * 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
+ * 为当前统计日期设置整组外框:当天首个来源块首行加顶部边框,当天所有行加左右边框,末行加底部边框。
*
* 流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
* 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。
*
* @param row 当前数据行
* @param holder 当前工作簿持有者,用于创建彩色边框样式
- * @param groupIndex 当前预约日期分组序号,未识别到日期时为负数
+ * @param groupIndex 当前统计日期分组序号,未识别到日期时为负数
* @param topBorder 是否为当天首行并设置顶部边框
* @param bottomBorder 是否为当天末行并设置底部边框
*/
@@ -284,10 +284,10 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
- * 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。
+ * 获取日期汇总单元格的稳定值标识;同一统计日期的不同餐段和来源块会得到相同标识。
*
* @param row 当前日期餐段汇总行
- * @param columnIndex 预约日期所在列索引
+ * @param columnIndex 统计日期所在列索引
* @return 用于判断日期是否变化的非空值标识
*/
private String getCellValueKey(Row row, int columnIndex) {
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java
index 16a98893..8f918f78 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/HardDoorFlowMapper.java
@@ -20,6 +20,7 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper wrapper = new MPJLambdaQueryWrapper()
.selectAll(HardDoorFlowEntity.class)
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), HardDoorFlowEntity::getUserId, dto.getUserId())
+ .in(ObjectUtil.isNotEmpty(dto.getUserIds()), HardDoorFlowEntity::getUserId, dto.getUserIds())
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), HardDoorFlowEntity::getBizType, dto.getBizType())
.eq(ObjectUtil.isNotEmpty(dto.getHardDoorId()), HardDoorFlowEntity::getHardDoorId, dto.getHardDoorId())
.eq(ObjectUtil.isNotEmpty(dto.getCabinetNo()), HardDoorFlowEntity::getCabinetNo, dto.getCabinetNo())
@@ -35,4 +36,4 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper
Page scopePageList(Page page, @Param("dto") HardInvoiceDto dto, @Param("dataScope") DataScope dataScope);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
index 911c3d71..56e69579 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
@@ -5,10 +5,12 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
+import java.util.List;
/**
* 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
@@ -64,4 +66,12 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
+
+ /**
+ * 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
+ *
+ * @param mchId 精确商户 ID
+ * @return 按订单主键升序排列的可重扫订单
+ */
+ List listExpiredRescanOrders(@Param("mchId") Long mchId);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
index ef4e2303..e5fbe1cf 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
+import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -49,8 +50,8 @@ public interface HardCarMapper extends PjcloudBaseMapper {
/**
* 查询菜品多维统计行且不应用数据库分页。
*
- * 同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
- * 服务层按完整自然日分页,导出仅选择餐段来源层。
+ * 同一次查询通过统一来源 CTE 返回当日合计、来源合计、餐段合计和餐段来源四层数据,
+ * 保证交易人数在每个层级独立去重;服务层按完整自然日分页。
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
@@ -67,31 +68,26 @@ public interface HardCarMapper extends PjcloudBaseMapper {
*/
List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 查询预约核销商品事实,复用菜品销售统计的核销时间和完成退款净额口径。
+ *
+ * @param dto 核销日期、食堂、餐段和用户范围;调用方必须把来源限制为预约核销
+ * @return 按核销时间、订单和商品稳定排序的有效商品事实,全额退款订单不返回记录
+ */
+ List listReservationVerifiedSaleFacts(@Param("dto") HardOrderDto dto);
+
Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
- * 查询菜品销售三级导出的日期餐段汇总及来源拆分。
+ * 一次查询菜品销售三级导出的汇总、商户部门和商品聚合行。
+ *
+ * SQL 只构造一次来源 CTE,再按记录层级分别聚合,避免三个查询重复扫描订单、购物车和退款数据。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
- * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
+ * @param dataScope 订单所属部门数据范围;为空时不附加范围条件
+ * @return 按层级、日期、餐段、来源、商户部门和商品稳定排序的三级聚合行
*/
- List listSalesExportSummary(@Param("dto") HardOrderDto dto);
-
- /**
- * 查询菜品销售三级导出的商户部门汇总及来源拆分。
- *
- * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
- * @return 每个日期餐段下商户部门的跨来源合计及各来源统计
- */
- List listSalesExportMerchant(@Param("dto") HardOrderDto dto);
-
- /**
- * 查询菜品销售三级导出的商品汇总及来源拆分。
- *
- * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
- * @return 每个商户部门块下商品的跨来源合计及各来源统计
- */
- List listSalesExportProduct(@Param("dto") HardOrderDto dto);
+ List listSalesExportRows(@Param("dto") HardOrderDto dto, DataScope dataScope);
Page proudctQuery(Page page, @Param("dto") HardCarDto dto);
@@ -100,7 +96,7 @@ public interface HardCarMapper extends PjcloudBaseMapper {
*
* @param page 分页参数
* @param dto 单日、来源、食堂、餐段、部门和商品名称筛选
- * @return 按商品聚合的来源明细页
+ * @return 按商品名称、商品 ID、统计日期和来源升序排列的商品聚合明细页
*/
Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto);
@@ -146,6 +142,17 @@ public interface HardCarMapper extends PjcloudBaseMapper {
.orderByAsc(HardCarEntity::getId));
}
+ /**
+ * 按父购物车批量查询完成退款扣减后的预约商品快照。
+ *
+ *