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); + } + } } } + /** + * 单品标记(不按套餐组块处理): + *

+ */ 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)); } + /** + * 按父购物车批量查询完成退款扣减后的预约商品快照。 + * + *

部分退款仅保留剩余份数和金额,全额退款商品不返回;该结果用于待核销、部分核销及预约日统计, + * 避免人员页面继续累计原始购物车快照。

+ * + * @param parentCarIds 预约订单关联的父购物车主键集合 + * @return 退款后仍有有效数量的商品快照;传入空集合时调用方应直接返回空列表 + */ + List listEffectiveReservationCars(@Param("parentCarIds") List parentCarIds); + /** * 菜品销量按照分类查询 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java new file mode 100644 index 00000000..d063b778 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardCanteenReservationSaleFact.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.hard.mapper.order.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 食堂预约核销商品事实。 + * + *

数据直接来自菜品销售统计的预约核销分支,日期取订单核销时间,数量和金额均已扣除状态为 50 的 + * 完成退款;仅用于 Hardware 服务内部统一人员统计与商品统计口径,不作为外部接口模型。

+ */ +@Data +public class HardCanteenReservationSaleFact { + + /** 订单实际核销完成时间,用于把人员统计行归入对应核销日期。 */ + private LocalDateTime statisticsTime; + + /** 已核销预约订单主键,用于订单去重及关联页面预约明细。 */ + private Long orderId; + + /** 实际用餐用户主键,用于人员筛选和人数去重。 */ + private Long userId; + + /** 订单餐段协议编码,来源于核销时的订单快照。 */ + private String hourType; + + /** 订单商品主键,历史数据无法识别商品时可为空。 */ + private Long productId; + + /** 下单时保存的商品名称,历史未记录时可为空。 */ + private String productName; + + /** 下单时保存的商品图片地址,历史未记录时可为空。 */ + private String productPic; + + /** 完成退款扣减后的有效商品份数,最小为零。 */ + private Integer productCount; + + /** 完成退款扣减后的有效商品金额,单位为分,最小为零。 */ + private Long totalPrice; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java index 77f024c4..17e8913e 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleCarService.java @@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService getOccupiedByCarParentId(Long carParentId, List statuses); + /** + * 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。 + * + * @param carParentIds 父购物车主键集合,空集合不发起数据库查询 + * @param statuses 需要计入占用的售后状态编码集合 + * @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中 + */ + Map> getOccupiedByCarParentIds(List carParentIds, List statuses); + /** * 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java index 90a46764..5913bdd3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java @@ -10,6 +10,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardDisagreeDto; 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.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo; @@ -57,6 +58,14 @@ public interface HardAfterSaleService extends IService { */ List getHistory(Long id); + /** + * 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。 + * + * @param id 售后单 ID + * @return App 可见的售后进度记录 + */ + List getAppHistory(Long id); + /** * 退款 */ @@ -109,6 +118,15 @@ public interface HardAfterSaleService extends IService { */ Boolean disagreeRefund(Long userId, HardDisagreeDto dto); + /** + * 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。 + * + * @param userId 当前后台操作用户 ID + * @param id 售后单 ID + * @return 资金、库存和售后状态均已恢复时返回 {@code true} + */ + Boolean returnToReview(Long userId, Long id); + /** * 校验订单是否可以退款 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index fa8a34ee..2f96b3b9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -39,7 +39,7 @@ public interface HardApptReservationService { * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 - * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款 + * @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用 */ record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId, LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java index 212fb749..3c5695af 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java @@ -9,6 +9,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; 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 java.util.List; @@ -63,6 +64,15 @@ public interface HardAutoRefundTaskService extends IService pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto); + /** + * 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。 + * + * @param mchId 需要重扫的商户 ID + * @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计 + * @return 本轮扫描、任务同步和退款执行数量 + */ + HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId); + /** * 查询任务的只读尝试日志,按尝试序号升序返回。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java new file mode 100644 index 00000000..e863919f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardBatchRefundService.java @@ -0,0 +1,31 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto; +import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto; +import com.bsd.pjcloud.hard.api.vo.order.HardBatchRefundPreviewVo; + +/** + * 后台订单批量退款服务,负责权限冻结、只读预览和逐笔独立退款任务。 + */ +public interface HardBatchRefundService { + + /** + * 按当前后台用户的数据范围生成批量退款预览。 + * + * @param dto 列表页跨页勾选的订单主键 + * @param user 当前后台登录用户,用于设备类型和商户数据范围校验 + * @return 服务端实时计算的订单、菜品、人员和剩余金额明细 + */ + HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user); + + /** + * 冻结当前仍有权限访问的订单并提交异步逐笔退款任务。 + * + * @param dto 订单主键和本批次统一退款备注 + * @param user 当前后台登录用户,用户主键会记录为每笔退款的审核操作人 + * @return 前端可通过通用进度接口轮询的初始任务状态 + */ + AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java index 53172794..2ec1b525 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java @@ -2,8 +2,6 @@ package com.bsd.pjcloud.hard.service; import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; 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.excel.HardCanteenProductApptExcelVo; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import java.util.List; @@ -31,20 +29,4 @@ public interface HardCanteenApptTotalExportService { * @return 用户预约导出行 */ List userReservationExport(HardCanteenUserApptQueryDto query); - - /** - * 提交菜品预约异步导出任务。 - * - * @param query 菜品预约统计筛选条件 - * @return 可供前端轮询的初始任务进度 - */ - AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query); - - /** - * 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。 - * - * @param query 菜品预约统计筛选条件 - * @return 按日期餐段、商户部门、商品三级结构排列的导出行 - */ - List productReservationExport(HardOrderDto query); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java index 61eba838..4c3734c3 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java @@ -6,6 +6,7 @@ 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; @@ -53,4 +54,24 @@ public interface HardCanteenApptTotalService { */ HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query); + /** + * 按 App 当前账号可管理的食堂范围统计有效预约菜品及点餐人员。 + * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 + * @return 与 PC 端核销状态口径一致且已完成退款扣减的菜品和点餐人员汇总 + */ + HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( + HardCanteenUserApptQueryDto query, List merchantIds); + + /** + * 按 App 当前账号可管理的食堂范围统计每日预约及查询范围总计。 + * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合 + * @return 与 PC 端核销状态口径一致的逐日预约统计 + */ + HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds); + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java index 7502b74e..ab70e8e0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; @@ -78,4 +79,20 @@ public interface HardCanteenTotalService { * @return 日期餐段、商户部门、商品三级排列的导出行 */ List exportSales(HardOrderDto dto, DataScope dataScope); + + /** + * 提交食堂菜品销售统计异步导出任务。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto); + + /** + * 提交食堂部门销售统计异步导出任务。 + * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java new file mode 100644 index 00000000..66f3c8d9 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.hard.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 硬件订单导出服务。 + * + *

该服务独立承担导出条件冻结、数据权限、分批查询、自定义表头写入、进度上报和文件中心上传, + * 订单查询服务只保留订单业务查询职责。

+ */ +public interface HardOrderExportService { + + /** + * 提交硬件订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + AsyncTaskProgressVO submitExport(HardOrderDto dto); + + /** + * 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。 + * + * @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件 + * @param dto 订单页面当前筛选条件 + */ + void export(HttpServletResponse response, HardOrderDto dto); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java index d5d02cba..e2bae5ad 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java @@ -294,6 +294,19 @@ public interface HardOrderService extends IService { */ void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId); + /** + * 使用调用方提供的稳定业务号执行售后库存变更,用于同一售后单多次退回、再审核时隔离每轮库存动作。 + * 普通库存协议保持原行为,只有支持幂等的食堂基础 SPU 使用该业务号。 + * + * @param order 原订单 + * @param refundCarList 本次售后商品;为空表示整单商品 + * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存 + * @param afterSaleId 售后单 ID,用于校验退款库存上下文 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号 + */ + void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId); + /** * 核销硬件二维码 */ diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 43a8c4ba..2dddc62b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -358,11 +358,10 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new RuntimeException(availability.unavailableReason()); } return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(), - availability.endDateTime()); + resolveUsageEndDateTime(targetDate)); } - private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, - LocalDate apptDate, LocalDateTime now) { + private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) { throw new RuntimeException("周期预约需要选择预约日期和餐端"); } @@ -379,33 +378,18 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new RuntimeException(match.availability().unavailableReason()); } return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(), - resolveCycleUsageEndDateTime(mchId, hourType, apptDate)); + resolveUsageEndDateTime(apptDate)); } /** - * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析, - * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。 + * 预约订单的核销有效期按实际用餐日全天计算,统一固化为当天 23:59:59。 + * 该口径不读取当前餐段配置,避免预约配置高频调整后影响已创建订单。 * - * @param mchId 订单所属商户 ID - * @param hourType 订单选中的标准餐段编码 * @param apptDate 实际用餐日期 - * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null} + * @return 当天 23:59:59;用餐日期为空时返回 {@code null} */ - private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) { - List candidates = hardApptService.list(new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getHourType, hourType) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) - .orderByAsc(HardApptEntity::getId)); - if (candidates.size() != 1) { - return null; - } - HardApptEntity appt = candidates.get(0); - HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType()); - if (endType == null || appt.getEndTime() == null) { - return null; - } - return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime()); + private LocalDateTime resolveUsageEndDateTime(LocalDate apptDate) { + return apptDate == null ? null : apptDate.atTime(23, 59, 59); } private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java index ee9821de..c0fd9052 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardAuthServiceImpl.java @@ -34,6 +34,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAppCreateThirdDoorOrderVo; import com.bsd.pjcloud.hard.mapper.HardAuthMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -62,8 +63,12 @@ public class HardAuthServiceImpl extends ServiceImpl scopePageJoin(Page page, HardAuthDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -237,14 +242,18 @@ public class HardAuthServiceImpl extends ServiceImpl userIds = list.stream().map(HardAuthVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将授权用户身份条件预筛选为授权记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardAuthDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } /** @@ -268,4 +277,4 @@ public class HardAuthServiceImpl extends ServiceImpl scopePageJoin(Page page, HardBroadcastDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -310,14 +315,18 @@ public class HardBroadcastServiceImpl extends ServiceImpl userIds = list.stream().map(HardBroadcastVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将广播预定用户身份条件预筛选为广播记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardBroadcastDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java index eb2a746f..301eeb9b 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardDoorFlowServiceImpl.java @@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.mapper.HardDoorFlowMapper; import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdDoorApiService; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdKsService; import com.bsd.pjcloud.thirdparty.dto.door.*; @@ -88,11 +89,15 @@ public class HardDoorFlowServiceImpl extends ServiceImpl scopePageJoin(Page page, HardDoorFlowDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -427,14 +432,18 @@ public class HardDoorFlowServiceImpl extends ServiceImpl userIds = list.stream().map(HardDoorFlowVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); + } + + /** 将开门流程用户身份条件预筛选为流程记录可直接使用的用户主键集合。 */ + private void prepareIdentityQuery(HardDoorFlowDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) return; + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); } private void disposeList(HardDoorFlowVo detail) { @@ -458,4 +467,4 @@ public class HardDoorFlowServiceImpl extends ServiceImpl scopeJoinPageList(Page page, HardInvoiceDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto, DataScope.of()); if (ObjectUtil.isNotEmpty(list.getRecords())) { // 部门 @@ -102,14 +107,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 商户 List mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { @@ -122,6 +120,20 @@ public class HardInvoiceServiceImpl extends ServiceImpl> getOccupiedByCarParentIds(List carParentIds, + List statuses) { + if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) { + return Map.of(); + } + Map> result = new LinkedHashMap<>(); + baseMapper.getOccupiedByCarParentIds(carParentIds.stream().filter(java.util.Objects::nonNull).distinct().toList(), + statuses) + .forEach(item -> result.computeIfAbsent(item.getParentCarId(), ignored -> new ArrayList<>()).add(item)); + return result; + } + /** * 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java index 98ca45dd..a5765cbf 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java @@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto; import com.bsd.pjcloud.hard.api.entity.order.*; import com.bsd.pjcloud.hard.api.utils.HardUtil; +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.HardOrderVo; import com.bsd.pjcloud.hard.api.vo.order.app.HardAppRefundApplyVo; @@ -46,6 +47,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper; import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto; @@ -95,6 +97,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl pageJoin(Page page, HardAfterSaleDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -151,16 +156,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl scopePageList(Page page, HardAfterSaleDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageJoinList(page, dto, null); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -174,6 +173,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl list = baseMapper.selectList(new LambdaQueryWrapper() @@ -462,6 +482,8 @@ public class HardAfterSaleServiceImpl extends ServiceImpl user = userService.getInnerByUserId(detail.getUserId()); @@ -488,6 +510,61 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getAppHistory(Long id) { + List history = getHistory(id); + if (history.isEmpty()) return List.of(); + HardAfterSaleEntity afterSale = baseMapper.selectById(id); + return history.stream().map(log -> { + HardAfterSaleLogVo vo = BeanUtils.toBean(log, HardAfterSaleLogVo.class); + if (afterSale != null) { + vo.setReason(resolveAppHistoryReason(log.getOperateType(), afterSale)); + } + return vo; + }).toList(); + } + + /** + * 按售后操作类型选择当前节点的原因,原因只用于 App 展示且不改变日志持久化内容。 + * + * @param operateType 售后日志操作类型协议编码 + * @param afterSale 当前售后单 + * @return 去除首尾空白的节点原因;该节点无需展示时返回 {@code null} + */ + private String resolveAppHistoryReason(String operateType, HardAfterSaleEntity afterSale) { + if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.MEMBER_CREATE.getType())) { + return trimToNull(afterSale.getApplyReason()); + } + if (Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_DISAGREE_APPLY.getType())) { + return trimToNull(afterSale.getAuditReason()); + } + boolean directRefund = Objects.equals(operateType, HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType()) + || Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType()) + || Objects.equals(operateType, HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType()); + if (!directRefund) return null; + String applyReason = trimToNull(afterSale.getApplyReason()); + return applyReason == null ? trimToNull(afterSale.getAuditReason()) : applyReason; + } + + /** + * 统一过滤接口原因中的纯空白值,避免 App 渲染空的信息行。 + * + * @param value 数据库存储的原因文本 + * @return 去除首尾空白后的文本;空值或纯空白返回 {@code null} + */ + private String trimToNull(String value) { + if (value == null || value.isBlank()) return null; + return value.trim(); + } + /** * 使用默认退款单号前缀执行售后退款。 * @@ -828,6 +905,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl 0 + ? RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX + id + ":" + returnReviewCount : null; + restoreRefundInventory(order, id, inventoryBizId); } getSelf().refund(updateRefundObj.getId()); if (!canteenSpuInventoryOrder) { @@ -851,6 +931,104 @@ public class HardAfterSaleServiceImpl extends ServiceImpl getSelf().returnToReviewInTransaction(userId, id)); + } + + /** + * 在订单锁内冲正人工钱包退款,并恢复退款审核前的库存占用、售后状态和订单聚合状态。 + * 食堂基础 SPU 具备跨服务幂等补偿,先重新占用库存再扣回钱包;普通库存沿用历史接口, + * 先完成可幂等恢复的钱包冲正,再执行库存扣减,失败后可按仍为完成的售后状态继续重试。 + * + * @param userId 当前后台操作用户 ID + * @param id 已完成人工审核的售后单 ID + * @return 资金、库存和售后状态均恢复时返回 {@code true} + */ + @Transactional(rollbackFor = Exception.class) + public Boolean returnToReviewInTransaction(Long userId, Long id) { + HardAfterSaleEntity afterSale = baseMapper.selectById(id); + if (afterSale == null) { + throw new RuntimeException("售后单不存在"); + } + ensureNoAutoRefundTask(id); + long completedReturnCount = countReturnToReviewOperations(id); + if (ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus()) && completedReturnCount > 0) { + return true; + } + if (!ObjectUtil.equals(afterSale.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) { + throw new RuntimeException("仅已完成的人工审核退款可以退回审核中"); + } + + HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId()); + if (order == null) { + throw new RuntimeException("订单不存在"); + } + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (orderType == null || payType == null || !hardOrderService.isPay(orderType, payType)) { + throw new RuntimeException("仅钱包支付订单支持退回审核,第三方支付退款无法自动扣回"); + } + + long reviewCycle = completedReturnCount + 1; + String inventoryBizId = RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX + id + ":" + reviewCycle; + boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order); + if (canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + rollbackWalletRefund(id); + if (!canteenSpuInventoryOrder) { + reoccupyRefundInventory(order, id, inventoryBizId); + } + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(HardAfterSaleEntity::getId, id) + .eq(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.COMPLETE.getStatus()) + .set(HardAfterSaleEntity::getStatus, HardSaleStatusEnum.APPLY.getStatus()) + .set(HardAfterSaleEntity::getAuditUserId, null) + .set(HardAfterSaleEntity::getAuditTime, null) + .set(HardAfterSaleEntity::getAuditReason, null) + .set(HardAfterSaleEntity::getRefundTime, null); + if (baseMapper.update(null, updateWrapper) != 1) { + throw new RuntimeException("退回售后审核状态失败"); + } + insertAfterSaleLog(id, userId, HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType(), + HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.APPLY.getStatus(), + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getContent()); + recomputeOrderRefundState(order.getId()); + return true; + } + + /** + * 调用 Wallet 冲正原售后单退款,并严格校验跨服务业务响应,远程异常时禁止推进本地售后状态。 + * + * @param afterSaleId 待冲正的 Hardware 售后单 ID + */ + private void rollbackWalletRefund(Long afterSaleId) { + R result = walletRechargeService.rollbackRefund(afterSaleId); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + throw new RuntimeException(result == null || ObjectUtil.isEmpty(result.getMsg()) + ? "钱包退款金额扣回失败" : result.getMsg()); + } + } + + /** + * 统计售后单已经完成的退回审核次数,作为同一售后反复审核时库存幂等业务号的稳定轮次。 + * + * @param afterSaleId 售后单 ID + * @return 已持久化的退回审核日志数量 + */ + private long countReturnToReviewOperations(Long afterSaleId) { + return hardAfterSaleLogMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleLogEntity::getAfterSaleId, afterSaleId) + .eq(HardAfterSaleLogEntity::getOperateType, + HardAfterSaleOperateTypeEnum.ADMIN_RETURN_TO_REVIEW.getType())); + } + @Override public Boolean disagreeRefund(Long userId, HardDisagreeDto dto) { HardAfterSaleEntity current = baseMapper.selectById(dto.getId()); @@ -1846,15 +2024,40 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); if (ObjectUtil.isEmpty(refundCars)) { - hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId, inventoryBizId); return; } - hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId); + hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId, inventoryBizId); + } + + /** + * 重新占用本次退款曾恢复的商品库存。食堂基础 SPU 使用本轮独立业务号保证远程重试不重复扣减, + * 其他商品来源继续使用原库存协议和售后商品快照。 + * + * @param order 原订单 + * @param afterSaleId 退回审核的售后单 ID + * @param inventoryBizId 本轮库存重新占用的幂等业务号 + */ + private void reoccupyRefundInventory(HardOrderEntity order, Long afterSaleId, String inventoryBizId) { + List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId); + List inventoryCars = ObjectUtil.isEmpty(refundCars) ? null : toInventoryCars(refundCars); + hardOrderService.fastenerInventory(order, inventoryCars, YesNoEnum.YES, afterSaleId, inventoryBizId); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java index 7b7fff23..bb4b0c7c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAutoRefundTaskServiceImpl.java @@ -19,12 +19,14 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum; import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum; import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto; import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity; import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity; import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; 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.mapper.order.HardAfterSaleMapper; import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper; @@ -108,6 +110,21 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page, - HardAutoRefundTaskPageDto dto) { + public Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto) { LocalDateTime now = LocalDateTime.now(); Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now); List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class); @@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl MAX_EXPIRED_RESERVE_DELAY_HOURS) { + throw new RuntimeException("过期预约退款延迟必须在1到168小时之间"); + } + boolean enabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()); + String restoreInventory = Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode(); + List orders = baseMapper.listExpiredRescanOrders(mchId); + HardExpiredRefundRescanResultVo result = new HardExpiredRefundRescanResultVo(); + result.setScannedOrderCount(orders == null ? 0 : orders.size()); + result.setCreatedTaskCount(0); + result.setRefreshedTaskCount(0); + result.setExecutedTaskCount(0); + result.setSuccessTaskCount(0); + result.setSkippedOrderCount(0); + if (orders == null || orders.isEmpty()) { + return result; + } + + LocalDateTime now = LocalDateTime.now(); + for (HardOrderEntity order : orders) { + boolean taskSynced = false; + try { + LocalDateTime usageEndTime = resolveExpiredRefundUsageEndTimeSnapshot(order); + if (usageEndTime == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + log.warn("|{}|立即重扫跳过缺少可靠订单时间快照的订单,orderId:{}", TAG, order.getId()); + continue; + } + LocalDateTime triggerTime = usageEndTime.plusHours(delayHours); + LockInfo syncLock = lockTemplate.lock(REFUND_ORDER_LOCK_PREFIX + order.getId(), + REFUND_ORDER_LOCK_EXPIRE_MILLIS, REFUND_ORDER_LOCK_WAIT_MILLIS); + if (syncLock == null) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + int syncResult; + try { + syncResult = getSelf().syncExpiredRescanTask(order, config, triggerTime, restoreInventory, enabled); + } finally { + try { + lockTemplate.releaseLock(syncLock); + } catch (Exception exception) { + log.warn("|{}|释放立即重扫订单锁失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + if (syncResult == RESCAN_SYNC_CREATED) { + result.setCreatedTaskCount(result.getCreatedTaskCount() + 1); + } else if (syncResult == RESCAN_SYNC_REFRESHED) { + result.setRefreshedTaskCount(result.getRefreshedTaskCount() + 1); + } else { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + continue; + } + taskSynced = true; + if (enabled && !triggerTime.isAfter(now)) { + HardAutoRefundTaskEntity task = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, order.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (task != null && Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.PENDING.getStatus())) { + result.setExecutedTaskCount(result.getExecutedTaskCount() + 1); + execute(task.getId(), HardAutoRefundTriggerTypeEnum.EXPIRED_RESCAN, operatorUserId); + HardAutoRefundTaskEntity completed = getById(task.getId()); + if (completed != null && Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) { + result.setSuccessTaskCount(result.getSuccessTaskCount() + 1); + } + } + } + } catch (Exception exception) { + if (!taskSynced) { + result.setSkippedOrderCount(result.getSkippedOrderCount() + 1); + } + log.warn("|{}|立即重扫订单失败,orderId:{},exceptionType:{}", TAG, order.getId(), + exception.getClass().getSimpleName()); + } + } + return result; + } + + /** + * 解析订单自身保存的过期退款时间快照。新订单优先使用专用时间字段;历史订单仅使用订单上的预约日期 + * 兜底为当天 23:59:59,绝不读取当前预约配置或旧的自动退款截止时间。 + * + * @param order 待重扫的已支付预约订单 + * @return 可信的核销有效期结束时间;订单没有预约日期时返回 {@code null} + */ + private LocalDateTime resolveExpiredRefundUsageEndTimeSnapshot(HardOrderEntity order) { + if (order == null) { + return null; + } + if (order.getExpiredRefundUsageEndTime() != null) { + return order.getExpiredRefundUsageEndTime(); + } + return order.getApptUseDate() == null ? null : order.getApptUseDate().atTime(23, 59, 59); + } + + /** + * 同步单个历史订单的过期任务。已成功、已创建售后或已产生资金尝试的任务不允许被新配置覆盖,避免重扫造成重复退款。 + * + * @param order 历史已支付未核销订单 + * @param config 当前商户过期退款配置 + * @param triggerTime 按当前延迟重新计算的触发时间 + * @param restoreInventory 当前退库存快照 + * @param enabled 当前过期退款开关 + * @return 新建、刷新或跳过结果编码 + */ + @Transactional(rollbackFor = Exception.class) + public int syncExpiredRescanTask(HardOrderEntity order, MchAutoRefundConfigVo config, + LocalDateTime triggerTime, String restoreInventory, boolean enabled) { + if (order == null || order.getId() == null || config == null || config.getId() == null || triggerTime == null) { + return RESCAN_SYNC_SKIPPED; + } + HardOrderEntity currentOrder = hardOrderMapper.selectByIdForUpdate(order.getId()); + if (currentOrder == null || !Objects.equals(currentOrder.getMerchantId(), config.getMchId()) + || !Objects.equals(currentOrder.getOrderType(), HardOrderTypeEnum.RESERVE.getType()) + || !Objects.equals(currentOrder.getOrderStatus(), HardOrderStatusEnum.PAY.getType()) + || !Objects.equals(currentOrder.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType()) + || currentOrder.getPayPrice() == null || currentOrder.getPayPrice() <= 0 + || currentOrder.getRefundPrice() != null && currentOrder.getRefundPrice() > 0) { + return RESCAN_SYNC_SKIPPED; + } + Long conflictingAfterSaleCount = afterSaleMapper.selectCount(new LambdaQueryWrapper() + .eq(HardAfterSaleEntity::getOrderId, currentOrder.getId()) + .in(HardAfterSaleEntity::getStatus, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()))); + if (conflictingAfterSaleCount != null && conflictingAfterSaleCount > 0) { + return RESCAN_SYNC_SKIPPED; + } + HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper() + .eq(HardAutoRefundTaskEntity::getOrderId, currentOrder.getId()) + .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())); + if (existing != null) { + boolean refreshableStatus = List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(), + HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus(), + HardAutoRefundTaskStatusEnum.CONFIG_UNAVAILABLE.getStatus()).contains(existing.getStatus()); + if (!refreshableStatus || !Objects.equals(existing.getMchId(), currentOrder.getMerchantId()) + || existing.getAfterSaleId() != null || safeCount(existing.getAutoAttemptCount()) > 0) { + return RESCAN_SYNC_SKIPPED; + } + existing.setConfigId(config.getId()); + existing.setTriggerTime(triggerTime); + existing.setRestoreInventory(restoreInventory); + existing.setStatus(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()); + existing.setNextRetryTime(enabled ? triggerTime : null); + existing.setLastError(null); + if (baseMapper.updateById(existing) != 1) { + throw new RuntimeException("刷新过期预约退款任务失败"); + } + return RESCAN_SYNC_REFRESHED; + } + HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder() + .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + .orderId(currentOrder.getId()).mchId(currentOrder.getMerchantId()) + .configId(config.getId()).triggerTime(triggerTime) + .restoreInventory(restoreInventory) + .status(enabled ? HardAutoRefundTaskStatusEnum.PENDING.getStatus() + : HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()) + .autoAttemptCount(0).totalAttemptCount(0) + .nextRetryTime(enabled ? triggerTime : null) + .build(); + if (baseMapper.insert(task) != 1) { + throw new RuntimeException("创建过期预约退款任务失败"); + } + return RESCAN_SYNC_CREATED; + } + + /** + * 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 + */ private void fillOrderData(List records) { List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList(); if (orderIds.isEmpty()) return; @@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl expired.ne(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType, - HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) + HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()) .le(HardAutoRefundTaskEntity::getTriggerTime, now))))) .orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime) .orderByAsc(HardAutoRefundTaskEntity::getId) @@ -1187,7 +1395,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl SUPPORTED_THIRD_PARTY_PAY_TYPES = Set.of( + HardPayTypeEnum.WECHANT_SELF_QR_CODE_PAY, + HardPayTypeEnum.WECHANT_APP_PAY, + HardPayTypeEnum.ALI_SELF_QR_CODE_PAY, + HardPayTypeEnum.ALI_APP_PAY + ); + + /** 订单服务,用于按后台数据范围批量读取订单和原购物车快照。 */ + private final HardOrderService hardOrderService; + + /** 售后服务,复用既有整单退款业务和读取历史售后金额。 */ + private final HardAfterSaleService hardAfterSaleService; + + /** 售后商品服务,用一次查询取得全部订单的待审核和已退款商品占用。 */ + private final HardAfterSaleCarService hardAfterSaleCarService; + + /** 设备权限服务,用于冻结当前后台用户允许查看的设备类型范围。 */ + private final HardManagerService hardManagerService; + + /** 通用异步任务执行器,负责恢复租户、登录和 Feign Token 上下文并保存进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** 文件中心服务,用于保存最多 1000 笔订单的退款执行结果工作簿。 */ + private final RemoteFileService remoteFileService; + + /** Spring 事务管理器,用于让每笔退款在异步任务外层事务中仍以独立事务提交。 */ + private final PlatformTransactionManager transactionManager; + + /** + * 生成只读退款预览,订单、购物车、售后金额和商品占用均按批量方式读取。 + * + * @param dto 列表页勾选的订单主键 + * @param user 当前后台登录用户 + * @return 当前时点的批量退款预览 + */ + @Override + public HardBatchRefundPreviewVo preview(HardBatchRefundPreviewDto dto, PjcloudUser user) { + return buildPreview(normalizeOrderIds(dto == null ? null : dto.getOrderIds()), user); + } + + /** + * 提交异步退款任务前同步冻结权限与预览状态,避免任务线程接收未经授权的订单主键。 + * + * @param dto 订单主键和统一退款备注 + * @param user 当前后台登录用户 + * @return 通用异步任务初始进度 + */ + @Override + public AsyncTaskProgressVO submit(HardBatchRefundSubmitDto dto, PjcloudUser user) { + if (dto == null || StrUtil.isBlank(dto.getRemark())) { + throw new IllegalArgumentException("请输入退款备注"); + } + String remark = dto.getRemark().trim(); + HardBatchRefundPreviewVo preview = buildPreview(normalizeOrderIds(dto.getOrderIds()), user); + long refundableCount = preview.getOrders().stream().filter(item -> Boolean.TRUE.equals(item.getRefundable())).count(); + if (refundableCount == 0) { + throw new IllegalStateException("所选订单当前均不可退款,请刷新列表后重试"); + } + List orderSnapshot = List.copyOf(preview.getOrders()); + Long operatorId = user == null ? null : user.getId(); + if (operatorId == null) { + throw new IllegalStateException("无法取得当前退款操作人"); + } + return asyncTaskExecutorService.submitWithResult(BATCH_REFUND_REDIS_KEY, BATCH_REFUND_TASK_NAME, + orderSnapshot.size(), reporter -> executeBatch(orderSnapshot, remark, operatorId, reporter)); + } + + /** + * 去重并保留页面勾选顺序,同时执行服务端数量上限兜底。 + * + * @param orderIds 客户端提交的订单主键 + * @return 非空、去重且不超过上限的订单主键 + */ + private List normalizeOrderIds(List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + List normalized = orderIds.stream().filter(Objects::nonNull).distinct().toList(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("请选择需要退款的订单"); + } + if (normalized.size() > MAX_BATCH_ORDER_COUNT) { + throw new IllegalArgumentException("单次最多选择" + MAX_BATCH_ORDER_COUNT + "个订单"); + } + return normalized; + } + + /** + * 按订单主键一次装配订单、用户、商户、购物车及售后占用,再计算每笔剩余退款内容。 + * + * @param orderIds 已规范化的订单主键 + * @param user 当前后台登录用户 + * @return 与请求主键顺序一致的退款预览 + */ + private HardBatchRefundPreviewVo buildPreview(List orderIds, PjcloudUser user) { + List scopedOrders = loadScopedOrders(orderIds, user); + Map orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId, + item -> item, (first, second) -> first, LinkedHashMap::new)); + if (orderMap.size() != orderIds.size()) { + throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试"); + } + + Map> afterSalesByOrderId = loadEffectiveAfterSales(orderIds); + List parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId) + .filter(Objects::nonNull).distinct().toList(); + Map> occupiedCarsByParentId = + hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())); + + List items = new ArrayList<>(orderIds.size()); + for (Long orderId : orderIds) { + HardOrderVo order = orderMap.get(orderId); + List afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of()); + List occupiedCars = occupiedCarsByParentId.getOrDefault(order.getParentCarId(), List.of()); + items.add(buildOrderItem(order, afterSales, occupiedCars)); + } + + HardBatchRefundPreviewVo result = new HardBatchRefundPreviewVo(); + result.setOrders(items); + result.setSummary(buildSummary(items)); + return result; + } + + /** + * 复用订单列表的数据范围、设备类型范围和批量关联装配能力读取所选订单。 + * + * @param orderIds 已规范化的订单主键 + * @param user 当前后台登录用户 + * @return 当前用户仍有权限访问的订单及购物车快照 + */ + private List loadScopedOrders(List orderIds, PjcloudUser user) { + if (user == null) { + throw new IllegalStateException("无法取得当前登录用户"); + } + HardOrderDto query = new HardOrderDto(); + query.setOrderIds(orderIds); + query.setIsFindCarList(true); + CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); + query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null + ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList())); + DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); + dataScope.setScopeDeptName("merchant_dept_id"); + return hardOrderService.scopeList(query, dataScope); + } + + /** + * 一次读取全部待审核和已完成售后单,取消及拒绝记录不占用本次退款金额。 + * + * @param orderIds 预览订单主键 + * @return 订单主键到有效售后记录的映射 + */ + private Map> loadEffectiveAfterSales(List orderIds) { + return hardAfterSaleService.list(new LambdaQueryWrapper() + .in(HardAfterSaleEntity::getOrderId, orderIds) + .in(HardAfterSaleEntity::getStatus, + List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus())) + .orderByAsc(HardAfterSaleEntity::getId)) + .stream().collect(Collectors.groupingBy(HardAfterSaleEntity::getOrderId, + LinkedHashMap::new, Collectors.toList())); + } + + /** + * 生成单笔订单预览并给出不可退款原因;实际任务仍会在订单锁内重新校验全部条件。 + * + * @param order 当前数据范围内的订单 + * @param afterSales 待审核和已完成售后记录 + * @param occupiedCars 待审核和已完成售后占用的商品汇总 + * @return 单笔订单预览明细 + */ + private HardBatchRefundPreviewVo.OrderItem buildOrderItem(HardOrderVo order, + List afterSales, + List occupiedCars) { + HardBatchRefundPreviewVo.OrderItem item = new HardBatchRefundPreviewVo.OrderItem(); + item.setOrderId(order.getId()); + item.setOrderNo(order.getOrderNo()); + item.setBusinessDate(resolveBusinessDate(order)); + item.setHourType(order.getHourType()); + item.setUserId(order.getUserId()); + item.setUsername(order.getUsername()); + item.setUserDeptName(order.getUserDeptName()); + item.setMerchantName(order.getMerchantName()); + int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()); + item.setRefundPrice(Math.max(0, safePrice(order.getPayPrice()) - completedRefundPrice)); + item.setProducts(buildRemainingProducts(order.getCarList(), occupiedCars)); + String unavailableReason = resolveUnavailableReason(order, afterSales, item.getProducts(), completedRefundPrice); + item.setRefundable(unavailableReason == null); + item.setUnavailableReason(unavailableReason); + return item; + } + + /** + * 业务日期优先体现预约用餐日期,非预约订单再回退到支付或创建日期。 + * + * @param order 当前订单 + * @return 可用于抽屉按日扫描的业务日期;历史时间均为空时返回 {@code null} + */ + private LocalDate resolveBusinessDate(HardOrderVo order) { + if (order.getApptUseDate() != null) return order.getApptUseDate(); + if (order.getPayTime() != null) return order.getPayTime().toLocalDate(); + return order.getCreateTime() == null ? null : order.getCreateTime().toLocalDate(); + } + + /** + * 按商品主键聚合原购物车重复行,并扣除待审核和已完成售后占用的数量、重量及商品金额。 + * + * @param originalCars 原订单购物车快照 + * @param occupiedCars 有效售后商品占用汇总 + * @return 仍有数量或重量可退的商品明细,同时携带对应剩余商品金额 + */ + private List buildRemainingProducts(List originalCars, + List occupiedCars) { + if (ObjectUtil.isEmpty(originalCars)) return List.of(); + Map occupiedByProductId = occupiedCars.stream() + .filter(item -> item != null && item.getProductId() != null) + .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item, + (first, second) -> first, LinkedHashMap::new)); + Map products = new LinkedHashMap<>(); + for (HardCarVo car : originalCars) { + if (car == null) continue; + String key = car.getProductId() == null ? "car:" + car.getId() : "product:" + car.getProductId(); + products.computeIfAbsent(key, ignored -> new ProductAccumulator(car)).add(car); + } + List result = new ArrayList<>(); + for (ProductAccumulator product : products.values()) { + HardAfterSaleCarEntity occupied = occupiedByProductId.get(product.getProductId()); + HardBatchRefundPreviewVo.ProductItem item = product.toRemainingItem(occupied); + if (item != null) result.add(item); + } + return result; + } + + /** + * 按现有单笔退款能力判断渠道、订单类型、历史退款和剩余购物车是否满足批量执行条件。 + * + * @param order 当前订单 + * @param afterSales 当前有效售后记录 + * @param products 扣除有效售后占用后的剩余商品 + * @param completedRefundPrice 已完成退款金额,单位分 + * @return 不可退款原因;返回 {@code null} 表示通过预览校验 + */ + private String resolveUnavailableReason(HardOrderVo order, List afterSales, + List products, + int completedRefundPrice) { + HardOrderTypeEnum orderType = HardOrderTypeEnum.valueOfByType(order.getOrderType()); + if (!HardOrderTypeEnum.isRefund(orderType) || ObjectUtil.equals(orderType, HardOrderTypeEnum.ZY)) { + return "当前订单类型不支持后台退款"; + } + HardPayTypeEnum payType = HardPayTypeEnum.valueOfByType(order.getPayType()); + if (payType == null || !isSupportedRefundChannel(orderType, payType)) { + return "当前支付方式不支持原路退款"; + } + if (safePrice(order.getPayPrice()) <= 0) { + return "订单实付金额异常"; + } + if (afterSales.stream().anyMatch(item -> ObjectUtil.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) { + return "已有退款申请待审核"; + } + if (completedRefundPrice >= safePrice(order.getPayPrice())) { + return "订单已全部退款"; + } + boolean simplePay = HardPayTypeEnum.isSimplePayList.contains(payType) + || HardOrderTypeEnum.isSimplePayList.contains(orderType); + if (!simplePay && products.isEmpty()) { + return "没有剩余可退商品"; + } + return null; + } + + /** + * 退款渠道严格跟随现有售后服务的分支:钱包支付或四类微信、支付宝原路退款。 + * + * @param orderType 订单类型协议枚举 + * @param payType 支付方式协议枚举 + * @return {@code true} 表示现有服务存在对应渠道退款实现 + */ + private boolean isSupportedRefundChannel(HardOrderTypeEnum orderType, HardPayTypeEnum payType) { + return hardOrderService.isPay(orderType, payType) || SUPPORTED_THIRD_PARTY_PAY_TYPES.contains(payType); + } + + /** + * 汇总可退款订单的去重人数、菜品种类、份数、重量和剩余金额。 + * + * @param items 全部预览订单明细 + * @return 只累计可退款订单的汇总 + */ + private HardBatchRefundPreviewVo.Summary buildSummary(List items) { + HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary(); + List refundableItems = items.stream() + .filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList(); + Set userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Set productKeys = new LinkedHashSet<>(); + long productCount = 0L; + BigDecimal productWeight = BigDecimal.ZERO; + long refundPrice = 0L; + for (HardBatchRefundPreviewVo.OrderItem item : refundableItems) { + refundPrice += safePrice(item.getRefundPrice()); + for (HardBatchRefundPreviewVo.ProductItem product : item.getProducts()) { + productKeys.add(product.getProductId() == null ? "name:" + product.getProductName() + : "id:" + product.getProductId()); + productCount += product.getProductCount() == null ? 0 : Math.max(0, product.getProductCount()); + productWeight = productWeight.add(safeWeight(product.getProductWeight())); + } + } + summary.setSelectedOrderCount(items.size()); + summary.setRefundableOrderCount(refundableItems.size()); + summary.setUnavailableOrderCount(items.size() - refundableItems.size()); + summary.setUserCount(userIds.size()); + summary.setProductKindCount(productKeys.size()); + summary.setProductCount(productCount); + summary.setProductWeight(productWeight); + summary.setRefundPrice(refundPrice); + return summary; + } + + /** + * 在通用异步线程中逐笔退款;每笔使用独立事务,失败会记录结果并继续后续订单。 + * + * @param orders 提交时冻结的预览订单 + * @param remark 去除首尾空白后的统一退款备注 + * @param operatorId 后台退款操作人主键 + * @param reporter 通用异步任务进度报告器 + * @return 包含完整逐笔结果工作簿下载地址的任务结果 + */ + private AsyncTaskResult executeBatch(List orders, String remark, + Long operatorId, AsyncTaskProgressReporter reporter) { + List results = new ArrayList<>(orders.size()); + int successCount = 0; + int failedCount = 0; + for (HardBatchRefundPreviewVo.OrderItem order : orders) { + if (!Boolean.TRUE.equals(order.getRefundable())) { + failedCount++; + results.add(toResultRow(order, "跳过", order.getUnavailableReason())); + } else { + try { + refundOneOrder(order.getOrderId(), remark, operatorId); + successCount++; + results.add(toResultRow(order, "成功", "退款已完成")); + } catch (Exception exception) { + failedCount++; + String message = safeErrorMessage(exception); + log.warn("|{}|单笔退款失败,orderId:{},message:{}", LOG_TAG, order.getOrderId(), message); + results.add(toResultRow(order, "失败", message)); + } + } + reporter.advance("已处理 " + results.size() + " / " + orders.size() + " 笔,成功 " + + successCount + " 笔,失败或跳过 " + failedCount + " 笔"); + } + return createResultFile(results); + } + + /** + * 使用 REQUIRES_NEW 包裹既有整单退款,使异步任务中的某笔失败不会回滚其他已完成退款。 + * + * @param orderId 当前退款订单主键 + * @param remark 本批次统一退款备注 + * @param operatorId 后台退款操作人主键 + */ + private void refundOneOrder(Long orderId, String remark, Long operatorId) { + HardAdminRefundDto refund = new HardAdminRefundDto(); + refund.setOrderId(orderId); + refund.setWay(REFUND_ONLY_WAY); + refund.setApplyReason(remark); + refund.setApplyDescription(remark); + refund.setApplyPicUrls(""); + + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + Boolean result = transaction.execute(status -> hardAfterSaleService.adminRefund(refund, operatorId)); + if (!Boolean.TRUE.equals(result)) { + throw new IllegalStateException("退款服务未返回成功结果"); + } + } + + /** + * 将单笔执行结果转换为结果工作簿行,金额使用提交任务时的预览剩余金额。 + * + * @param order 提交时冻结的订单预览 + * @param status 执行状态:成功、失败或跳过 + * @param message 面向后台操作人的业务结果说明 + * @return 可直接交给 EasyExcel 的结果行 + */ + private BatchRefundResultExcelRow toResultRow(HardBatchRefundPreviewVo.OrderItem order, String status, + String message) { + BatchRefundResultExcelRow row = new BatchRefundResultExcelRow(); + row.setOrderId(String.valueOf(order.getOrderId())); + row.setOrderNo(order.getOrderNo()); + row.setBusinessDate(order.getBusinessDate() == null ? null : order.getBusinessDate().toString()); + row.setUsername(StrUtil.blankToDefault(order.getUsername(), "未知用户")); + row.setPreviewRefundPrice(BigDecimal.valueOf(safePrice(order.getRefundPrice()), 2) + .setScale(2, RoundingMode.HALF_UP).toPlainString()); + row.setStatus(status); + row.setMessage(message); + return row; + } + + /** + * 生成并上传结果工作簿;文件异常不改变已经逐笔提交的真实退款结果。 + * + * @param results 全部订单执行结果 + * @return 成功上传时携带下载地址,文件异常时返回无附件成功结果 + */ + private AsyncTaskResult createResultFile(List results) { + Path outputPath = null; + String fileName = "批量退款结果_" + LocalDateTime.now().format(RESULT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + outputPath = Files.createTempFile("hard-batch-refund-", ".xlsx"); + EasyExcel.write(outputPath.toFile(), BatchRefundResultExcelRow.class) + .sheet(RESULT_SHEET_NAME).doWrite(results); + return AsyncTaskResult.file(uploadResultFile(outputPath, fileName), fileName); + } catch (Exception exception) { + log.error("|{}|结果文件生成或上传失败,fileName:{}", LOG_TAG, fileName, exception); + return AsyncTaskResult.empty(); + } finally { + deleteTemporaryResultFile(outputPath); + } + } + + /** + * 上传完整结果工作簿并校验文件中心统一业务响应。 + * + * @param file 已关闭并可读取的临时 XLSX 文件 + * @param fileName 用户下载时看到的文件名 + * @return 文件中心下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadResultFile(Path file, String fileName) throws Exception { + MultipartFile resultFile = new MockMultipartFile("file", encodeOriginalFilename(fileName), + XLSX_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(resultFile, RESULT_FILE_DIR, null, RESULT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的批量退款结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回批量退款结果下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务历史约定转换中文原始文件名编码。 + * + * @param fileName UTF-8 中文业务文件名 + * @return 文件中心上传接口使用的 ISO-8859-1 原始文件名 + */ + private String encodeOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 删除批量退款临时结果文件,清理失败只记录告警且不覆盖退款结果。 + * + * @param outputPath 待清理的临时路径,允许为空 + */ + private void deleteTemporaryResultFile(Path outputPath) { + if (outputPath == null) return; + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理临时结果文件失败,path:{}", LOG_TAG, outputPath, exception); + } + } + + /** + * 提取可展示的业务异常信息并限制长度,避免把技术堆栈或超长下游响应写入结果文件。 + * + * @param exception 单笔退款抛出的异常 + * @return 最多 200 个字符的业务失败说明 + */ + private String safeErrorMessage(Exception exception) { + String message = exception == null ? null : exception.getMessage(); + if (StrUtil.isBlank(message)) return "退款失败,请查看服务日志"; + return message.length() > 200 ? message.substring(0, 200) : message; + } + + /** + * 汇总指定状态售后单金额,历史空金额按零处理并限制在整数分范围内。 + * + * @param afterSales 当前订单有效售后记录 + * @param status 需要汇总的售后状态编码 + * @return 累计金额,单位分 + */ + private int sumRefundPrice(List afterSales, String status) { + long total = afterSales.stream().filter(item -> ObjectUtil.equals(item.getStatus(), status)) + .map(HardAfterSaleEntity::getRefundPrice).filter(Objects::nonNull).mapToLong(Integer::longValue).sum(); + return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : Math.toIntExact(Math.max(0L, total)); + } + + /** + * 将可空金额转换为非负整数分,历史负值按零处理。 + * + * @param price 数据库或预览中的金额 + * @return 可参与退款计算的非负金额 + */ + private int safePrice(Integer price) { + return price == null ? 0 : Math.max(0, price); + } + + /** + * 将可空重量转换为非负值,避免历史负数扩大剩余可退重量。 + * + * @param weight 购物车或售后商品重量 + * @return 非负重量 + */ + private static BigDecimal safeWeight(BigDecimal weight) { + return weight == null ? BigDecimal.ZERO : weight.max(BigDecimal.ZERO); + } + + /** + * 将可空商品金额转换为非负整数分,历史负值按零处理。 + * + * @param price 购物车或售后商品总价,单位分 + * @return 可参与剩余商品金额计算的非负金额 + */ + private static long safeProductPrice(Long price) { + return price == null ? 0L : Math.max(0L, price); + } + + /** + * 同一商品多行购物车快照的原始数量、重量和金额累加器。 + */ + @Data + private static class ProductAccumulator { + + /** 商品主键;无法识别的历史购物车行允许为空。 */ + private final Long productId; + + /** 首行购物车快照中的商品名称。 */ + private final String productName; + + /** 商品性质协议编码,用于选择数量或重量口径。 */ + private final String productNature; + + /** 同一商品全部原购物车行的数量总和。 */ + private int originalCount; + + /** 同一商品全部原购物车行的重量总和。 */ + private BigDecimal originalWeight = BigDecimal.ZERO; + + /** 同一商品全部原购物车行的商品总价,单位为分。 */ + private long originalRefundPrice; + + /** + * 使用首行购物车快照建立商品身份和展示字段。 + * + * @param car 当前商品第一条购物车快照 + */ + private ProductAccumulator(HardCarVo car) { + this.productId = car.getProductId(); + this.productName = car.getProductName(); + this.productNature = car.getProductNature(); + } + + /** + * 累加同一商品购物车行的非负数量、重量和商品总价。 + * + * @param car 同一商品的购物车快照 + */ + private void add(HardCarVo car) { + this.originalCount += car.getProductCount() == null ? 0 : Math.max(0, car.getProductCount()); + this.originalWeight = this.originalWeight.add(safeWeight(car.getProductWeight())); + this.originalRefundPrice = Math.addExact(this.originalRefundPrice, safeProductPrice(car.getTotalPrice())); + } + + /** + * 扣除有效售后占用并转换为抽屉展示商品;无剩余数量或重量时返回空。 + * + * @param occupied 待审核和已完成售后商品占用汇总 + * @return 携带剩余数量、重量和商品金额的可退商品,已无剩余时返回 {@code null} + */ + private HardBatchRefundPreviewVo.ProductItem toRemainingItem(HardAfterSaleCarEntity occupied) { + int occupiedCount = occupied == null || occupied.getProductCount() == null + ? 0 : Math.max(0, occupied.getProductCount()); + BigDecimal occupiedWeight = occupied == null ? BigDecimal.ZERO : safeWeight(occupied.getProductWeight()); + int remainingCount = Math.max(0, originalCount - occupiedCount); + BigDecimal remainingWeight = originalWeight.subtract(occupiedWeight).max(BigDecimal.ZERO); + long occupiedRefundPrice = occupied == null ? 0L : safeProductPrice(occupied.getTotalPrice()); + long remainingRefundPrice = Math.max(0L, originalRefundPrice - occupiedRefundPrice); + boolean countProduct = ObjectUtil.equals(productNature, HardProductNatureEnum.DEFAULT.getType()); + if ((countProduct && remainingCount <= 0) + || (!countProduct && remainingWeight.compareTo(BigDecimal.ZERO) <= 0)) { + return null; + } + HardBatchRefundPreviewVo.ProductItem item = new HardBatchRefundPreviewVo.ProductItem(); + item.setProductId(productId); + item.setProductName(productName); + item.setProductNature(productNature); + item.setProductCount(countProduct ? remainingCount : 0); + item.setProductWeight(countProduct ? BigDecimal.ZERO : remainingWeight); + item.setRefundPrice(remainingRefundPrice); + return item; + } + } + + /** + * 异步批量退款结果工作簿行,保留预览信息和最终逐笔执行状态。 + */ + @Data + public static class BatchRefundResultExcelRow { + + /** Hardware 订单主键,文本格式避免 Excel 科学计数法丢失精度。 */ + @ExcelProperty("订单ID") + private String orderId; + + /** 订单业务编号;历史数据可能为空。 */ + @ExcelProperty("订单编号") + private String orderNo; + + /** 提交任务时冻结的预约、支付或创建业务日期。 */ + @ExcelProperty("业务日期") + private String businessDate; + + /** 提交任务时订单关联的订餐人姓名。 */ + @ExcelProperty("订餐人") + private String username; + + /** 提交任务时服务端计算的剩余可退金额,单位元。 */ + @ExcelProperty("预览可退金额(元)") + private String previewRefundPrice; + + /** 本次异步任务的逐笔状态:成功、失败或跳过。 */ + @ExcelProperty("处理状态") + private String status; + + /** 渠道退款结果或不可退款原因,不包含技术堆栈。 */ + @ExcelProperty("处理说明") + private String message; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index 8a09f42f..31123719 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -17,9 +17,6 @@ import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteDictService; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; -import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; -import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; -import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; @@ -30,19 +27,17 @@ import com.bsd.pjcloud.common.core.util.R; 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.HardSaleSourceEnum; 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.HardCanteenProductApptExportRecord; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; -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.HardCanteenUserApptVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo; import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; -import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -54,7 +49,6 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; -import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -63,8 +57,6 @@ import java.time.LocalTime; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -75,10 +67,9 @@ import java.util.function.Function; import java.util.stream.Collectors; /** - * 食堂预约统计导出服务实现。 + * 用户食堂预约统计导出服务实现。 * - *

菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过 - * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。

+ *

按权限用户分批查询每日预约并流式写入 Excel,避免长日期范围和大量用户一次性占用过多内存。

*/ @Slf4j @Validated @@ -131,33 +122,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - /** 菜品预约导出进度的 Redis Key 命名空间。 */ - private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export"; - - /** 菜品预约导出任务名称。 */ - private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出"; - - /** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */ - private static final int INITIAL_PROGRESS_TOTAL = 1; - - /** 菜品预约导出工作表名称。 */ - private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计"; - - /** 文件中心存储目录。 */ - private static final String EXPORT_FILE_DIR = "hard-export"; - - /** 文件中心业务类型编码。 */ - private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT"; - - /** Excel 文件 MIME 类型。 */ - private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - /** 用餐时间段字典类型。 */ private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; - /** Excel 文件名时间格式。 */ - private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ private static final long MAX_QUERY_MONTHS = 2L; @@ -206,9 +173,6 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; - /** 每日菜谱远程服务,用于展开套餐组成。 */ - private final RemoteDailyMenuService remoteDailyMenuService; - /** 部门远程服务,用于补齐预约人所属部门名称。 */ private final RemoteDeptService remoteDeptService; @@ -537,15 +501,15 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot private void validateQuery(HardCanteenUserApptQueryDto query) { if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2 || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) { - throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期"); + throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期"); } LocalDate startDate = query.getApptUseDateList()[0]; LocalDate endDate = query.getApptUseDateList()[1]; if (endDate.isBefore(startDate)) { - throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + throw new IllegalArgumentException("统计结束日期不能早于开始日期"); } if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { - throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + throw new IllegalArgumentException("统计日期范围最多查询两个月"); } if (StrUtil.isNotBlank(query.getReservationStatus()) && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { @@ -558,7 +522,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } /** - * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。 * * @param query 已通过日期范围校验的查询条件 * @return 按日期升序排列的查询日期 @@ -735,9 +699,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() - .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null) .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, - Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new, Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { @@ -792,16 +756,54 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot new ArrayList<>(userIds.subList(start, end))); orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); } - return orders; + return retainEffectiveReservationOrders(orders, query); } /** - * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * 只保留完成退款后仍有有效商品的预约订单,并把导出金额改为退款后的净商品金额。 + * + * @param orders 已按订单状态和业务日期查询出的预约订单 + * @param query 当前导出筛选条件 + * @return 完成退款后仍有商品的订单,订单总价已更新为净额 + */ + private List retainEffectiveReservationOrders(List orders, + HardCanteenUserApptQueryDto query) { + if (orders.isEmpty()) { + return List.of(); + } + Map effectiveTotalPriceMap; + if (isVerifiedFilter(query)) { + Set orderIds = orders.stream().map(HardOrderVo::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())) + .collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId, + Collectors.summingLong(fact -> number(fact.getTotalPrice())))); + } else { + Map> carMap = fetchEffectiveReservationCars(orders); + effectiveTotalPriceMap = orders.stream() + .filter(order -> order.getId() != null && order.getParentCarId() != null) + .filter(order -> carMap.containsKey(order.getParentCarId())) + .collect(Collectors.toMap(HardOrderVo::getId, + order -> carMap.get(order.getParentCarId()).stream() + .map(HardCarEntity::getTotalPrice) + .mapToLong(HardCanteenApptTotalExportServiceImpl::number).sum(), + (left, right) -> left, LinkedHashMap::new)); + } + return orders.stream().filter(order -> order.getId() != null) + .filter(order -> effectiveTotalPriceMap.containsKey(order.getId())) + .peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId())))) + .toList(); + } + + /** + * 分批读取预约订单关联的完成退款后有效商品,避免生成超长父购物车 IN 条件。 * * @param orders 当前筛选口径下的预约订单 - * @return 父购物车主键到商品快照的映射 + * @return 父购物车主键到退款后有效商品的映射 */ - private Map> fetchReservationCars(List orders) { + private Map> fetchEffectiveReservationCars(List orders) { List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) .filter(Objects::nonNull).distinct().toList(); if (parentCarIds.isEmpty()) { @@ -810,12 +812,41 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot Map> result = new LinkedHashMap<>(); for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); - hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end)) .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); } return result; } + /** + * 按用户分批查询菜品销售统计中的预约核销商品事实。 + * + * @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键 + * @param query 核销日期、食堂和餐段筛选条件 + * @return 完成退款扣减后仍有商品的预约核销事实 + */ + private List fetchReservationVerifiedSaleFacts( + List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List facts = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + HardOrderDto factQuery = new HardOrderDto(); + factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end))); + factQuery.setMerchantId(query.getMerchantId()); + factQuery.setHourType(query.getHourType()); + factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + factQuery.setStatisticsDateTimeList(new LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery)); + } + return facts; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -833,15 +864,64 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot orderQuery.setMerchantId(query.getMerchantId()); orderQuery.setHourType(query.getHourType()); orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType()); - orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES); orderQuery.setApptIdOrApptRuleIdNotNull(true); - orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{ - query.getApptUseDateList()[0].atStartOfDay(), - query.getApptUseDateList()[1].atTime(LocalTime.MAX) - }); + LocalDateTime[] dateTimeRange = new LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)}; + if (isVerifiedFilter(query)) { + orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); + orderQuery.setVerificationTimeList(dateTimeRange); + } else { + orderQuery.setOrderStatusCodes(isUnverifiedFilter(query) + ? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptUseDateTimeList(dateTimeRange); + } return orderQuery; } + /** + * 解析导出行的业务统计日期。 + * + * @param order 预约订单 + * @param query 当前核销状态筛选 + * @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期 + */ + private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate(); + } + return order.getApptUseDate(); + } + + /** + * 判断当前导出是否要求按单笔已核销订单统计。 + * + * @param query 导出查询条件 + * @return 核销状态为 VERIFIED 时返回 true + */ + private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED); + } + + /** + * 判断当前导出是否要求按单笔待核销订单统计。 + * + * @param query 导出查询条件 + * @return 核销状态为 UNVERIFIED 时返回 true + */ + private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED); + } + + /** + * 将可能为空的商品金额安全归一为长整型零值。 + * + * @param value 商品金额,单位为分 + * @return 原数值的长整型表示;空值返回零 + */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + /** * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 * @@ -881,6 +961,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); detail.setId(order.getId()); detail.setHourType(order.getHourType()); + detail.setApptUseDate(order.getApptUseDate()); detail.setProductDesc(order.getProductDesc()); detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); @@ -1303,459 +1384,4 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot } } - /** - * 提交菜品预约异步导出任务。 - * - * @param query 菜品预约统计筛选条件 - * @return 初始异步任务进度 - */ - @Override - public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) { - HardOrderDto querySnapshot = copyProductQuery(query); - return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME, - INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter)); - } - - /** - * 同步查询菜品预约自定义 Excel 行。 - * - * @param query 菜品预约统计筛选条件 - * @return 三级层级导出行 - */ - @Override - public List productReservationExport(HardOrderDto query) { - HardOrderDto querySnapshot = copyProductQuery(query); - List rows = new ArrayList<>(); - for (LocalDate date : getQueryDates(querySnapshot)) { - HardOrderDto dateQuery = copyProductQuery(querySnapshot); - dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)}); - rows.addAll(buildProductRows(dateQuery, date)); - } - return rows; - } - - /** - * 后台按日期批次生成菜品预约工作簿并上传文件中心。 - * - * @param query 后台线程独占的查询快照 - * @param reporter 当前异步任务进度报告器 - * @return 文件中心结果 - */ - private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) { - List dates = getQueryDates(query); - reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出"); - Path outputPath = null; - String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx"; - try { - outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx"); - long rowCount; - try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class) - .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) { - WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build(); - rowCount = 0L; - for (LocalDate date : dates) { - HardOrderDto dateQuery = copyProductQuery(query); - dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)}); - List batchRows = buildProductRows(dateQuery, date); - if (!batchRows.isEmpty()) { - writer.write(batchRows, sheet); - rowCount += batchRows.size(); - } - reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行"); - } - if (rowCount == 0L) { - throw new IllegalStateException("没有符合当前条件的菜品预约数据"); - } - } - String fileUrl = upload(outputPath, fileName); - reporter.advance("菜品预约导出文件已生成"); - return AsyncTaskResult.file(fileUrl, fileName); - } catch (IllegalStateException exception) { - throw exception; - } catch (Exception exception) { - log.error("菜品预约统计导出失败,fileName:{}", fileName, exception); - throw new IllegalStateException("生成菜品预约导出文件失败", exception); - } finally { - if (outputPath != null) { - try { - Files.deleteIfExists(outputPath); - } catch (Exception exception) { - log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception); - } - } - } - } - - /** - * 查询一个日期批次的三层数据并转换为 Excel 行。 - * - * @param query 日期批次查询条件 - * @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取 - * @return 当前批次的汇总、商户部门、商品明细和商品统计行 - */ - private List buildProductRows(HardOrderDto query, LocalDate fixedDate) { - List summaries = hardCarMapper.listApptExportSummary(query); - List merchants = hardCarMapper.listApptExportMerchant(query); - List products = hardCarMapper.listApptExportProduct(query); - if (summaries == null || summaries.isEmpty()) { - return List.of(); - } - Map merchantNames = fetchMerchantNames(merchants); - Map departmentNames = fetchDepartmentNames(merchants); - Map hourNames = fetchHourNames(); - Map menuMap = fetchDailyMenus(products, fixedDate); - Map> merchantGroups = merchants.stream() - .collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList())); - Map> productGroups = products.stream() - .collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList())); - List rows = new ArrayList<>(); - for (HardCanteenProductTotalVo summary : summaries) { - LocalDate date = fixedDate != null ? fixedDate - : summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate(); - if (date == null) { - log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType()); - continue; - } - String hourType = summary.getHourType(); - String summaryKey = date + "|" + hourType; - List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of()); - Map> detailRowsByMerchant = new LinkedHashMap<>(); - Map detailCountsByMerchant = new HashMap<>(); - Map detailPricesByMerchant = new HashMap<>(); - long summaryProductCount = 0L; - long summaryTotalPrice = 0L; - for (HardCanteenProductApptExportRecord merchant : merchantRows) { - String merchantKey = groupKey(merchant); - List detailRows = productGroups - .getOrDefault(merchantKey, List.of()).stream() - .flatMap(product -> toProductRows(product, - menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream()) - .toList(); - List statisticRows = buildProductStatistics(detailRows); - long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum(); - long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum(); - // 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。 - if (detailRows.isEmpty()) { - detailCount = valueOrZero(merchant.getPayCount()); - detailPrice = valueOrZero(merchant.getTotalPrice()); - } - List merchantProductRows = new ArrayList<>( - detailRows.size() + statisticRows.size()); - merchantProductRows.addAll(detailRows); - merchantProductRows.addAll(statisticRows); - detailRowsByMerchant.put(merchantKey, merchantProductRows); - detailCountsByMerchant.put(merchantKey, detailCount); - detailPricesByMerchant.put(merchantKey, detailPrice); - summaryProductCount += detailCount; - summaryTotalPrice += detailPrice; - } - if (merchantRows.isEmpty()) { - summaryProductCount = valueOrZero(summary.getPayCount()); - summaryTotalPrice = valueOrZero(summary.getTotalPrice()); - } - HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo(); - summaryRow.setSummaryDate(date); - summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames)); - summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount())); - summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount())); - // 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。 - summaryRow.setSummaryPayCount(summaryProductCount); - summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice)); - rows.add(summaryRow); - for (HardCanteenProductApptExportRecord merchant : merchantRows) { - HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo(); - merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录")); - merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录")); - merchantRow.setMerchantDate(date); - merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames)); - merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount())); - merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount())); - String merchantKey = groupKey(merchant); - merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey, - valueOrZero(merchant.getPayCount()))); - merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey, - valueOrZero(merchant.getTotalPrice())))); - rows.add(merchantRow); - rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of())); - } - } - return rows; - } - - /** - * 将商品聚合行按预约当天每日菜谱中的套餐组成展开。 - * - *

组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊, - * 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。

- * - * @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径 - * @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行 - * @return 单品原始行,或按套餐组成顺序生成的商品明细行 - */ - private List toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) { - List items = menu != null && menu.getComboItems() != null - && !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of(); - if (items.isEmpty()) { - return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()), - product.getTotalPrice())); - } - long packageCount = valueOrZero(product.getPayCount()); - long totalCents = valueOrZero(product.getTotalPrice()); - long sign = totalCents < 0 ? -1L : 1L; - long absoluteTotalCents = Math.abs(totalCents); - long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice())) - * Math.max(1, valueOrZero(item.getQuantity()))).sum(); - long[] itemPrices = new long[items.size()]; - long[] itemWeights = new long[items.size()]; - long allocated = 0L; - List remainderIndexes = new ArrayList<>(); - List result = new ArrayList<>(); - for (int index = 0; index < items.size(); index++) { - OnlineSpuComboItemVO item = items.get(index); - long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity())); - itemWeights[index] = itemWeight; - if (baseAmount > 0) { - BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) - .multiply(BigDecimal.valueOf(itemWeight)) - .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); - itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue(); - allocated += itemPrices[index]; - remainderIndexes.add(index); - } - } - if (baseAmount > 0) { - long remainder = absoluteTotalCents - allocated; - // 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。 - remainderIndexes.sort(Comparator.comparing((Integer index) -> { - BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) - .multiply(BigDecimal.valueOf(itemWeights[index])) - .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); - return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index])); - }).reversed()); - for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) { - itemPrices[remainderIndexes.get(index)]++; - } - } else if (!items.isEmpty()) { - itemPrices[items.size() - 1] = absoluteTotalCents; - } - for (int index = 0; index < items.size(); index++) { - OnlineSpuComboItemVO item = items.get(index); - long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity())); - long itemPrice = itemPrices[index] * sign; - result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice)); - } - return result; - } - - /** 创建单个商品明细行。 */ - private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) { - HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); - row.setProductName(name); - row.setProductCount(count); - row.setProductTotalPrice(centsToYuan(totalPrice)); - return row; - } - - /** - * 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。 - * - *

套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键, - * 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。

- * - * @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细 - * @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致 - */ - private List buildProductStatistics( - List detailRows) { - Map statistics = new LinkedHashMap<>(); - for (HardCanteenProductApptExcelVo detailRow : detailRows) { - String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim(); - String statisticKey = normalizeProductName(displayName); - HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> { - HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); - row.setStatisticProductName(displayName); - row.setStatisticProductCount(0L); - row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); - return row; - }); - statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount()) - + valueOrZero(detailRow.getProductCount())); - long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice()) - + yuanToCents(detailRow.getProductTotalPrice()); - statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice)); - } - return new ArrayList<>(statistics.values()); - } - - /** - * 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。 - * - * @param productName 首次出现的商品展示名称 - * @return 去除首尾空白并把连续空白压缩为单个空格的统计键 - */ - private String normalizeProductName(String productName) { - return productName.replaceAll("\\s+", " ").trim(); - } - - /** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */ - private Map fetchDailyMenus(List products, - LocalDate fixedDate) { - Map result = new HashMap<>(); - LocalDate date = fixedDate; - if (date == null) return result; - List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId) - .filter(Objects::nonNull).distinct().toList(); - for (Long merchantId : merchantIds) { - for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId)) - .map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) { - try { - Integer hour = Integer.valueOf(hourType); - R> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId); - if (response != null && response.isOk() && response.getData() != null) { - response.getData().forEach(menu -> { - if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu); - }); - } - } catch (Exception exception) { - log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}", - merchantId, date, hourType, exception.getMessage()); - } - } - } - return result; - } - - /** 批量查询商户名称。 */ - private Map fetchMerchantNames(List rows) { - List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList(); - if (ids.isEmpty()) return Map.of(); - try { - R> response = remoteMchService.getByIds(ids); - if (response == null || !response.isOk() || response.getData() == null) return Map.of(); - return response.getData().stream().filter(item -> item.getId() != null) - .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left)); - } catch (Exception exception) { - log.warn("查询导出商户名称失败,使用空名称", exception); - return Map.of(); - } - } - - /** 批量查询部门名称。 */ - private Map fetchDepartmentNames(List rows) { - List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList(); - if (ids.isEmpty()) return Map.of(); - try { - R> response = remoteDeptService.getDeptByDeptIds(ids); - if (response == null || !response.isOk() || response.getData() == null) return Map.of(); - return response.getData().values().stream().filter(item -> item.getDeptId() != null) - .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (left, right) -> left)); - } catch (Exception exception) { - log.warn("查询导出部门名称失败,使用空名称", exception); - return Map.of(); - } - } - - /** 查询餐段字典。 */ - private Map fetchHourNames() { - try { - R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); - if (response == null || !response.isOk() || response.getData() == null) return Map.of(); - return response.getData().stream().filter(item -> StrUtil.isNotBlank(item.getItemValue())) - .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); - } catch (Exception exception) { - log.warn("查询导出餐段字典失败,使用餐段编码", exception); - return Map.of(); - } - } - - /** 复制菜品统计查询条件,避免异步任务与请求对象共享可变数组和集合。 */ - private HardOrderDto copyProductQuery(HardOrderDto source) { - if (source == null) throw new IllegalArgumentException("菜品预约导出查询条件不能为空"); - HardOrderDto target = new HardOrderDto(); - target.setApptUseDateTimeList(source.getApptUseDateTimeList() == null ? null : source.getApptUseDateTimeList().clone()); - target.setHourType(source.getHourType()); - target.setMerchantId(source.getMerchantId()); - target.setUserDeptIds(copyList(source.getUserDeptIds())); - target.setIsInclude(source.getIsInclude()); - target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); - target.setMchIds(copyList(source.getMchIds())); - target.setOrderType(HardOrderTypeEnum.RESERVE.getType()); - target.setApptIdOrApptRuleIdNotNull(true); - target.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); - return target; - } - - /** 获取查询日期闭区间。 */ - private List getQueryDates(HardOrderDto query) { - if (query.getApptUseDateTimeList() == null || query.getApptUseDateTimeList().length != 2 - || query.getApptUseDateTimeList()[0] == null || query.getApptUseDateTimeList()[1] == null) { - throw new IllegalArgumentException("预约时间不能为空"); - } - LocalDate start = query.getApptUseDateTimeList()[0].toLocalDate(); - LocalDate end = query.getApptUseDateTimeList()[1].toLocalDate(); - if (end.isBefore(start)) throw new IllegalArgumentException("预约结束时间不能早于开始时间"); - List dates = new ArrayList<>(); - for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) dates.add(date); - return dates; - } - - /** 构造商户部门分组键。 */ - private String groupKey(HardCanteenProductApptExportRecord record) { - return record.getApptDate() + "|" + record.getHourType() + "|" + record.getMerchantId() + "|" + record.getUserDeptId(); - } - - /** 构造日期餐段分组键,用于从日期餐段汇总行定位其下属商户部门统计。 */ - private String summaryGroupKey(HardCanteenProductApptExportRecord record) { - return record.getApptDate() + "|" + record.getHourType(); - } - - /** 构造商品每日菜谱缓存键。 */ - private String menuKey(HardCanteenProductApptExportRecord record, LocalDate date) { - return record.getMerchantId() + "|" + date + "|" + record.getHourType() + "|" + record.getProductId(); - } - - /** 将空数值按零处理。 */ - private long valueOrZero(Number value) { - return value == null ? 0L : value.longValue(); - } - - /** 将分转换为元,保持两位小数。 */ - private BigDecimal centsToYuan(Number cents) { - return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP); - } - - /** - * 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。 - * - *

商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。

- * - * @param yuan 商品明细展示金额,单位为元;为空按零处理 - * @return 金额,单位为分 - */ - private long yuanToCents(BigDecimal yuan) { - return yuan == null ? 0L : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); - } - - /** 解析餐段名称,字典缺失时保留原始编码。 */ - private String resolveHourName(String hourType, Map hourNames) { - return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); - } - - /** 上传已关闭的 Excel 临时文件并提取下载地址。 */ - private String upload(Path path, String fileName) throws Exception { - MultipartFile file = new MockMultipartFile("file", encodeFileName(fileName), EXCEL_CONTENT_TYPE, - Files.readAllBytes(path)); - R response = remoteFileService.innerUpload(file, EXPORT_FILE_DIR, null, PRODUCT_EXPORT_FILE_TYPE); - if (response == null || !response.isOk() || !(response.getData() instanceof Map data) - || data.get("url") == null || StrUtil.isBlank(data.get("url").toString())) { - throw new IllegalStateException("文件中心未返回菜品预约导出下载地址"); - } - return data.get("url").toString(); - } - - /** 按文件中心约定编码中文文件名。 */ - private String encodeFileName(String fileName) { - return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); - } - } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 86004770..8492f2fe 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; 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.HardSaleSourceEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity; @@ -21,12 +22,14 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDailyStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishUserStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptMealStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.mapper.order.model.HardCanteenReservationSaleFact; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -40,6 +43,7 @@ import java.time.LocalTime; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -260,8 +264,8 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** * 按当前用户预约查询条件生成每日和查询范围总计。 * - *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数, - * 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。

+ *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。 + * 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。

* * @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件 * @return 按日期升序排列并包含范围总计的统计结果 @@ -276,7 +280,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ List rows = filterRows(buildRows(users, query, queryDates, orders), query); Map orderMap = orders.stream().filter(order -> order.getId() != null) .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); - Map> carMap = fetchReservationCars(orders); + Map> carMap = fetchReservationCars(orders, query); Map dailyBuckets = new LinkedHashMap<>(); queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); @@ -296,6 +300,114 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return result; } + /** + * 按 App 当前账号可管理食堂统计预约菜品及点餐人员。 + * + *

与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源, + * 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。

+ * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 + * @return 与 PC 端核销状态口径一致且完成退款扣减后的菜品及点餐人员汇总 + */ + @Override + public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics( + HardCanteenUserApptQueryDto query, List merchantIds) { + return appUserReservationDailyStatistics(query, merchantIds).getTotal(); + } + + /** + * 按 App 当前账号可管理食堂统计每日预约及查询范围总计。 + * + *

订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行, + * 保证待核销、部分核销和已核销的协议值及统计日期口径一致。

+ * + * @param query 预约日期、餐段和核销状态筛选条件 + * @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围 + * @return 按统计日期升序排列的每日预约统计及范围总计 + */ + @Override + public HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics( + HardCanteenUserApptQueryDto query, List merchantIds) { + validateQuery(query); + List scopedMerchantIds = merchantIds == null + ? List.of() : merchantIds.stream().filter(Objects::nonNull).distinct().toList(); + if (scopedMerchantIds.isEmpty()) { + return new HardCanteenUserApptStatisticsVo(); + } + + HardOrderDto orderQuery = buildReservationOrderQuery(query, null); + orderQuery.setMerchantId(null); + orderQuery.setMchIds(scopedMerchantIds); + List orders = retainEffectiveReservationOrders( + hardOrderMapper.scopeList(orderQuery, null), query); + if (orders.isEmpty()) { + return new HardCanteenUserApptStatisticsVo(); + } + + List userIds = orders.stream().map(HardOrderVo::getUserId) + .filter(Objects::nonNull).distinct().toList(); + List users = fetchUsersByIds(userIds); + List queryDates = getQueryDates(query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query).stream() + .filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList(); + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + Map> carMap = fetchReservationCars(orders, query); + + Map dailyBuckets = new LinkedHashMap<>(); + queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); + StatisticsBucket totalBucket = new StatisticsBucket(); + rows.forEach(row -> { + StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate()); + if (dailyBucket == null) return; + dailyBucket.addRow(row, orderMap, carMap); + totalBucket.addRow(row, orderMap, carMap); + }); + + HardCanteenUserApptStatisticsVo result = new HardCanteenUserApptStatisticsVo(); + result.setTotal(totalBucket.toSummary()); + result.setDaily(queryDates.stream().map(date -> dailyBuckets.get(date).toDaily(date)).toList()); + return result; + } + + /** + * 按订单用户主键批量读取不受商户账号组织数据权限裁剪的用户展示摘要。 + * + *

订单范围已先由可管理食堂限定,因此这里只补齐这些订单关联人员的姓名、账号和部门; + * 若历史用户已删除,则保留一个未命名占位,确保对应订单和菜品份数不会从统计中消失。

+ * + * @param userIds 可管理食堂有效预约订单中的去重用户主键 + * @return 与输入主键顺序一致的用户摘要列表 + */ + private List fetchUsersByIds(List userIds) { + if (userIds.isEmpty()) { + return List.of(); + } + Map userMap = new LinkedHashMap<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + List batchUserIds = new ArrayList<>(userIds.subList(start, end)); + R> response = remoteUserService.getUserDeptByIds(batchUserIds); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.isBlank(message) ? "查询点餐人员信息失败" : message); + } + response.getData().stream().filter(user -> user.getUserId() != null) + .forEach(user -> userMap.putIfAbsent(user.getUserId(), user)); + } + return userIds.stream().map(userId -> { + UserVO user = userMap.get(userId); + if (user != null) { + return user; + } + UserVO fallback = new UserVO(); + fallback.setUserId(userId); + fallback.setName("未命名用户"); + return fallback; + }).toList(); + } + /** * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。 * @@ -304,15 +416,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ private void validateQuery(HardCanteenUserApptQueryDto query) { if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2 || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) { - throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期"); + throw new IllegalArgumentException("统计日期范围必须包含开始和结束日期"); } LocalDate startDate = query.getApptUseDateList()[0]; LocalDate endDate = query.getApptUseDateList()[1]; if (endDate.isBefore(startDate)) { - throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + throw new IllegalArgumentException("统计结束日期不能早于开始日期"); } if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { - throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + throw new IllegalArgumentException("统计日期范围最多查询两个月"); } if (StrUtil.isNotBlank(query.getReservationStatus()) && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { @@ -325,7 +437,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * 展开统计范围内的全部自然日,开始和结束日期均包含在结果中。 * * @param query 已通过日期范围校验的查询条件 * @return 按日期升序排列的查询日期 @@ -398,8 +510,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ * @param queryDates 查询范围内按升序排列的全部日期 * @return 符合用户和预约状态条件的全部行 */ - private List listAllFilteredRows(HardCanteenUserApptQueryDto query, - List queryDates) { + private List listAllFilteredRows(HardCanteenUserApptQueryDto query, List queryDates) { List users = fetchAllUsers(query); return filterRows(buildRows(users, query, queryDates), query); } @@ -536,8 +647,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ * @return 含准确总数的用户分页 */ private Page fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) { - R> response = remoteUserService.getScopeBasicUserPage(current, size, - buildUserQuery(query)); + R> response = remoteUserService.getScopeBasicUserPage(current, size, buildUserQuery(query)); if (response == null || !response.isOk() || response.getData() == null) { String message = response == null ? null : response.getMsg(); throw new IllegalStateException(StrUtil.isBlank(message) ? "查询权限用户列表失败" : message); @@ -623,9 +733,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } Map merchantNameMap = fetchMerchantNameMap(orders); Map>> orderMap = orders.stream() - .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .filter(order -> order.getUserId() != null && resolveStatisticsDate(order, query) != null) .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, - Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.groupingBy(order -> resolveStatisticsDate(order, query), LinkedHashMap::new, Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { @@ -680,16 +790,78 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ new ArrayList<>(userIds.subList(start, end))); orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); } - return orders; + return retainEffectiveReservationOrders(orders, query); } /** - * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。 + * 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。 + * + *

已核销筛选直接使用菜品销售统计事实判定有效订单;其他预约状态读取退款后有效商品快照。 + * 这样全额退款订单不会继续计入人员、订单和金额,部分退款只保留实际剩余值。

+ * + * @param orders 已按订单状态和业务日期查询出的预约订单 + * @param query 当前人员页面筛选条件 + * @return 完成退款后仍有商品的订单,订单总价已更新为净额 + */ + private List retainEffectiveReservationOrders(List orders, + HardCanteenUserApptQueryDto query) { + if (orders.isEmpty()) { + return List.of(); + } + Map effectiveTotalPriceMap; + if (isVerifiedFilter(query)) { + Set orderIds = orders.stream().map(HardOrderVo::getId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + effectiveTotalPriceMap = fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderIds.contains(fact.getOrderId())) + .collect(Collectors.groupingBy(HardCanteenReservationSaleFact::getOrderId, + Collectors.summingLong(fact -> number(fact.getTotalPrice())))); + } else { + Map> carMap = fetchEffectiveReservationCars(orders); + effectiveTotalPriceMap = orders.stream() + .filter(order -> order.getId() != null && order.getParentCarId() != null) + .filter(order -> carMap.containsKey(order.getParentCarId())) + .collect(Collectors.toMap(HardOrderVo::getId, + order -> carMap.get(order.getParentCarId()).stream() + .map(HardCarEntity::getTotalPrice).mapToLong(HardCanteenApptTotalServiceImpl::number).sum(), + (left, right) -> left, LinkedHashMap::new)); + } + return orders.stream().filter(order -> order.getId() != null) + .filter(order -> effectiveTotalPriceMap.containsKey(order.getId())) + .peek(order -> order.setTotalPrice(Math.toIntExact(effectiveTotalPriceMap.get(order.getId())))) + .toList(); + } + + /** + * 分批读取预约订单关联的退款后有效商品,避免统计请求生成超长父购物车 IN 条件。 * * @param orders 当前筛选口径下的预约订单 - * @return 父购物车主键到商品快照的映射 + * @param query 当前人员页面筛选条件;已核销时用于切换到销售统计事实 + * @return 父购物车主键到有效商品快照的映射 */ - private Map> fetchReservationCars(List orders) { + private Map> fetchReservationCars(List orders, + HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + return fetchReservationVerifiedSaleFacts( + orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(), query) + .stream().filter(fact -> fact.getOrderId() != null && orderMap.containsKey(fact.getOrderId())) + .map(fact -> toEffectiveCar(fact, orderMap.get(fact.getOrderId()))) + .collect(Collectors.groupingBy(HardCarEntity::getParentCarId, LinkedHashMap::new, + Collectors.toList())); + } + return fetchEffectiveReservationCars(orders); + } + + /** + * 按父购物车分批读取完成退款扣减后的预约商品。 + * + * @param orders 当前筛选口径下的预约订单 + * @return 父购物车主键到退款后有效商品的映射 + */ + private Map> fetchEffectiveReservationCars(List orders) { List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId) .filter(Objects::nonNull).distinct().toList(); if (parentCarIds.isEmpty()) { @@ -698,12 +870,59 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ Map> result = new LinkedHashMap<>(); for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) { int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size()); - hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end)) + hardCarMapper.listEffectiveReservationCars(parentCarIds.subList(start, end)) .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car)); } return result; } + /** + * 按用户分批查询菜品销售统计中的预约核销商品事实。 + * + * @param userIds 已经过 UPMS 数据权限和人员条件限制的用户主键 + * @param query 核销日期、食堂和餐段筛选条件 + * @return 完成退款扣减后仍有商品的预约核销事实 + */ + private List fetchReservationVerifiedSaleFacts( + List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List facts = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + HardOrderDto factQuery = new HardOrderDto(); + factQuery.setUserIds(new ArrayList<>(userIds.subList(start, end))); + factQuery.setMerchantId(query.getMerchantId()); + factQuery.setHourType(query.getHourType()); + factQuery.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())); + factQuery.setStatisticsDateTimeList(new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + facts.addAll(hardCarMapper.listReservationVerifiedSaleFacts(factQuery)); + } + return facts; + } + + /** + * 将销售统计商品事实转换为人员统计聚合器使用的有效购物车快照。 + * + * @param fact 已扣完成退款的预约核销商品事实 + * @param order 商品所属预约订单 + * @return 仅包含统计所需父购物车、商品、份数和金额的内存对象 + */ + private HardCarEntity toEffectiveCar(HardCanteenReservationSaleFact fact, HardOrderVo order) { + HardCarEntity car = new HardCarEntity(); + car.setParentCarId(order.getParentCarId()); + car.setProductId(fact.getProductId()); + car.setProductName(fact.getProductName()); + car.setProductPic(fact.getProductPic()); + car.setProductCount(fact.getProductCount()); + car.setTotalPrice(fact.getTotalPrice()); + return car; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -721,15 +940,64 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ orderQuery.setMerchantId(query.getMerchantId()); orderQuery.setHourType(query.getHourType()); orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType()); - orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES); orderQuery.setApptIdOrApptRuleIdNotNull(true); - orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{ - query.getApptUseDateList()[0].atStartOfDay(), - query.getApptUseDateList()[1].atTime(LocalTime.MAX) - }); + java.time.LocalDateTime[] dateTimeRange = new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), query.getApptUseDateList()[1].atTime(LocalTime.MAX)}; + if (isVerifiedFilter(query)) { + orderQuery.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType())); + orderQuery.setVerificationTimeList(dateTimeRange); + } else { + orderQuery.setOrderStatusCodes(isUnverifiedFilter(query) + ? List.of(HardOrderStatusEnum.PAY.getType()) : VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptUseDateTimeList(dateTimeRange); + } return orderQuery; } + /** + * 解析当前订单在人员页面中的统计日期。 + * + * @param order 预约订单 + * @param query 当前核销状态筛选 + * @return 已核销筛选返回实际核销日期,其他情况返回预约用餐日期 + */ + private LocalDate resolveStatisticsDate(HardOrderVo order, HardCanteenUserApptQueryDto query) { + if (isVerifiedFilter(query)) { + return order.getVerificationTime() == null ? null : order.getVerificationTime().toLocalDate(); + } + return order.getApptUseDate(); + } + + /** + * 判断当前查询是否要求按单笔已核销订单统计。 + * + * @param query 人员页面查询条件 + * @return 核销状态为 VERIFIED 时返回 true + */ + private boolean isVerifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_VERIFIED); + } + + /** + * 判断当前查询是否要求按单笔待核销订单统计。 + * + * @param query 人员页面查询条件 + * @return 核销状态为 UNVERIFIED 时返回 true + */ + private boolean isUnverifiedFilter(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_UNVERIFIED); + } + + /** + * 将可能为空的数值安全归一为长整型零值。 + * + * @param value 商品数量或金额 + * @return 原数值的长整型表示;空值返回零 + */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + /** * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 * @@ -769,6 +1037,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); detail.setId(order.getId()); detail.setHourType(order.getHourType()); + detail.setApptUseDate(order.getApptUseDate()); detail.setProductDesc(order.getProductDesc()); detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); detail.setTotalPrice(order.getTotalPrice()); @@ -807,7 +1076,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 餐段编码到餐段聚合结果的有序映射。 */ private final Map meals = new LinkedHashMap<>(); - /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + /** 餐段、商品 ID 和名称快照到菜品聚合结果的有序映射;商品 ID 缺失时仅使用名称兼容历史数据。 */ private final Map dishes = new LinkedHashMap<>(); /** @@ -851,14 +1120,29 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ dishCount += count; meal.dishCount += count; String productName = normalize(car.getProductName(), "未命名菜品"); - String dishKey = hourType + "\u0000" + productName; + // 商品 ID 可能被后续菜谱复用,名称快照必须同时参与分组,避免历史菜品被错误并入当前名称。 + String productIdentityKey = car.getProductId() == null ? "name" : "id:" + car.getProductId(); + String productNameKey = StrUtil.cleanBlank(productName); + String dishKey = hourType + "\u0000" + productIdentityKey + "\u0000" + productNameKey; DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, - key -> new DishStatisticsAccumulator(hourType, productName)); + key -> new DishStatisticsAccumulator(hourType, productName, car.getProductPic())); + if (car.getProductId() != null) { + dish.productIds.add(car.getProductId()); + } + // 同餐段、同商品和同名称快照合并时优先保留首个非空图片,兼容早期订单未保存图片的情况。 + if (StrUtil.isBlank(dish.productPic) && StrUtil.isNotBlank(car.getProductPic())) { + dish.productPic = car.getProductPic(); + } dish.productCount += count; dish.totalPrice += number(car.getTotalPrice()); if (orderId != null) { dish.orderIds.add(orderId); } + if (row.getUserId() != null) { + DishUserStatisticsAccumulator user = dish.users.computeIfAbsent(row.getUserId(), + key -> new DishUserStatisticsAccumulator(row)); + user.productCount += count; + } } } } @@ -966,6 +1250,12 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 下单时固化的菜品名称。 */ private final String productName; + /** 当前统计项实际涉及的商品 ID,正常数据仅有一个;集合结构兼容历史无 ID 的名称兜底聚合。 */ + private final Set productIds = new LinkedHashSet<>(); + + /** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */ + private String productPic; + /** 菜品快照份数。 */ private long productCount; @@ -975,15 +1265,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 包含该菜品的去重订单主键。 */ private final Set orderIds = new HashSet<>(); + /** 点过该菜品的用户主键到个人净份数的有序映射。 */ + private final Map users = new LinkedHashMap<>(); + /** * 创建指定餐段和菜品名称的聚合状态。 * * @param hourType 餐段协议编码或历史兼容文案 * @param productName 下单时保存的菜品名称 + * @param productPic 下单时保存的菜品图片地址,历史订单可能为空 */ - private DishStatisticsAccumulator(String hourType, String productName) { + private DishStatisticsAccumulator(String hourType, String productName, String productPic) { this.hourType = hourType; this.productName = productName; + this.productPic = productPic; } /** 转换为菜品统计响应。 */ @@ -991,9 +1286,58 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); vo.setHourType(hourType); vo.setProductName(productName); + vo.setProductIds(new ArrayList<>(productIds)); + vo.setProductPic(productPic); vo.setProductCount(productCount); vo.setOrderCount((long) orderIds.size()); vo.setTotalPrice(totalPrice); + vo.setUsers(users.values().stream().map(DishUserStatisticsAccumulator::toVo).toList()); + return vo; + } + } + + /** 单个用户在指定餐段和菜品统计项下的可变聚合状态。 */ + private static final class DishUserStatisticsAccumulator { + + /** 系统用户主键,来源于当前统计行。 */ + private final Long userId; + + /** 页面展示姓名,来源于当前统计行。 */ + private final String name; + + /** 用户登录账号,来源于当前统计行。 */ + private final String username; + + /** 用户当前所属部门名称,来源于当前统计行。 */ + private final String deptName; + + /** 该用户点选当前菜品并扣除完成退款后的净份数。 */ + private long productCount; + + /** + * 从用户-日期统计行创建个人菜品聚合状态。 + * + * @param row 包含 UPMS 用户摘要的当前统计行 + */ + private DishUserStatisticsAccumulator(HardCanteenUserApptVo row) { + this.userId = row.getUserId(); + this.name = row.getUserName(); + this.username = row.getUsername(); + this.deptName = row.getDeptName(); + } + + /** + * 转换为菜品点餐人员响应。 + * + * @return 当前用户的身份摘要和菜品净份数 + */ + private HardCanteenUserApptDishUserStatisticsVo toVo() { + HardCanteenUserApptDishUserStatisticsVo vo = new HardCanteenUserApptDishUserStatisticsVo(); + vo.setUserId(userId); + vo.setName(name); + vo.setUsername(username); + vo.setDeptName(deptName); + vo.setProductCount(productCount); return vo; } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java index b8656b81..aa750f30 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenTotalServiceImpl.java @@ -4,12 +4,16 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysDictItem; import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteDictService; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService; import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; @@ -18,8 +22,15 @@ import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.CollectionUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum; @@ -36,22 +47,30 @@ 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.HardCanteenProductApptExcelVo; +import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; import java.math.BigDecimal; import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.Comparator; @@ -80,23 +99,130 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { static private final String TAG = "食堂统计"; - /** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */ + /** + * 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 + */ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE; + /** + * 菜品统计异步导出的 Redis 业务命名空间。 + */ + private static final String PRODUCT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:product"; + + /** + * 部门统计异步导出的 Redis 业务命名空间。 + */ + private static final String DEPARTMENT_SALES_EXPORT_REDIS_KEY = "hard:canteen-sales:export:department"; + + /** + * 菜品统计异步导出任务展示名称。 + */ + private static final String PRODUCT_SALES_EXPORT_TASK_NAME = "食堂菜品销售统计导出"; + + /** + * 部门统计异步导出任务展示名称。 + */ + private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出"; + + /** + * 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。 + */ + private static final int SALES_EXPORT_INITIAL_TOTAL = 1; + + /** + * 流式写完日期批次后的工作簿收尾和文件上传进度步数。 + */ + private static final int SALES_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** + * 三级统计 Excel 的双层表头行数。 + */ + private static final int SALES_EXPORT_HEADER_ROW_COUNT = 2; + + /** + * 单个三级统计工作表允许写入的最大业务行数。 + */ + private static final int MAX_SALES_EXPORT_ROWS = + BatchExcelExportTemplate.calculateMaxDetailRowCount(SALES_EXPORT_HEADER_ROW_COUNT, 0); + + /** + * 统一销售统计异步导出的工作表名称。 + */ + private static final String SALES_EXPORT_SHEET_NAME = "食堂销售统计"; + + /** + * 统一销售统计异步导出的文件中心目录。 + */ + private static final String SALES_EXPORT_FILE_DIR = "hard-export"; + + /** + * 菜品统计导出文件的文件中心业务类型。 + */ + private static final String PRODUCT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_SALES_EXPORT"; + + /** + * 部门统计导出文件的文件中心业务类型。 + */ + private static final String DEPARTMENT_SALES_EXPORT_FILE_TYPE = "HARD_CANTEEN_DEPARTMENT_SALES_EXPORT"; + + /** + * 导出聚合记录中的日期餐段汇总层级编码,必须与 Mapper SQL 返回值一致。 + */ + private static final String SALES_EXPORT_RECORD_LEVEL_SUMMARY = "SUMMARY"; + + /** + * 导出聚合记录中的商户部门层级编码,必须与 Mapper SQL 返回值一致。 + */ + private static final String SALES_EXPORT_RECORD_LEVEL_MERCHANT = "MERCHANT"; + + /** + * 导出聚合记录中的商品层级编码,必须与 Mapper SQL 返回值一致。 + */ + private static final String SALES_EXPORT_RECORD_LEVEL_PRODUCT = "PRODUCT"; + + /** + * XLSX 文件的标准 MIME 类型。 + */ + private static final String SALES_EXPORT_EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** + * 异步导出文件名使用的时间格式,保证同一用户连续导出时文件名可区分。 + */ + private static final DateTimeFormatter SALES_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private final HardOrderMapper hardOrderMapper; private final HardCarMapper hardCarMapper; + /** 用户身份查询支持器,负责通过 UPMS 将身份资料条件收敛为订单用户主键。 */ + private final HardUserIdentitySupport userIdentitySupport; + private final RemoteDeptService remoteDeptService; private final RemoteMchService remoteMchService; private final RemoteDataScopeService dataScopeService; private final RemoteCanteenCategoryService canteenCategoryService; - /** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */ + /** + * 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 + */ private final RemoteDailyMenuService remoteDailyMenuService; - /** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */ + /** + * 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 + */ private final RemoteDictService remoteDictService; + /** + * 文件中心远程服务,用于保存异步导出完成后的 XLSX 文件。 + */ + private final RemoteFileService remoteFileService; + + /** + * 通用异步任务执行器,用于恢复租户和用户上下文并持久化导出进度。 + */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + /** * 菜品销量按照分类查询 */ @@ -208,8 +334,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

新版来源统计先读取日期、餐段和来源的完整多维结果,再按自然日分页装配为“当日合计 -> 餐段合计”, * 每条合计行携带对应来源明细,避免来源行占据主表分组后无法查看整体数据。旧调用方未传统计日期时仍使用原 SQL。

* - * @param page 分页参数;新版统计的每页数量表示自然日数量 - * @param dto 统计日期、来源、食堂、餐段和部门筛选 + * @param page 分页参数;新版统计的每页数量表示自然日数量 + * @param dto 统计日期、来源、食堂、餐段和部门筛选 * @param dataScope 订单部门数据范围;为空时不追加数据权限条件 * @return 按自然日分页且包含餐段和来源明细的统计结果 */ @@ -244,16 +370,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { *

交易人数等合计值直接使用对应数据库分组结果,禁止通过来源行相加生成;来源行只作为横向对比明细挂到合计行。 * 选择具体餐段时隐藏内容相同的当日合计,避免页面出现两条重复统计。

* - * @param requestedPage 调用方请求的页码和每页自然日数量 - * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 - * @param hourTypeFiltered 是否已经按单一餐段筛选 + * @param requestedPage 调用方请求的页码和每页自然日数量 + * @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行 + * @param hourTypeFiltered 是否已经按单一餐段筛选 * @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行 * @return 仅包含当前页自然日的合计行,每行附带来源对比数据 */ private Page assembleProductStatisticsPage(Page requestedPage, - List rows, - boolean hourTypeFiltered, - boolean includeSourceRows) { + List rows, + boolean hourTypeFiltered, + boolean includeSourceRows) { Page result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize()); if (ObjectUtil.isEmpty(rows)) { result.setTotal(0); @@ -356,8 +482,8 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { * “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过 * 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。

* - * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 - * @param user 当前登录用户,保留给既有商户范围契约 + * @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细 + * @param user 当前登录用户,保留给既有商户范围契约 * @param dataScope 订单所属部门数据范围;为空时不附加范围条件 * @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细 */ @@ -365,12 +491,10 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { public List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); // 查询所有的自营食堂 - MchManageDto mchListDto = new MchManageDto(); - mchListDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); - // mchListDto.setScopeUserDeptId(user.getDeptId()); - List mchList = remoteMchService.scopeList(mchListDto).getData(); - if (ObjectUtil.isEmpty(mchList)) return null; - dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList()); + List mchIdList = remoteMchService.getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData(); + if (ObjectUtil.isEmpty(mchIdList)) return null; + dto.setMchIds(mchIdList); + applyUserIdentityQuery(dto); boolean sourceStatistics = isSourceStatistics(dto); if (sourceStatistics) normalizeSaleSources(dto); List mapperRows = sourceStatistics @@ -381,46 +505,31 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { ? assembleDepartmentMainRows(mapperRows, includeSourceRows) : mapperRows; fillSaleSourceFields(list); - if (ObjectUtil.isNotEmpty(list) && ObjectUtil.isNotEmpty(dto.getTotalIsFindChildren()) && dto.getTotalIsFindChildren()) { - // 限制最大线程数为10 - int threadCount = Math.min(list.size(), 10); - ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null; - try { - List> futures = list.stream() - .map(v -> CompletableFuture.runAsync(() -> { - // 查询时间段 - HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class); - deptQuery.setMerchantId(v.getMchId()); - if (sourceStatistics) { - deptQuery.setCreateTimeStr(v.getCreateTime() == null ? null : v.getCreateTime().toLocalDate().toString()); - deptQuery.setOrderApptUseDateStr(null); - } - List deptMapperRows = sourceStatistics - ? hardCarMapper.listByHourTypeStats(deptQuery) - : hardCarMapper.listByHourType(deptQuery); - List deptList = sourceStatistics - ? assembleDepartmentChildRows(deptMapperRows, v.getMchId(), includeSourceRows) - : deptMapperRows; - - for (HardCanteenProductTotalVo j : deptList) { - if (ObjectUtil.isNotEmpty(v.getMchId())) j.setMchId(v.getMchId()); - fillSaleSourceFields(j); - } - - if (!sourceStatistics) { - v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size()); - } - v.setChildren(deptList); - if (sourceStatistics && includeSourceRows) { - for (HardCanteenProductTotalVo sourceRow : v.getSourceRows()) { - sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + if (ObjectUtil.isNotEmpty(list) && Boolean.TRUE.equals(dto.getTotalIsFindChildren())) { + if (sourceStatistics) { + populateDepartmentStatisticsChildren(dto, list, includeSourceRows); + } else { + // 旧接口仍保留原有并发查询方式,避免无统计日期的历史调用发生结构变化。 + int threadCount = Math.min(list.size(), 10); + ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null; + try { + List> futures = list.stream() + .map(v -> CompletableFuture.runAsync(() -> { + HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class); + deptQuery.setMerchantId(v.getMchId()); + List deptList = hardCarMapper.listByHourType(deptQuery); + for (HardCanteenProductTotalVo child : deptList) { + if (ObjectUtil.isNotEmpty(v.getMchId())) child.setMchId(v.getMchId()); } - } - }, executorService)) - .toList(); - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - } finally { - if (executorService != null) executorService.shutdown(); + v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId) + .distinct().toList().size()); + v.setChildren(deptList); + }, executorService)) + .toList(); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } finally { + if (executorService != null) executorService.shutdown(); + } } } @@ -432,15 +541,35 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { return list; } + /** + * 将部门统计携带的身份、人脸及扩展资料条件转换为订单用户主键范围。 + * + *

订单表不持久化学生、老师和人脸资料,必须先由 UPMS 在当前用户数据权限内筛选用户; + * 无匹配结果时支持器会返回不可命中的用户主键,避免 Mapper 将空集合误解为不限制用户。

+ * + * @param dto 部门统计查询条件;方法会原地写入 userIds 并清除已经消费的身份资料条件 + */ + private void applyUserIdentityQuery(HardOrderDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } + /** * 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。 * - * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 + * @param rows Mapper 返回的日期食堂合计行和日期食堂来源行 * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 * @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序 */ private List assembleDepartmentMainRows(List rows, - boolean includeSourceRows) { + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); List sourceRows = rows.stream() .filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource())) @@ -463,14 +592,14 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。 * - * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 - * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 + * @param rows Mapper 返回的部门餐段合计行和部门餐段来源行 + * @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围 * @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载 * @return 部门餐段合计行列表 */ private List assembleDepartmentChildRows(List rows, - Long merchantId, - boolean includeSourceRows) { + Long merchantId, + boolean includeSourceRows) { if (ObjectUtil.isEmpty(rows)) return List.of(); rows.forEach(row -> row.setMchId(merchantId)); List sourceRows = rows.stream() @@ -490,15 +619,46 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { .toList(); } + /** + * 一次批量查询全部日期和食堂的部门餐段行,再按主表维度装配展开数据。 + * + *

旧实现按每个“日期 + 食堂”主行执行一次 SQL,日期或食堂增多时会形成 N+1;新版 SQL 已返回 + * 食堂主键,因此可在相同筛选范围内一次聚合后按稳定键拆分,同时保持来源行和合计行的独立去重口径。

+ * + * @param dto 已补全可见食堂、来源和统计日期的查询条件 + * @param mainRows 日期食堂合计主行,每行可能挂载来源拆分 + * @param includeSourceRows 是否为合计行挂载来源拆分;单来源标签页为 false + */ + private void populateDepartmentStatisticsChildren(HardOrderDto dto, + List mainRows, + boolean includeSourceRows) { + List allChildRows = hardCarMapper.listByHourTypeStats(dto); + Map> childRowsByMainScope = ObjectUtil.isEmpty(allChildRows) + ? Map.of() + : allChildRows.stream().collect(Collectors.groupingBy(this::departmentMainScopeKey, + LinkedHashMap::new, Collectors.toList())); + for (HardCanteenProductTotalVo mainRow : mainRows) { + List deptList = assembleDepartmentChildRows( + childRowsByMainScope.getOrDefault(departmentMainScopeKey(mainRow), List.of()), + mainRow.getMchId(), includeSourceRows); + mainRow.setChildren(deptList); + if (includeSourceRows) { + for (HardCanteenProductTotalVo sourceRow : mainRow.getSourceRows()) { + sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource())); + } + } + } + } + /** * 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。 * - * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 + * @param totalRows 部门餐段合计行,每行可能挂载来源拆分 * @param saleSource 主表来源行对应的稳定来源编码 * @return 指定来源的部门餐段行;没有匹配数据时返回空列表 */ private List departmentSourceChildren(List totalRows, - String saleSource) { + String saleSource) { if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of(); return totalRows.stream() .flatMap(row -> row.getSourceRows().stream()) @@ -561,7 +721,7 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { /** * 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。 * - * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 + * @param parent 提供食堂、部门、日期和餐段上下文的父级统计行 * @param children 待补充上下文的合计行或来源拆分行 */ private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent, @@ -580,72 +740,530 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。 + * 导出日期餐段、商户部门和商品三级销售统计。 * - * @param dto 来源统计请求 - * @param dataScope 部门数据权限 - * @return Excel 导出行 + *

每个日期餐段先输出当前来源筛选的跨来源合计块,再输出各已选来源块;合计人数由数据库独立去重, + * 不能通过来源人数相加得到。两个统计页面调用同一实现,因此筛选、退款扣减、套餐展开和合计规则保持一致。

+ * + * @param dto 统计日期、来源、餐段、食堂和部门筛选条件 + * @param dataScope 数据权限上下文,继续透传给 Mapper 拦截器 + * @return 按日期餐段、来源口径、商户部门和商品顺序排列的 Excel 行 */ @Override - public List exportProduct(HardOrderDto dto, DataScope dataScope) { - if (!isSourceStatistics(dto)) { - throw new IllegalArgumentException("导出必须选择统计日期范围"); + public List exportSales(HardOrderDto dto, DataScope dataScope) { + HardOrderDto exportQuery = prepareSalesExportQuery(dto); + List rows = new ArrayList<>(); + for (LocalDate date : getExportDates(exportQuery)) { + HardOrderDto dateQuery = createExportDateQuery(exportQuery, date); + rows.addAll(buildSalesExportRows(dateQuery, date, dataScope)); } - normalizeSaleSources(dto); - List rows = hardCarMapper.listProductTotalStats(dto, dataScope).stream() - .filter(row -> ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_SOURCE.getCode())) - .toList(); - fillSaleSourceFields(rows); - Map hourTypeNames = getHourTypeNames(); - if (ObjectUtil.isEmpty(rows)) return List.of(); - return rows.stream().map(row -> { - HardCanteenProductTotalExcelVo excel = new HardCanteenProductTotalExcelVo(); - excel.setStatisticsDate(row.getCreateTime() == null ? null : row.getCreateTime().toLocalDate()); - excel.setHourTypeName(hourTypeNames.getOrDefault(row.getHourType(), row.getHourType())); - excel.setSaleSourceName(row.getSaleSourceName()); - excel.setPayUserCount(row.getPayUserCount()); - excel.setTimeCount(row.getTimeCount()); - excel.setPayCount(row.getPayCount()); - excel.setTotalPrice(centsToYuan(row.getTotalPrice())); - return excel; - }).toList(); + return rows; } /** - * 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。 + * 提交菜品统计异步分批导出任务。 * - * @param dto 来源统计请求 - * @param user 当前登录用户 - * @param dataScope 部门数据权限 - * @return Excel 导出行 + * @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供页面轮询的初始任务进度 */ @Override - public List exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) { + public AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto) { + return submitSalesExport(dto, PRODUCT_SALES_EXPORT_REDIS_KEY, PRODUCT_SALES_EXPORT_TASK_NAME, + "食堂菜品销售统计", PRODUCT_SALES_EXPORT_FILE_TYPE); + } + + /** + * 提交部门统计异步分批导出任务。 + * + * @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件 + * @return 可供页面轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto) { + return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, DEPARTMENT_SALES_EXPORT_TASK_NAME, + "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE); + } + + /** + * 冻结请求快照和当前用户可见食堂后提交统一销售统计导出任务。 + * + *

权限范围在请求线程完成收敛,后台线程只读取独立 DTO,避免调用方继续修改数组或列表导致导出条件漂移。

+ * + * @param dto 页面提交的销售统计筛选条件 + * @param redisKey 当前导出入口独立使用的 Redis 业务命名空间 + * @param taskName 进度弹窗展示的任务名称 + * @param fileNamePrefix 下载文件名称前缀 + * @param fileType 文件中心保存的业务类型编码 + * @return 已创建的初始任务进度 + */ + private AsyncTaskProgressVO submitSalesExport(HardOrderDto dto, String redisKey, String taskName, + String fileNamePrefix, String fileType) { if (!isSourceStatistics(dto)) { throw new IllegalArgumentException("导出必须选择统计日期范围"); } - dto.setTotalIsFindChildren(true); - List rows = listByDept(dto, user, dataScope); - if (ObjectUtil.isEmpty(rows)) return List.of(); - Map hourTypeNames = getHourTypeNames(); - List result = new ArrayList<>(); - for (HardCanteenProductTotalVo parent : rows) { - if (ObjectUtil.isEmpty(parent.getChildren())) continue; - for (HardCanteenProductTotalVo child : parent.getChildren()) { - List exportRows = ObjectUtil.isNotEmpty(child.getSourceRows()) - ? child.getSourceRows() : List.of(child); - for (HardCanteenProductTotalVo exportRow : exportRows) { - HardCanteenDeptTotalExcelVo excel = new HardCanteenDeptTotalExcelVo(); - excel.setStatisticsDate(parent.getCreateTime() == null ? null : parent.getCreateTime().toLocalDate()); - excel.setMerchantName(parent.getMchName()); - excel.setSaleSourceName(resolveDepartmentExportSourceName(exportRow, dto)); - excel.setUserDeptName(exportRow.getUserDeptName()); - excel.setHourTypeName(hourTypeNames.getOrDefault(exportRow.getHourType(), exportRow.getHourType())); - excel.setPayUserCount(exportRow.getPayUserCount()); - excel.setTimeCount(exportRow.getTimeCount()); - excel.setPayCount(exportRow.getPayCount()); - excel.setTotalPrice(centsToYuan(exportRow.getTotalPrice())); - result.add(excel); + HardOrderDto querySnapshot = prepareSalesExportQuery(copyExportQuery(dto)); + return asyncTaskExecutorService.submitWithResult(redisKey, taskName, SALES_EXPORT_INITIAL_TOTAL, + reporter -> exportSalesToFile(querySnapshot, fileNamePrefix, fileType, reporter)); + } + + /** + * 校验并复制导出查询,同时把当前用户可见食堂范围写入独立快照。 + * + * @param source 控制器接收的原始筛选条件 + * @return 已规范化来源并冻结食堂权限的独立查询快照;传入对象继续保留同步接口原有的条件补全行为 + */ + private HardOrderDto prepareSalesExportQuery(HardOrderDto source) { + if (!isSourceStatistics(source)) { + throw new IllegalArgumentException("导出必须选择统计日期范围"); + } + normalizeSaleSources(source); + applyExportMerchantScope(source); + return copyExportQuery(source); + } + + /** + * 在异步线程中按自然日查询并流式写入三级统计 Excel,完成后上传文件中心。 + * + *

每个自然日完成 SQL 聚合后立即写盘并释放当日行,日期跨度增大时 JVM 不会同时持有全部导出结果。 + * 进度总数由日期批次数、工作簿收尾和文件上传三部分组成。

+ * + * @param query 已冻结权限范围的销售统计查询 + * @param fileNamePrefix 下载文件名称前缀 + * @param fileType 文件中心业务类型编码 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportSalesToFile(HardOrderDto query, String fileNamePrefix, String fileType, + AsyncTaskProgressReporter reporter) { + List dates = getExportDates(query); + reporter.updateTotal(dates.size() + SALES_EXPORT_EXTRA_PROGRESS_STEPS, + "已拆分为 " + dates.size() + " 个日期批次,正在生成统计文件"); + String fileName = fileNamePrefix + "_" + + LocalDateTime.now().format(SALES_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + Path outputPath = null; + try { + outputPath = Files.createTempFile("canteen-sales-export-", ".xlsx"); + long exportedRows = writeSalesExportWorkbook(outputPath, query, dates, reporter); + reporter.advance("统计文件已完成写入,共 " + exportedRows + " 行,正在上传文件中心"); + String fileUrl = uploadSalesExportWorkbook(outputPath, fileName, fileType); + reporter.advance("统计文件已上传,共 " + exportedRows + " 行"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|异步导出生成或上传失败,fileName:{}", TAG, fileName, exception); + throw new IllegalStateException("生成食堂销售统计导出文件失败", exception); + } finally { + deleteTemporarySalesExportFile(outputPath); + } + } + + /** + * 按自然日批次查询三级统计行并写入同一个流式工作簿。 + * + * @param outputPath 上传前使用的临时 XLSX 文件 + * @param query 已冻结的完整筛选条件 + * @param dates 从开始日期到结束日期的有序自然日列表 + * @param reporter 当前任务进度报告器 + * @return 实际写入的业务行总数 + */ + private long writeSalesExportWorkbook(Path outputPath, HardOrderDto query, List dates, + AsyncTaskProgressReporter reporter) { + long exportedRows = 0L; + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class) + .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(SALES_EXPORT_SHEET_NAME).build(); + for (int index = 0; index < dates.size(); index++) { + LocalDate date = dates.get(index); + HardOrderDto dateQuery = createExportDateQuery(query, date); + List dateRows = buildSalesExportRows(dateQuery, date, null); + long nextExportedRows = exportedRows + dateRows.size(); + if (nextExportedRows > MAX_SALES_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_SALES_EXPORT_ROWS + " 行,请缩小统计日期或筛选范围后重试"); + } + if (!dateRows.isEmpty()) { + excelWriter.write(dateRows, writeSheet); + } + exportedRows = nextExportedRows; + reporter.advance("已处理 " + (index + 1) + " / " + dates.size() + " 个日期批次,已写入 " + + exportedRows + " 行"); + } + } + if (exportedRows == 0L) { + throw new IllegalStateException("当前筛选条件没有可导出的食堂销售统计数据"); + } + return exportedRows; + } + + /** + * 创建单个自然日的查询副本,并保留用户在首尾日期选择的具体时间边界。 + * + * @param query 已冻结的完整日期范围查询 + * @param date 当前待处理的自然日 + * @return 日期范围已收敛到当前自然日的独立查询 + */ + private HardOrderDto createExportDateQuery(HardOrderDto query, LocalDate date) { + HardOrderDto dateQuery = copyExportQuery(query); + LocalDateTime rangeStart = query.getStatisticsDateTimeList()[0]; + LocalDateTime rangeEnd = query.getStatisticsDateTimeList()[1]; + LocalDateTime dayStart = date.atStartOfDay(); + LocalDateTime dayEnd = date.atTime(23, 59, 59); + dateQuery.setStatisticsDateTimeList(new LocalDateTime[]{ + rangeStart.isAfter(dayStart) ? rangeStart : dayStart, + rangeEnd.isBefore(dayEnd) ? rangeEnd : dayEnd + }); + return dateQuery; + } + + /** + * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 + * + * @param outputPath 已完成流式写入的临时文件 + * @param fileName 用户下载时使用的 UTF-8 文件名 + * @param fileType 文件中心业务类型编码 + * @return 文件中心返回的下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadSalesExportWorkbook(Path outputPath, String fileName, String fileType) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeSalesExportFileName(fileName), + SALES_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); + R uploadResult = remoteFileService.innerUpload(exportFile, SALES_EXPORT_FILE_DIR, null, fileType); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的食堂销售统计导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回食堂销售统计导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务现有编码约定转换中文原始文件名。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeSalesExportFileName(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理异步导出的临时文件,清理失败只记录告警而不覆盖任务的原始结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporarySalesExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理销售统计导出临时文件失败,path:{}", TAG, outputPath, exception); + } + } + + /** + * 将当前用户可见食堂范围冻结到导出请求中。 + * + *

调用方传入的食堂也必须与可见范围取交集;无可见食堂时使用不可命中主键,避免空集合被 SQL + * 解释为“不限制食堂”。

+ * + * @param dto 待补充食堂权限范围的导出请求 + */ + private void applyExportMerchantScope(HardOrderDto dto) { + MchManageDto scopeQuery = new MchManageDto(); + scopeQuery.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List visibleMerchants = remoteMchService.scopeList(scopeQuery).getData(); + List visibleIds = ObjectUtil.isEmpty(visibleMerchants) ? List.of() + : visibleMerchants.stream().map(MchManageVo::getId).filter(Objects::nonNull).distinct().toList(); + if (visibleIds.isEmpty()) { + dto.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID)); + return; + } + if (dto.getMerchantId() != null && !visibleIds.contains(dto.getMerchantId())) { + dto.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID)); + return; + } + if (ObjectUtil.isEmpty(dto.getMchIds())) { + dto.setMchIds(visibleIds); + return; + } + List allowedIds = dto.getMchIds().stream().filter(visibleIds::contains).distinct().toList(); + dto.setMchIds(allowedIds.isEmpty() ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedIds); + } + + /** + * 查询一个自然日的三层销售数据并转换为 Excel 行。 + * + * @param query 已收敛到单个自然日的查询条件 + * @param fixedDate 当前批次业务日期 + * @param dataScope 数据权限上下文 + * @return 当前日期下合计和各来源的日期餐段、商户部门、商品明细及商品统计行 + */ + private List buildSalesExportRows(HardOrderDto query, LocalDate fixedDate, + DataScope dataScope) { + List exportRecords = hardCarMapper.listSalesExportRows(query, dataScope); + List summaries = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_SUMMARY); + if (ObjectUtil.isEmpty(summaries)) { + return List.of(); + } + List merchants = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_MERCHANT); + List products = filterSalesExportRecords( + exportRecords, SALES_EXPORT_RECORD_LEVEL_PRODUCT); + Map merchantNames = fetchExportMerchantNames(merchants); + Map departmentNames = fetchExportDepartmentNames(merchants); + Map hourNames = getHourTypeNames(); + Map menuMap = fetchExportDailyMenus(products, fixedDate); + Map> merchantGroups = merchants.stream() + .collect(Collectors.groupingBy(this::exportSummaryGroupKey, LinkedHashMap::new, Collectors.toList())); + Map> productGroups = products.stream() + .collect(Collectors.groupingBy(this::exportGroupKey, LinkedHashMap::new, Collectors.toList())); + List rows = new ArrayList<>(); + for (HardCanteenProductApptExportRecord summary : summaries) { + LocalDate statisticsDate = parseExportDate(summary, fixedDate); + if (statisticsDate == null) { + log.warn("{}:销售导出汇总缺少业务日期,hourType:{},跳过该记录", TAG, summary.getHourType()); + continue; + } + String summaryKey = exportSummaryGroupKey(summary); + List merchantRows = + merchantGroups.getOrDefault(summaryKey, List.of()); + Map> detailRowsByMerchant = new LinkedHashMap<>(); + Map detailCountsByMerchant = new HashMap<>(); + Map detailPricesByMerchant = new HashMap<>(); + long summaryProductCount = 0L; + long summaryTotalPrice = 0L; + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + String merchantKey = exportGroupKey(merchant); + List detailRows = productGroups + .getOrDefault(merchantKey, List.of()).stream() + .flatMap(product -> toExportProductRows(product, + menuMap.get(exportMenuKey(product, statisticsDate))).stream()) + .toList(); + List statisticRows = buildExportProductStatistics(detailRows); + long detailCount = detailRows.stream() + .mapToLong(detail -> valueOrZero(detail.getProductCount())).sum(); + long detailPrice = detailRows.stream() + .mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum(); + // 菜谱服务不可用时保留 SQL 聚合数量和金额,不能让导出因套餐信息补全失败而丢失交易。 + if (detailRows.isEmpty()) { + detailCount = valueOrZero(merchant.getPayCount()); + detailPrice = valueOrZero(merchant.getTotalPrice()); + } + List merchantProductRows = + new ArrayList<>(detailRows.size() + statisticRows.size()); + merchantProductRows.addAll(detailRows); + merchantProductRows.addAll(statisticRows); + detailRowsByMerchant.put(merchantKey, merchantProductRows); + detailCountsByMerchant.put(merchantKey, detailCount); + detailPricesByMerchant.put(merchantKey, detailPrice); + summaryProductCount += detailCount; + summaryTotalPrice += detailPrice; + } + if (merchantRows.isEmpty()) { + summaryProductCount = valueOrZero(summary.getPayCount()); + summaryTotalPrice = valueOrZero(summary.getTotalPrice()); + } + HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo(); + summaryRow.setSummaryDate(statisticsDate); + summaryRow.setSummaryHourType(resolveHourName(summary.getHourType(), hourNames)); + summaryRow.setSummarySaleSourceName(resolveExportSourceName(summary.getSaleSource())); + summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount())); + summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount())); + summaryRow.setSummaryPayCount(summaryProductCount); + summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice)); + summaryRow.setRowLevel("SUMMARY"); + rows.add(summaryRow); + for (HardCanteenProductApptExportRecord merchant : merchantRows) { + String merchantKey = exportGroupKey(merchant); + HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo(); + merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录")); + merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录")); + merchantRow.setMerchantDate(statisticsDate); + merchantRow.setMerchantHourType(resolveHourName(summary.getHourType(), hourNames)); + merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount())); + merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount())); + merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault( + merchantKey, valueOrZero(merchant.getPayCount()))); + merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault( + merchantKey, valueOrZero(merchant.getTotalPrice())))); + merchantRow.setRowLevel("MERCHANT"); + rows.add(merchantRow); + rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of())); + } + } + return rows; + } + + /** + * 从一次 SQL 返回的三级聚合记录中提取指定层级。 + * + * @param records 当前自然日的全部导出聚合记录;允许为空 + * @param recordLevel Mapper 返回的 SUMMARY、MERCHANT 或 PRODUCT 层级编码 + * @return 保持数据库稳定排序的指定层级记录 + */ + private List filterSalesExportRecords( + List records, String recordLevel) { + if (ObjectUtil.isEmpty(records)) { + return List.of(); + } + return records.stream().filter(record -> ObjectUtil.equals(record.getRecordLevel(), recordLevel)).toList(); + } + + /** + * 将商品聚合行按业务当天每日菜谱中的套餐组成展开。 + * + *

组成商品数量按套餐数量乘组成份数计算,金额按菜谱单价权重使用最大余数法分摊,保证组成行金额 + * 之和严格等于订单快照金额。找不到套餐菜谱时保留原商品行。

+ * + * @param product 商品聚合记录 + * @param menu 与商户、日期、餐段和商品匹配的每日菜谱 + * @return 原商品行或套餐组成商品行 + */ + private List toExportProductRows( + HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) { + List items = menu != null && ObjectUtil.isNotEmpty(menu.getComboItems()) + ? menu.getComboItems() : List.of(); + if (items.isEmpty()) { + return List.of(toExportProductRow(product.getProductName(), valueOrZero(product.getPayCount()), + valueOrZero(product.getTotalPrice()))); + } + long packageCount = valueOrZero(product.getPayCount()); + long totalCents = valueOrZero(product.getTotalPrice()); + long sign = totalCents < 0 ? -1L : 1L; + long absoluteTotalCents = Math.abs(totalCents); + long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice())) + * Math.max(1, valueOrZero(item.getQuantity()))).sum(); + long[] itemPrices = new long[items.size()]; + long[] itemWeights = new long[items.size()]; + long allocated = 0L; + List remainderIndexes = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + OnlineSpuComboItemVO item = items.get(index); + long itemWeight = Math.max(0, valueOrZero(item.getPrice())) + * Math.max(1, valueOrZero(item.getQuantity())); + itemWeights[index] = itemWeight; + if (baseAmount > 0) { + BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) + .multiply(BigDecimal.valueOf(itemWeight)) + .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); + itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue(); + allocated += itemPrices[index]; + remainderIndexes.add(index); + } + } + if (baseAmount > 0) { + long remainder = absoluteTotalCents - allocated; + remainderIndexes.sort(Comparator.comparing((Integer index) -> { + BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents) + .multiply(BigDecimal.valueOf(itemWeights[index])) + .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN); + return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index])); + }).reversed()); + for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) { + itemPrices[remainderIndexes.get(index)]++; + } + } else { + itemPrices[items.size() - 1] = absoluteTotalCents; + } + List rows = new ArrayList<>(); + for (int index = 0; index < items.size(); index++) { + OnlineSpuComboItemVO item = items.get(index); + long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity())); + rows.add(toExportProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), + itemCount, itemPrices[index] * sign)); + } + return rows; + } + + /** + * 创建一个商品明细导出行。 + * + * @param productName 商品展示名称 + * @param productCount 有效商品数量 + * @param totalPrice 商品总价,单位为分 + * @return 只填写商品明细列的导出行 + */ + private HardCanteenProductApptExcelVo toExportProductRow(String productName, long productCount, + long totalPrice) { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setProductName(StrUtil.blankToDefault(productName, "未命名商品")); + row.setProductCount(productCount); + row.setProductTotalPrice(centsToYuan(totalPrice)); + row.setRowLevel("PRODUCT"); + return row; + } + + /** + * 在当前商户部门商品明细右侧生成按名称合并的商品统计。 + * + * @param detailRows 已扣减有效退款并完成套餐展开的商品明细 + * @return 按规范化商品名称聚合的商品统计行 + */ + private List buildExportProductStatistics( + List detailRows) { + Map statistics = new LinkedHashMap<>(); + for (HardCanteenProductApptExcelVo detailRow : detailRows) { + String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim(); + String statisticKey = normalizeExportProductName(displayName); + HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> { + HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo(); + row.setStatisticProductName(displayName); + row.setStatisticProductCount(0L); + row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)); + row.setRowLevel("PRODUCT_STATISTIC"); + return row; + }); + statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount()) + + valueOrZero(detailRow.getProductCount())); + long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice()) + + yuanToCents(detailRow.getProductTotalPrice()); + statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice)); + } + return new ArrayList<>(statistics.values()); + } + + /** + * 查询当前日期涉及的每日菜谱并建立套餐展开缓存。 + * + * @param products 当前日期的商品聚合记录 + * @param statisticsDate 当前业务发生日期 + * @return 商户、日期、餐段和商品主键到每日菜谱的映射;远程查询失败的项不写入 + */ + private Map fetchExportDailyMenus( + List products, LocalDate statisticsDate) { + Map result = new HashMap<>(); + if (statisticsDate == null || ObjectUtil.isEmpty(products)) { + return result; + } + List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + for (Long merchantId : merchantIds) { + List hourTypes = products.stream() + .filter(item -> Objects.equals(item.getMerchantId(), merchantId)) + .map(HardCanteenProductApptExportRecord::getHourType) + .filter(StrUtil::isNotBlank).distinct().toList(); + for (String hourType : hourTypes) { + try { + R> response = remoteDailyMenuService.getCanteenList( + statisticsDate.toString(), Integer.valueOf(hourType), null, merchantId); + if (response != null && response.isOk() && response.getData() != null) { + response.getData().forEach(menu -> { + if (menu.getSpuId() != null) { + result.put(merchantId + "|" + statisticsDate + "|" + hourType + "|" + + menu.getSpuId(), menu); + } + }); + } + } catch (Exception exception) { + log.warn("{}:查询套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}", + TAG, merchantId, statisticsDate, hourType, exception); } } } @@ -653,20 +1271,212 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { } /** - * 解析部门导出的具体销售来源名称。 + * 批量补全三级导出中的食堂名称。 * - *

多来源导出使用来源拆分行自身名称;单来源查询为避免页面重复行不会挂载来源拆分, - * 此时使用已经校验过的请求来源补全名称。

- * - * @param row 待导出的部门餐段统计行 - * @param dto 已完成来源标准化的统计请求 - * @return 可追溯的具体来源中文名称;来源异常时返回空值 + * @param rows 商户部门聚合记录 + * @return 商户主键到名称的映射;远程查询失败时返回空映射 */ - private String resolveDepartmentExportSourceName(HardCanteenProductTotalVo row, HardOrderDto dto) { - if (row != null && ObjectUtil.isNotEmpty(row.getSaleSourceName())) return row.getSaleSourceName(); - if (dto == null || ObjectUtil.isEmpty(dto.getSaleSourceCodes()) || dto.getSaleSourceCodes().size() != 1) return null; - HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(dto.getSaleSourceCodes().get(0)); - return source == null ? null : source.getDescription(); + private Map fetchExportMerchantNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + try { + R> response = remoteMchService.getByIds(ids); + if (response == null || !response.isOk() || response.getData() == null) { + return Map.of(); + } + return response.getData().stream().filter(item -> item.getId() != null) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, + (left, right) -> left)); + } catch (Exception exception) { + log.warn("{}:查询导出食堂名称失败", TAG, exception); + return Map.of(); + } + } + + /** + * 批量补全三级导出中的用户部门名称。 + * + * @param rows 商户部门聚合记录 + * @return 部门主键到名称的映射;远程查询失败时返回空映射 + */ + private Map fetchExportDepartmentNames(List rows) { + List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId) + .filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + try { + R> response = remoteDeptService.getDeptByDeptIds(ids); + if (response == null || !response.isOk() || response.getData() == null) { + return Map.of(); + } + return response.getData().values().stream().filter(item -> item.getDeptId() != null) + .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, + (left, right) -> left)); + } catch (Exception exception) { + log.warn("{}:查询导出部门名称失败", TAG, exception); + return Map.of(); + } + } + + /** + * 复制三级导出查询,避免按日处理时改写控制器接收的时间范围。 + * + * @param source 已完成日期、来源和食堂权限校验的原请求 + * @return 可独立修改统计日期范围的请求副本 + */ + private HardOrderDto copyExportQuery(HardOrderDto source) { + HardOrderDto target = BeanUtil.toBean(source, HardOrderDto.class); + target.setStatisticsDateTimeList(source.getStatisticsDateTimeList() == null + ? null : source.getStatisticsDateTimeList().clone()); + target.setSaleSourceCodes(source.getSaleSourceCodes() == null + ? null : new ArrayList<>(source.getSaleSourceCodes())); + target.setMchIds(source.getMchIds() == null ? null : new ArrayList<>(source.getMchIds())); + target.setDeptIds(source.getDeptIds() == null ? null : new ArrayList<>(source.getDeptIds())); + target.setUserDeptIds(source.getUserDeptIds() == null + ? null : new ArrayList<>(source.getUserDeptIds())); + target.setMerchantDeptIds(source.getMerchantDeptIds() == null + ? null : new ArrayList<>(source.getMerchantDeptIds())); + target.setScopeDeptIds(source.getScopeDeptIds() == null + ? null : new ArrayList<>(source.getScopeDeptIds())); + return target; + } + + /** + * 将导出统计日期闭区间展开为自然日列表。 + * + * @param query 已通过日期范围校验的导出请求 + * @return 从开始日期到结束日期的有序闭区间 + */ + private List getExportDates(HardOrderDto query) { + LocalDate start = query.getStatisticsDateTimeList()[0].toLocalDate(); + LocalDate end = query.getStatisticsDateTimeList()[1].toLocalDate(); + List dates = new ArrayList<>(); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + dates.add(date); + } + return dates; + } + + /** + * 解析导出记录的业务日期。 + * + * @param row 日期餐段聚合记录 + * @param fixedDate 当前按日批次的已知日期 + * @return 可用业务日期;两者均为空或格式错误时返回空 + */ + private LocalDate parseExportDate(HardCanteenProductApptExportRecord row, LocalDate fixedDate) { + if (fixedDate != null) { + return fixedDate; + } + try { + return StrUtil.isBlank(row.getStatisticsDate()) ? null : LocalDate.parse(row.getStatisticsDate()); + } catch (Exception exception) { + return null; + } + } + + /** + * 构造日期餐段及来源口径分组键。 + * + * @param record 任一层级的销售聚合记录 + * @return 日期、餐段和来源组成的稳定键,空来源表示跨来源合计 + */ + private String exportSummaryGroupKey(HardCanteenProductApptExportRecord record) { + return record.getStatisticsDate() + "|" + record.getHourType() + "|" + + ObjectUtil.defaultIfNull(record.getSaleSource(), "TOTAL"); + } + + /** + * 构造商户部门及来源口径分组键。 + * + * @param record 商户部门或商品聚合记录 + * @return 日期、餐段、来源、商户和部门组成的稳定键 + */ + private String exportGroupKey(HardCanteenProductApptExportRecord record) { + return exportSummaryGroupKey(record) + "|" + record.getMerchantId() + "|" + record.getUserDeptId(); + } + + /** + * 构造每日菜谱套餐缓存键。 + * + * @param record 商品聚合记录 + * @param statisticsDate 业务发生日期 + * @return 商户、日期、餐段和商品主键组成的稳定键 + */ + private String exportMenuKey(HardCanteenProductApptExportRecord record, LocalDate statisticsDate) { + return record.getMerchantId() + "|" + statisticsDate + "|" + record.getHourType() + + "|" + record.getProductId(); + } + + /** + * 解析来源口径中文名称。 + * + * @param saleSource 销售来源协议编码;为空表示所选来源合计 + * @return 合计、线下购买、预约核销或未知来源文案 + */ + private String resolveExportSourceName(String saleSource) { + if (StrUtil.isBlank(saleSource)) { + return "合计"; + } + HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(saleSource); + return source == null ? "未知来源" : source.getDescription(); + } + + /** + * 解析餐段名称,字典缺失时保留协议编码。 + * + * @param hourType 餐段协议编码 + * @param hourNames 餐段字典映射 + * @return 餐段名称或原编码 + */ + private String resolveHourName(String hourType, Map hourNames) { + return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); + } + + /** + * 规范化商品名称中的换行和连续空白。 + * + * @param productName 商品展示名称 + * @return 可用于同名商品聚合的稳定名称 + */ + private String normalizeExportProductName(String productName) { + return productName.replaceAll("\\s+", " ").trim(); + } + + /** + * 将空数值按零处理。 + * + * @param value 可空数量或金额 + * @return 原数值的长整型表示,空值返回零 + */ + private long valueOrZero(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** + * 将分转换为保留两位小数的元。 + * + * @param cents 金额,单位为分 + * @return 金额,单位为元 + */ + private BigDecimal centsToYuan(Number cents) { + return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2) + .setScale(2, RoundingMode.HALF_UP); + } + + /** + * 将两位小数的元转换为整数分,供商品明细重新累计合计。 + * + * @param yuan 金额,单位为元 + * @return 金额,单位为分;空值返回零 + */ + private long yuanToCents(BigDecimal yuan) { + return yuan == null ? 0L + : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue(); } /** @@ -677,25 +1487,20 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService { private Map getHourTypeNames() { try { List items = remoteDictService.getDictByType("daily_menu").getData(); - if (ObjectUtil.isEmpty(items)) return Map.of(); - return items.stream().filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) && ObjectUtil.isNotEmpty(item.getLabel())) - .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + if (ObjectUtil.isEmpty(items)) { + return Map.of(); + } + return items.stream() + .filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) + && ObjectUtil.isNotEmpty(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, + (left, right) -> left)); } catch (Exception exception) { log.warn("{}:读取餐段字典失败,导出将保留餐段编码", TAG, exception); return Map.of(); } } - /** - * 将持久化使用的分转换为导出展示使用的元,空金额按零处理。 - * - * @param cents 金额,单位为分 - * @return 保留两位小数的元金额 - */ - private BigDecimal centsToYuan(Long cents) { - return BigDecimal.valueOf(ObjectUtil.defaultIfNull(cents, 0L)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); - } - /** * 判断是否启用来源统计查询。未传统计日期时保留旧 SQL,兼容历史页面和其他服务调用方。 * diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java new file mode 100644 index 00000000..93a4b221 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -0,0 +1,1000 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +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; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; +import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto; +import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExportExcelVo; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; +import com.bsd.pjcloud.hard.service.HardManagerService; +import com.bsd.pjcloud.hard.service.HardOrderExportService; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 硬件订单导出服务实现。 + * + *

导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单, + * 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入, + * 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HardOrderExportServiceImpl implements HardOrderExportService { + + /** + * 订单导出日志标签,用于定位分批查询、工作簿写入和文件上传异常。 + */ + private static final String LOG_TAG = "硬件订单导出"; + + /** + * 订单导出任务的 Redis Key 业务命名空间。 + */ + private static final String ORDER_EXPORT_REDIS_KEY = "hard:order:export"; + + /** + * 订单导出任务在进度弹窗中的展示名称。 + */ + private static final String ORDER_EXPORT_TASK_NAME = "订单记录导出"; + + /** + * 后台取得真实订单数前创建任务使用的占位总数。 + */ + private static final int ORDER_EXPORT_INITIAL_TOTAL = 1; + + /** + * 单次订单查询和 EasyExcel 流式写入使用的记录数。 + */ + private static final int ORDER_EXPORT_BATCH_SIZE = 200; + + /** + * 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。 + */ + private static final long USER_EXPORT_BATCH_SIZE = 500L; + + /** + * 文件上传及异步任务成功收尾占用的额外进度步数。 + */ + private static final int ORDER_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + + /** + * EasyExcel 二级表头占用的固定行数。 + */ + private static final int ORDER_EXPORT_HEADER_ROW_COUNT = 2; + + /** + * 全部订单明细之后写入的金额合计行数量。 + */ + private static final int ORDER_EXPORT_SUMMARY_ROW_COUNT = 1; + + /** + * 单个订单工作表最多允许写入的明细数量。 + */ + private static final int MAX_ORDER_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount( + ORDER_EXPORT_HEADER_ROW_COUNT, ORDER_EXPORT_SUMMARY_ROW_COUNT); + + /** + * 订单导出工作表名称。 + */ + private static final String ORDER_EXPORT_SHEET_NAME = "订单记录"; + + /** + * 文件中心保存订单导出文件的业务目录。 + */ + private static final String ORDER_EXPORT_FILE_DIR = "hard-export"; + + /** + * 文件中心区分订单导出文件的业务类型编码。 + */ + private static final String ORDER_EXPORT_FILE_TYPE = "HARD_ORDER_EXPORT"; + + /** + * XLSX 文件的标准 MIME 类型。 + */ + private static final String ORDER_EXPORT_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** + * 异步订单导出文件名使用的时间格式。 + */ + private static final DateTimeFormatter ORDER_EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** + * Excel 日期时间字段统一展示格式,避免 EasyExcel 依赖服务端默认格式。 + */ + private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。 + */ + private final HardOrderMapper hardOrderMapper; + + /** + * 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。 + */ + private final HardUserIdentitySupport userIdentitySupport; + + /** + * 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。 + */ + private final RemoteUserService remoteUserService; + + /** + * 设备服务,用于冻结当前用户允许查看的设备类型。 + */ + private final HardManagerService hardManagerService; + + /** + * 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。 + */ + private final RemoteMchService remoteMchService; + + /** + * 文件中心远程服务,用于持久化异步生成的订单工作簿。 + */ + private final RemoteFileService remoteFileService; + + /** + * 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。 + */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 提交订单异步分批导出任务。 + * + * @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单 + * @return 可供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitExport(HardOrderDto dto) { + HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto)); + return asyncTaskExecutorService.submitWithResult(ORDER_EXPORT_REDIS_KEY, ORDER_EXPORT_TASK_NAME, + ORDER_EXPORT_INITIAL_TOTAL, reporter -> exportToFile(querySnapshot, reporter)); + } + + /** + * 执行兼容旧页面的同步分批导出。 + * + *

旧接口仍保持原 URL 和响应方式,但内部不再一次性读取全部订单;完整临时文件生成后才写入响应, + * 避免部分失败文件被浏览器当作成功结果下载。

+ * + * @param response 当前 HTTP 下载响应 + * @param dto 订单页面当前筛选条件 + */ + @Override + public void export(HttpServletResponse response, HardOrderDto dto) { + HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto)); + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, querySnapshot, true); + long total = validateExportTotal(firstPage.getTotal()); + response.setContentType(ORDER_EXPORT_CONTENT_TYPE); + writeExportWorkbook(querySnapshot, firstPage, total, null, file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 在异步线程中生成订单工作簿并上传文件中心。 + * + * @param query 已冻结权限和筛选条件的订单查询 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportToFile(HardOrderDto query, AsyncTaskProgressReporter reporter) { + Page firstPage = fetchExportPage(1L, ORDER_EXPORT_BATCH_SIZE, query, true); + long total = validateExportTotal(firstPage.getTotal()); + int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, ORDER_EXPORT_BATCH_SIZE, + ORDER_EXPORT_EXTRA_PROGRESS_STEPS); + reporter.updateTotal(progressTotal, "已查询 " + total + " 条订单,将分批生成文件"); + + String fileName = "订单记录_" + LocalDateTime.now().format(ORDER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + try { + String fileUrl = writeExportWorkbook(query, firstPage, total, reporter, + file -> uploadExportFile(file, fileName)); + reporter.advance("订单导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|生成或上传文件失败,fileName:{}", LOG_TAG, fileName, exception); + throw new IllegalStateException("生成订单导出文件失败", exception); + } + } + + /** + * 按页查询订单并使用 EasyExcel 流式写入自定义二级表头工作簿。 + * + *

文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度, + * 避免大批量订单长期占用请求线程和堆内存。

+ * + * @param query 已冻结的订单查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 + * @param total 导出开始时按相同条件统计的订单总数 + * @param reporter 异步任务进度报告器;同步兼容导出时为空 + * @param fileConsumer 完整临时文件消费回调 + * @param 文件消费结果类型 + * @return 文件消费回调返回的结果 + */ + private R writeExportWorkbook(HardOrderDto query, Page firstPage, long total, + AsyncTaskProgressReporter reporter, + BatchExcelExportTemplate.FileConsumer fileConsumer) { + Path outputPath = null; + try { + outputPath = Files.createTempFile("hard-order-export-", ".xlsx"); + OrderExportSummary summary = new OrderExportSummary(); + long processedTotal = 0L; + long current = 1L; + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardOrderExportExcelVo.class).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(ORDER_EXPORT_SHEET_NAME).build(); + Page currentPage = firstPage; + while (processedTotal < total) { + List records = safeRecords(currentPage); + if (records.isEmpty()) { + break; + } + long remaining = total - processedTotal; + int recordCount = Math.toIntExact(Math.min(records.size(), remaining)); + List batch = records.subList(0, recordCount); + List excelRows = new ArrayList<>(batch.size()); + for (OrderExportRow item : batch) { + summary.add(item); + excelRows.add(toExcelRow(item, processedTotal + excelRows.size() + 1L)); + } + excelWriter.write(excelRows, writeSheet); + processedTotal += batch.size(); + if (reporter != null) { + reporter.advance("已写入 " + processedTotal + " / " + total + " 条订单"); + } + if (processedTotal >= total) { + break; + } + currentPage = fetchExportPage(++current, ORDER_EXPORT_BATCH_SIZE, query, false); + } + if (processedTotal != total) { + throw new IllegalStateException("订单数据在导出期间发生变化,预期 " + total + + " 条,实际处理 " + processedTotal + " 条"); + } + excelWriter.write(List.of(toSummaryExcelRow(summary)), writeSheet); + } + return fileConsumer.consume(outputPath); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalStateException("生成订单导出文件失败", exception); + } finally { + deleteTemporaryExportFile(outputPath); + } + } + + /** + * 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。 + * + * @param current 从一开始的页码 + * @param size 单批最大订单数 + * @param query 已冻结的订单筛选条件 + * @param searchCount 是否执行总数查询,仅第一页需要 + * @return 在当前线程同步装配完整用户资料和商户名称的导出分页 + */ + private Page fetchExportPage(long current, long size, HardOrderDto query, boolean searchCount) { + Page page = new Page<>(current, size, searchCount); + Page orderPage = hardOrderMapper.scopePageList(page, query, createOrderDataScope()); + Page exportPage = new Page<>(current, size, orderPage.getTotal(), searchCount); + exportPage.setRecords(buildExportRows(orderPage.getRecords())); + return exportPage; + } + + /** + * 在导出任务线程中同步装配一个订单分片,避免列表服务的公共线程池丢失 Feign 登录凭证。 + * + * @param orders 当前订单分片,最多为订单导出批大小 + * @return 保持订单原始顺序并携带完整用户资料、商户名称的导出行 + */ + private List buildExportRows(List orders) { + if (orders == null || orders.isEmpty()) { + return List.of(); + } + List userIds = orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList(); + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId).filter(Objects::nonNull).distinct().toList(); + Map userMap = fetchExportUserMap(userIds); + Map merchantNameMap = fetchMerchantNameMap(merchantIds); + return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()), + StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName()))) + .toList(); + } + + /** + * 按当前订单分片的用户主键查询完整用户导出资料。 + * + * @param userIds 当前订单分片去重后的 UPMS 用户主键 + * @return 以用户主键索引的完整用户资料;订单未关联用户时返回空映射 + */ + private Map fetchExportUserMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + R> response = remoteUserService.getScopeExportUserPage(1L, USER_EXPORT_BATCH_SIZE, userQuery); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出用户资料失败")); + } + List users = response.getData().getRecords(); + if (users == null || users.isEmpty()) { + return Map.of(); + } + return users.stream().filter(user -> user != null && user.getUserId() != null) + .collect(Collectors.toMap(UserVO::getUserId, user -> user, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 批量查询订单商户名称并校验钱包服务业务响应。 + * + * @param merchantIds 当前订单分片去重后的商户主键 + * @return 商户主键到名称的映射;订单未关联系统商户时返回空映射 + */ + private Map fetchMerchantNameMap(List merchantIds) { + if (merchantIds.isEmpty()) { + return Map.of(); + } + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk()) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询订单导出商户资料失败")); + } + if (response.getData() == null || response.getData().isEmpty()) { + return Map.of(); + } + return response.getData().stream().filter(merchant -> merchant != null && merchant.getId() != null + && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 创建订单页面使用的数据权限配置,以商户部门字段限制当前用户可见订单。 + * + * @return 独立的数据权限参数,供单次分页查询使用 + */ + private DataScope createOrderDataScope() { + DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL); + dataScope.setScopeDeptName("merchant_dept_id"); + return dataScope; + } + + /** + * 校验导出总数和单工作表容量。 + * + * @param total 第一页分页查询返回的订单总数 + * @return 已通过校验的订单总数 + */ + private long validateExportTotal(long total) { + if (total <= 0L) { + throw new IllegalStateException("没有符合当前条件的订单记录"); + } + if (total > MAX_ORDER_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_ORDER_EXPORT_ROWS + + " 条,请缩小查询范围后分批导出"); + } + return total; + } + + /** + * 获取分页记录并把空记录统一为不可变空列表。 + * + * @param page 订单查询分页结果 + * @return 当前页导出行;分页或记录为空时返回空列表 + */ + private List safeRecords(Page page) { + return page == null || page.getRecords() == null ? List.of() : page.getRecords(); + } + + /** + * 将一条组合订单转换为二级表头导出对象。 + * + * @param item 当前订单及其完整用户、商户资料 + * @param lineNumber 当前订单在全部导出结果中的一基序号 + * @return 已完成枚举翻译、金额和时间格式化的精简导出行 + */ + private HardOrderExportExcelVo toExcelRow(OrderExportRow item, long lineNumber) { + HardOrderVo order = item.order; + UserVO user = item.user; + HardOrderExportExcelVo row = new HardOrderExportExcelVo(); + row.setLineNumber(Long.toString(lineNumber)); + row.setOrderType(resolveOrderType(order.getOrderType())); + row.setOrderStatus(resolveOrderStatus(order.getOrderStatus())); + row.setSaleSourceName(order.getSaleSourceName()); + row.setCreateTime(formatDateTime(order.getCreateTime())); + + row.setUserName(resolveUserName(user, order)); + row.setPhone(user == null ? order.getUserMobile() + : StrUtil.blankToDefault(user.getPhone(), order.getUserMobile())); + row.setDeptName(user == null ? order.getUserDeptName() + : StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName())); + row.setPostNames(user == null ? null : joinPostNames(user.getPostList())); + row.setIdentityTypeNames(user == null ? null : joinIdentityNames(user.getIdentityTypes())); + row.setStudentNo(user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getStudentNo()); + row.setSchoolName(user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getSchoolName()); + row.setClassName(user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getClassName()); + row.setGuardianPhones(user == null || user.getStudentInfo() == null + ? null : user.getStudentInfo().getGuardianPhones()); + row.setTeacherNo(user == null || user.getTeacherInfo() == null + ? null : user.getTeacherInfo().getTeacherNo()); + + row.setMerchantName(item.merchantName); + row.setHardName(order.getHardName()); + row.setHardType(resolveHardType(order.getHardType())); + + row.setProductDesc(order.getProductDesc()); + row.setHourType(resolveHourType(order.getHourType())); + row.setApptUseDate(order.getApptUseDate()); + + row.setPayType(resolvePayType(order.getPayType())); + row.setTotalPrice(formatPrice(order.getTotalPrice())); + row.setExtraPrice(formatPrice(order.getExtraPrice())); + row.setDeliveryPrice(formatPrice(order.getDeliveryPrice())); + row.setPayPrice(formatPrice(order.getPayPrice())); + row.setOrderActualPrice(formatOrderActualPrice(order.getPayPrice(), order.getRefundPrice())); + row.setPayTime(formatDateTime(order.getPayTime())); + + row.setVerificationTime(formatDateTime(order.getVerificationTime())); + + row.setRefundStatus(resolveRefundStatus(order.getRefundStatus())); + row.setRefundPrice(formatPrice(order.getRefundPrice())); + row.setRefundTime(formatDateTime(order.getRefundTime())); + return row; + } + + /** + * 生成与明细相同结构的金额合计行,确保 EasyExcel 保持二级表头列顺序。 + * + * @param summary 全部分片累计的订单金额 + * @return 首列显示“合计”且金额列写入元金额的导出行 + */ + private HardOrderExportExcelVo toSummaryExcelRow(OrderExportSummary summary) { + HardOrderExportExcelVo row = new HardOrderExportExcelVo(); + row.setLineNumber("合计"); + row.setTotalPrice(summary.formatPrice(summary.totalPriceSum)); + row.setExtraPrice(summary.formatPrice(summary.extraPriceSum)); + row.setDeliveryPrice(summary.formatPrice(summary.deliveryPriceSum)); + row.setPayPrice(summary.formatPrice(summary.payPriceSum)); + row.setOrderActualPrice(summary.formatPrice(summary.payPriceSum.subtract(summary.refundPriceSum))); + row.setRefundPrice(summary.formatPrice(summary.refundPriceSum)); + return row; + } + + /** + * 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。 + * + * @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空 + * @param order 当前订单,用于用户资料缺失时兼容历史展示字段 + * @return 用户导出姓名;所有来源均为空时返回空文本 + */ + private String resolveUserName(UserVO user, HardOrderVo order) { + if (user == null) { + return StrUtil.blankToDefault(order.getUsername(), ""); + } + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.blankToDefault(user.getUsername(), StrUtil.blankToDefault(order.getUsername(), "")); + } + + /** + * 拼接用户岗位名称,多个岗位使用中文逗号分隔。 + * + * @param posts UPMS 完整用户资料中的岗位列表 + * @return 去空后的岗位名称;没有岗位时返回空文本 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将用户身份协议编码转换并拼接为中文名称,未知历史编码保留原值。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;没有身份时返回空文本 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将订单类型协议编码转换为中文描述,未知历史编码原样保留。 + * + * @param orderType 数据库和跨端接口使用的订单类型编码 + * @return 订单类型中文描述、原始未知编码或空值 + */ + private String resolveOrderType(String orderType) { + if (StrUtil.isBlank(orderType)) { + return orderType; + } + HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType); + return value == null ? orderType : value.getDescription(); + } + + /** + * 将订单状态协议编码安全转换为中文描述。 + * + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。

+ * + * @param orderStatus 数据库和跨端接口使用的订单状态编码 + * @return 订单状态中文描述、原始未知编码或空值 + */ + private String resolveOrderStatus(String orderStatus) { + if (StrUtil.isBlank(orderStatus)) { + return orderStatus; + } + for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) { + if (Objects.equals(value.getType(), orderStatus)) { + return value.getDescription(); + } + } + return orderStatus; + } + + /** + * 将设备类型协议编码安全转换为中文描述。 + * + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。

+ * + * @param hardType 硬件设备类型编码 + * @return 设备类型中文描述、原始未知编码或空值 + */ + private String resolveHardType(String hardType) { + if (StrUtil.isBlank(hardType)) { + return hardType; + } + for (HardTypeEnum value : HardTypeEnum.values()) { + if (Objects.equals(value.getType(), hardType)) { + return value.getDescription(); + } + } + return hardType; + } + + /** + * 将支付方式协议编码转换为现有枚举中文描述,未知编码原样保留以兼容历史数据。 + * + * @param payType 订单支付方式协议编码 + * @return 支付方式中文描述或原始编码 + */ + private String resolvePayType(String payType) { + if (StrUtil.isBlank(payType)) { + return payType; + } + HardPayTypeEnum value = HardPayTypeEnum.valueOfByType(payType); + return value == null ? payType : value.getDescription(); + } + + /** + * 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。 + * + * @param hourType 用餐时间段协议编码 + * @return 餐段中文描述、原始未知编码或空值 + */ + private String resolveHourType(String hourType) { + if (StrUtil.isBlank(hourType)) { + return hourType; + } + HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType); + return value == null ? hourType : value.getDescription(); + } + + /** + * 将订单退款状态编码转换为中文名称,未知历史编码原样保留。 + * + * @param refundStatus hard_order.refund_status 中保存的退款状态编码 + * @return 退款状态中文名称、原始未知编码或空值 + */ + private String resolveRefundStatus(String refundStatus) { + if (StrUtil.isBlank(refundStatus)) { + return refundStatus; + } + for (HardRefundStatusEnum value : HardRefundStatusEnum.values()) { + if (Objects.equals(value.getStatus(), refundStatus)) { + return value.getName(); + } + } + return refundStatus; + } + + /** + * 将订单金额从分转换为保留两位小数的元金额。 + * + * @param amount 订单金额,单位为分;空值表示模板单元格留空 + * @return 单位为元的金额;输入为空时返回空 + */ + private BigDecimal formatPrice(Number amount) { + return amount == null ? null : BigDecimal.valueOf(amount.longValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 计算订单实际金额并转换为元,退款为空时按零分处理,允许结果为负数以保留真实差额。 + * + * @param payPrice 订单实际支付金额,单位为分;允许为空 + * @param refundPrice 订单已退款金额,单位为分;允许为空 + * @return 订单实际金额,单位为元并保留两位小数 + */ + private BigDecimal formatOrderActualPrice(Number payPrice, Number refundPrice) { + BigDecimal paid = payPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(payPrice.longValue()); + BigDecimal refunded = refundPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(refundPrice.longValue()); + return paid.subtract(refunded).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 将订单日期时间转换为 Excel 统一使用的标准文本。 + * + * @param dateTime 订单业务日期时间,允许为空 + * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空 + */ + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.format(EXCEL_DATE_TIME_FORMATTER); + } + + /** + * 上传完整订单工作簿并校验文件中心业务响应。 + * + * @param file 已关闭并可读取的完整 XLSX 临时文件 + * @param fileName 用户下载时使用的文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadExportFile(Path file, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null, + ORDER_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的订单导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回订单导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件服务现有约定转换中文原始文件名编码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理订单导出临时文件;清理失败只记录告警,不覆盖原始导出或上传结果。 + * + * @param outputPath 待删除的临时 XLSX 路径,允许为空 + */ + private void deleteTemporaryExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception); + } + } + + /** + * 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。 + * + * @param query 已复制、由当前任务独占的订单查询 + * @return 已补充设备、身份用户和商户权限范围的同一查询对象 + */ + private HardOrderDto prepareExportQuery(HardOrderDto query) { + CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(SecurityUtils.getUser()); + query.setMchTypeCodes(hardTypeInfo == null || hardTypeInfo.getHardTypeList() == null + ? List.of() : new ArrayList<>(hardTypeInfo.getHardTypeList())); + + applyIdentityQuery(query); + + List mchIds = query.getMchIds(); + if (mchIds != null && mchIds.size() == 1) { + Long parentMchId = mchIds.get(0); + R> response = remoteMchService.getByParentId(parentMchId); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询导出子商户范围失败"); + } + List expandedMchIds = new ArrayList<>(); + expandedMchIds.add(parentMchId); + if (response.getData() != null) { + response.getData().stream().map(MchManageVo::getId).filter(java.util.Objects::nonNull) + .forEach(expandedMchIds::add); + } + query.setMchIds(expandedMchIds.stream().distinct().toList()); + } + return query; + } + + /** + * 将身份筛选一次性转换为用户主键范围,后续所有订单分片复用同一条件。 + * + * @param query 当前导出任务独占的订单筛选条件,方法会清除已转换的身份字段 + */ + private void applyIdentityQuery(HardOrderDto query) { + if (!userIdentitySupport.hasIdentityQuery(query)) { + return; + } + query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds())); + query.setUserId(null); + query.setIdentityTypes(null); + query.setStudentInfo(null); + query.setTeacherInfo(null); + query.setHasFace(null); + } + + /** + * 深复制订单导出筛选条件,避免请求对象中的集合或数组在后台任务开始前发生变化。 + * + * @param source Controller 接收的订单筛选条件,允许为空 + * @return 后台导出任务独占的筛选条件副本 + */ + private HardOrderDto copyQuery(HardOrderDto source) { + HardOrderDto target = new HardOrderDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setDeptIds(copyList(source.getDeptIds())); + target.setUserIds(copyList(source.getUserIds())); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); + target.setOrderIds(copyList(source.getOrderIds())); + target.setHardIds(copyList(source.getHardIds())); + target.setMchIds(copyList(source.getMchIds())); + target.setMchTypeCodes(copyList(source.getMchTypeCodes())); + target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes())); + target.setOrderStatusCodes(copyList(source.getOrderStatusCodes())); + target.setOrderTypeCodes(copyList(source.getOrderTypeCodes())); + target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes())); + target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes())); + target.setHardTypeCodes(copyList(source.getHardTypeCodes())); + target.setHourTypeCodes(copyList(source.getHourTypeCodes())); + target.setRefundStatusCodes(copyList(source.getRefundStatusCodes())); + target.setSaleSourceCodes(copyList(source.getSaleSourceCodes())); + target.setSaleStatusCodes(copyList(source.getSaleStatusCodes())); + target.setScopeDeptIds(copyList(source.getScopeDeptIds())); + target.setAppWardUserIds(copyList(source.getAppWardUserIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(cloneArray(source.getCreateTimeList())); + target.setUpdateTimeList(cloneArray(source.getUpdateTimeList())); + target.setVerificationTimeList(cloneArray(source.getVerificationTimeList())); + target.setPayTimeList(cloneArray(source.getPayTimeList())); + target.setStatisticsDateTimeList(cloneArray(source.getStatisticsDateTimeList())); + target.setRefundTimeList(cloneArray(source.getRefundTimeList())); + target.setApptUseDateTimeList(cloneArray(source.getApptUseDateTimeList())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制可变列表,保留空值语义并隔离调用方后续修改。 + * + * @param source 原始列表,允许为空 + * @param 列表元素类型 + * @return 独立列表副本;输入为空时返回空 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 复制日期时间范围数组,保留空值语义。 + * + * @param source 原始日期时间范围,允许为空 + * @return 独立数组副本;输入为空时返回空 + */ + private LocalDateTime[] cloneArray(LocalDateTime[] source) { + return source == null ? null : source.clone(); + } + + /** + * 复制学生身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 学生身份筛选条件,允许为空 + * @return 独立学生筛选副本;输入为空时返回空 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 复制老师身份扩展筛选条件,避免异步任务共享请求对象。 + * + * @param source 老师身份筛选条件,允许为空 + * @return 独立老师筛选副本;输入为空时返回空 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + BeanUtils.copyProperties(source, target); + return target; + } + + /** + * 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。 + */ + private static final class OrderExportRow { + + /** + * 当前数据权限和筛选条件命中的订单记录。 + */ + private final HardOrderVo order; + + /** + * UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 + */ + private final UserVO user; + + /** + * 钱包服务返回的商户名称;历史商户不存在时为空。 + */ + private final String merchantName; + + /** + * 创建订单导出行。 + * + * @param order 当前订单记录 + * @param user 与订单用户主键匹配的完整用户资料 + * @param merchantName 与订单商户主键匹配的商户名称 + */ + private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) { + this.order = order; + this.user = user; + this.merchantName = merchantName; + } + } + + /** + * 订单导出跨分页金额汇总器,所有金额均按数据库分单位累计。 + */ + private static final class OrderExportSummary { + + /** + * 商品总价累计值,单位为分。 + */ + private BigDecimal totalPriceSum = BigDecimal.ZERO; + + /** + * 额外支付累计值,单位为分。 + */ + private BigDecimal extraPriceSum = BigDecimal.ZERO; + + /** + * 配送费用累计值,单位为分。 + */ + private BigDecimal deliveryPriceSum = BigDecimal.ZERO; + + /** + * 实际支付累计值,单位为分。 + */ + private BigDecimal payPriceSum = BigDecimal.ZERO; + + /** + * 退款金额累计值,单位为分。 + */ + private BigDecimal refundPriceSum = BigDecimal.ZERO; + + /** + * 累计当前订单的商品、额外、配送、实付和退款金额,历史空值按零分处理。 + * + * @param item 当前订单及其导出关联资料 + */ + private void add(OrderExportRow item) { + totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice()); + extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice()); + deliveryPriceSum = addAmount(deliveryPriceSum, item.order.getDeliveryPrice()); + payPriceSum = addAmount(payPriceSum, item.order.getPayPrice()); + refundPriceSum = addAmount(refundPriceSum, item.order.getRefundPrice()); + } + + /** + * 将非空分金额加入当前累计值。 + * + * @param current 当前累计金额,单位为分 + * @param amount 待累计金额,单位为分;允许为空 + * @return 累加后的分金额 + */ + private BigDecimal addAmount(BigDecimal current, Number amount) { + return amount == null ? current : current.add(BigDecimal.valueOf(amount.longValue())); + } + + /** + * 将累计的分金额转换为两位小数元金额。 + * + * @param amount 累计金额,单位为分 + * @return 单位为元的两位小数金额 + */ + private BigDecimal formatPrice(BigDecimal amount) { + return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index a70c072a..6a5a78da 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -13,6 +13,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.entity.SysUser; @@ -91,6 +92,7 @@ import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper; import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService; import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService; import com.bsd.pjcloud.supermarket.api.feign.RemoteSupermarketSpuService; @@ -217,6 +219,8 @@ public class HardOrderServiceImpl extends ServiceImpl pagejoin(Page page, HardOrderDto dto) { + applyIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); disposeList(list.getRecords(), dto); return list; @@ -251,6 +256,7 @@ public class HardOrderServiceImpl extends ServiceImpl scopeList(HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); List list = baseMapper.scopeList(dto, dataScope); disposeList(list, dto); return list; @@ -305,6 +311,7 @@ public class HardOrderServiceImpl extends ServiceImpl scopePageList(Page page, HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); if (dto.getAppViewerUserId() != null) { Page list = appVisibilityPageList(page, dto, dataScope); disposeList(list.getRecords(), dto); @@ -351,6 +358,7 @@ public class HardOrderServiceImpl extends ServiceImpl noRecogScopePageJoin(Page page, HardOrderDto dto, DataScope dataScope) { + applyIdentityQuery(dto); dto.setOrderPayTypeCodes(List.of(HardPayTypeEnum.HARD_DOOR_FREE_PAY.getType())); dto.setOrderStatus(HardOrderStatusEnum.CREATE.getType()); Page list = baseMapper.scopePageList(page, dto, dataScope); @@ -371,10 +379,7 @@ public class HardOrderServiceImpl extends ServiceImpl findUserFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); if (ObjectUtil.isNotEmpty(detail.getUserId())) { - List userVoList = userService.getUserDeptByIds(List.of(detail.getUserId())).getData(); - if (ObjectUtil.isNotEmpty(userVoList) && ObjectUtil.isNotEmpty(userVoList.get(0))) { - DataUtil.convertUserDept(detail, userVoList.get(0)); - } + userIdentitySupport.fillUserInfo(List.of(detail), true); } }); // 商户 @@ -1352,7 +1357,7 @@ public class HardOrderServiceImpl extends ServiceImpl 3) { + if (reserveCount > 200) { throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); } WalletManageEntity wallet = remoteWalletService.getWallet(dto.getUserId(), dto.getWalletType()).getData(); @@ -1460,7 +1465,7 @@ public class HardOrderServiceImpl extends ServiceImpl= 20) { + if (reserveCount >= 200) { throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,请先核销"); } } @@ -1552,8 +1557,8 @@ public class HardOrderServiceImpl extends ServiceImpl 20L) { - throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,本次订餐后将超过20笔上限"); + if (Math.addExact(reserveCount, (long) contexts.size()) > 200L) { + throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,本次订餐后将超过200笔上限"); } } @@ -1905,16 +1910,21 @@ public class HardOrderServiceImpl extends ServiceImpl= 1 && config.getExpiredReserveRefundDelayHours() <= 168 - && reservation.usageEndDateTime() != null + && order.getExpiredRefundUsageEndTime() != null && config.getId() != null) { order.setExpiredRefundConfigId(config.getId()); - order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime()); - order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours())); + order.setExpiredRefundDeadline(order.getExpiredRefundUsageEndTime().plusHours(config.getExpiredReserveRefundDelayHours())); order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); } @@ -2137,7 +2146,7 @@ public class HardOrderServiceImpl extends ServiceImpl dinerUserIds, MchManageVo merchant) { boolean containsProxyDiner = dinerUserIds.stream().anyMatch(id -> !Objects.equals(id, payerUserId)); - if (!containsProxyDiner) { + MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf(); + if (containsProxyDiner && (conf == null || ObjectUtil.isEmpty(conf.getUserRelationTypeIdList()) + || !conf.getUserRelationTypeIdList().contains(UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode()))) { + throw new RuntimeException("当前商户未开启监护关系代订餐"); + } + if (conf == null || ObjectUtil.isEmpty(conf.getUserIdentityTypeList())) { return; } - MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf(); - if (conf == null || ObjectUtil.isEmpty(conf.getUserRelationTypeIdList()) || !conf.getUserRelationTypeIdList().contains(UserRelationTypeEnum.GUARDIAN_WARD.getRelationCode())) { - throw new RuntimeException("当前商户未开启监护关系代订餐"); + List allowedIdentityTypes = conf.getUserIdentityTypeList().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(identityType -> UserIdentityTypeEnum.valueOfByType(identityType) != null) + .distinct() + .toList(); + if (allowedIdentityTypes.isEmpty()) { + return; + } + R> userResponse = userService.getUserDeptByIds(dinerUserIds); + if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null) { + throw new RuntimeException(userResponse == null + ? "查询用餐人身份无响应" + : StrUtil.blankToDefault(userResponse.getMsg(), "查询用餐人身份失败")); + } + Map userMap = userResponse.getData().stream() + .filter(Objects::nonNull) + .filter(user -> user.getUserId() != null) + .collect(Collectors.toMap(UserVO::getUserId, Function.identity(), (first, second) -> first)); + boolean containsIneligibleDiner = dinerUserIds.stream().anyMatch(dinerUserId -> { + UserVO dinerUser = userMap.get(dinerUserId); + return dinerUser == null || ObjectUtil.isEmpty(dinerUser.getIdentityTypes()) + || dinerUser.getIdentityTypes().stream().noneMatch(allowedIdentityTypes::contains); + }); + if (containsIneligibleDiner) { + throw new RuntimeException("存在不符合当前商户可订餐身份的用餐人"); } } @@ -2464,6 +2505,8 @@ public class HardOrderServiceImpl extends ServiceImpl 3) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); + if (reserveCount > 200) throw new RuntimeException("您有" + reserveCount + "笔未核销的预约订单,请先核销"); // 钱包 WalletManageEntity wallet = remoteWalletService.getWallet(dto.getUserId(), dto.getWalletType()).getData(); if (ObjectUtil.isEmpty(wallet)) throw new RuntimeException("用户钱包不存在"); @@ -4160,10 +4203,12 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, - YesNoEnum isReduce, Long afterSaleId, boolean validateOnly) { + YesNoEnum isReduce, Long afterSaleId, String inventoryBizId, + boolean validateOnly) { if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { throw new RuntimeException("食堂菜品退款库存缺少售后单 ID"); } @@ -4213,11 +4258,11 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, YesNoEnum isReduce, Long afterSaleId) { + getSelf().fastenerInventory(order, refundCarList, isReduce, afterSaleId, null); + } + + /** + * 按订单商品来源执行库存变更,并允许退款重审流程为食堂基础 SPU 指定本轮独立幂等业务号。 + * 普通库存协议不消费该业务号,继续保持原远程接口和事务顺序。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + * @param inventoryBizId 本轮食堂基础 SPU 库存幂等业务号;为空时沿用历史订单/售后业务号 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId, String inventoryBizId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; @@ -4319,7 +4381,7 @@ public class HardOrderServiceImpl extends ServiceImpl deptFuture = CompletableFuture.runAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); - List userIds = list.stream().map(HardOrderVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - List userList = userService.getUserDeptByIds(userIds).getData(); - if (ObjectUtil.isNotEmpty(userList)) { - Map map = CollectionUtils.convertMap(userList, UserVO::getUserId); - for (HardOrderVo item : list) { - UserVO userVO = map.get(item.getUserId()); - if (ObjectUtil.isEmpty(userVO)) continue; - item.setUsername(userVO.getName()); - item.setUserMobile(userVO.getPhone()); - item.setUserAvatar(userVO.getAvatar()); - } - } - } + userIdentitySupport.fillUserInfo(list, true); }); // 当前部门 CompletableFuture currentDeptFuture = CompletableFuture.runAsync(() -> { @@ -4935,4 +4984,21 @@ public class HardOrderServiceImpl extends ServiceImpl listCs(HardOrderDto dto) { @@ -200,85 +208,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { if (ObjectUtil.isEmpty(dto.getMchTypeCodes())) { CurrentUserHardTypeInfoDto hardTypeInfo = hardManagerService.getCurrentUserHardTypeInfo(user); dto.setMchTypeCodes(hardTypeInfo.getHardTypeList()); - } else { - dto.setMchTypeCodes(dto.getMchTypeCodes()); } - // 查询总流水 + + // 流水与退款的业务发生时间不同,必须分别查询后再按商户合并,避免直接关联造成一笔订单被多笔售后重复放大。 HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class); totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); DataScope totalDataScope = DataScope.of(); totalDataScope.setScopeDeptName("merchant_dept_id"); List totalList = hardOrderMapper.listCs(totalDto, totalDataScope); - // 扣款总流水 + HardOrderDto refundDto = BeanUtil.toBean(dto, HardOrderDto.class); refundDto.setPayTimeList(null); refundDto.setRefundTimeList(dto.getPayTimeList()); - refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes()); - refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + // refundStatusCodes 属于订单退款状态,售后完成状态必须使用 saleStatusCodes,二者混用会把有效退款全部过滤掉。 + refundDto.setRefundStatusCodes(null); + refundDto.setSaleStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + // 部分退款后订单仍是已支付状态,退款统计不能只查询整单退款状态的订单。 + refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); DataScope refundDataScope = DataScope.of(); refundDataScope.setScopeDeptName("merchant_dept_id"); - List refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope); - // 有退款没有流水的情况 - if (ObjectUtil.isAllNotEmpty(totalList, refundList)) { - List totalIds = totalList.stream().map(HardOrderCsVo::getMerchantId).toList(); - for (HardOrderCsVo refund : refundList) { - if (!totalIds.contains(refund.getMerchantId())) { - HardOrderCsVo entity = BeanUtils.toBean(refund, HardOrderCsVo.class); - entity.setTotalPayPrice(BigDecimal.ZERO); - entity.setTotalPayCount(BigDecimal.ZERO); - totalList.add(entity); - } - } - } - for (HardOrderCsVo v : totalList) { - // 退款 - if (ObjectUtil.isNotEmpty(refundList)) { - refundList.stream().filter(i -> ObjectUtil.equals(i.getMerchantId(), v.getMerchantId())).findFirst().ifPresent(i -> { - v.setTotalRefundPrice(i.getTotalPayPrice()); - v.setTotalRefundCount(i.getTotalPayCount()); - }); - } - // 消费净金额 - if (ObjectUtil.isNotEmpty(v.getTotalPayPrice())) { - BigDecimal refundPrice = BigDecimal.ZERO; - if (ObjectUtil.isNotEmpty(v.getTotalRefundPrice())) { - refundPrice = v.getTotalRefundPrice(); - } - v.setTotalProfitPrice(v.getTotalPayPrice().subtract(refundPrice)); - } - // 消费净次数 - if (ObjectUtil.isNotEmpty(v.getTotalPayCount())) { - BigDecimal refundCount = BigDecimal.ZERO; - if (ObjectUtil.isNotEmpty(v.getTotalRefundCount())) { - refundCount = v.getTotalRefundCount(); - } - v.setTotalProfitCount(v.getTotalPayCount().subtract(refundCount)); - } - } - // 聚合一下数据 + + // 使用有序 Map 一次完成合并,同时保留原流水排序,并覆盖筛选期内只有退款、没有新支付的商户。 + Map statisticsMap = new LinkedHashMap<>(); if (ObjectUtil.isNotEmpty(totalList)) { + for (HardOrderCsVo total : totalList) { + statisticsMap.put(total.getMerchantId(), total); + } + } + if (ObjectUtil.isNotEmpty(refundList)) { + for (HardOrderCsVo refund : refundList) { + HardOrderCsVo statistics = statisticsMap.computeIfAbsent(refund.getMerchantId(), merchantId -> { + HardOrderCsVo refundOnly = BeanUtils.toBean(refund, HardOrderCsVo.class); + refundOnly.setTotalPayPrice(BigDecimal.ZERO); + refundOnly.setTotalPayCount(BigDecimal.ZERO); + return refundOnly; + }); + statistics.setTotalRefundPrice(ObjectUtil.defaultIfNull(refund.getTotalPayPrice(), BigDecimal.ZERO)); + statistics.setTotalRefundCount(ObjectUtil.defaultIfNull(refund.getTotalPayCount(), BigDecimal.ZERO)); + } + } + + List statisticsList = new ArrayList<>(statisticsMap.values()); + for (HardOrderCsVo statistics : statisticsList) { + BigDecimal totalPayPrice = ObjectUtil.defaultIfNull(statistics.getTotalPayPrice(), BigDecimal.ZERO); + BigDecimal totalPayCount = ObjectUtil.defaultIfNull(statistics.getTotalPayCount(), BigDecimal.ZERO); + BigDecimal totalRefundPrice = ObjectUtil.defaultIfNull(statistics.getTotalRefundPrice(), BigDecimal.ZERO); + BigDecimal totalRefundCount = ObjectUtil.defaultIfNull(statistics.getTotalRefundCount(), BigDecimal.ZERO); + statistics.setTotalPayPrice(totalPayPrice); + statistics.setTotalPayCount(totalPayCount); + statistics.setTotalRefundPrice(totalRefundPrice); + statistics.setTotalRefundCount(totalRefundCount); + statistics.setTotalProfitPrice(totalPayPrice.subtract(totalRefundPrice)); + statistics.setTotalProfitCount(totalPayCount.subtract(totalRefundCount)); + } + + if (ObjectUtil.isNotEmpty(statisticsList)) { // 商户 - List mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList(); + List mchIds = statisticsList.stream().map(HardOrderCsVo::getMerchantId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchIds)) { - List mchList = remoteMchService.getByIds(mchIds).getData(); - if (ObjectUtil.isNotEmpty(mchList)) { - WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId)); + R> mchResponse = remoteMchService.getByIds(mchIds); + if (ObjectUtil.isNotEmpty(mchResponse) && mchResponse.isOk() && ObjectUtil.isNotEmpty(mchResponse.getData())) { + WalletDataUtil.convertMchList(statisticsList, CollectionUtils.convertMap(mchResponse.getData(), MchManageVo::getId)); } } // 商户部门 - List mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList(); + List mchDeptIds = statisticsList.stream().map(HardOrderCsVo::getMerchantDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); if (ObjectUtil.isNotEmpty(mchDeptIds)) { - Map deptMap = remoteDeptService.getDeptByDeptIds(mchDeptIds).getData(); - if (ObjectUtil.isNotEmpty(deptMap)) { - for (HardOrderCsVo v : totalList) { + R> deptResponse = remoteDeptService.getDeptByDeptIds(mchDeptIds); + if (ObjectUtil.isNotEmpty(deptResponse) && deptResponse.isOk() && ObjectUtil.isNotEmpty(deptResponse.getData())) { + Map deptMap = deptResponse.getData(); + for (HardOrderCsVo v : statisticsList) { if (ObjectUtil.isEmpty(v.getMerchantDeptId())) continue; - v.setMerchantDeptName(deptMap.get(v.getMerchantDeptId()).getName()); + SysDept dept = deptMap.get(v.getMerchantDeptId()); + // 历史订单关联的商户部门可能已删除或已不在当前租户可见范围,不能因名称缺失阻断整页统计。 + if (ObjectUtil.isNotEmpty(dept)) { + v.setMerchantDeptName(dept.getName()); + } } } } } - return totalList; + return statisticsList; } @Override @@ -391,11 +402,88 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { } /** - * 部门交易记录 + * 按用户部门汇总钱包操作、硬件消费、已完成退款和净消费数据。 + * + * @param dto 当前用户权限范围内的部门、时间和钱包类型筛选条件 + * @return 部门交易统计;筛选范围内没有部门时返回空列表 + * @throws IllegalStateException 部门或钱包远程统计调用失败时抛出,避免返回不完整报表 */ @Override public List pageListDept(HardDeptTotalDto dto) { - // 查询部门 + List deptList = queryDepartmentList(dto); + if (ObjectUtil.isEmpty(deptList)) { + return List.of(); + } + + List walletBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + List walletReturnCanteenList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.RETURN_CANTEEN.getCode(), null)); + List walletOnceBatchList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME)); + List walletOnceReduceList = queryWalletStatistics( + buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE)); + + List payList = hardOrderMapper.sumAndCount(buildOrderStatisticsDto(dto, false)); + List refundList = hardOrderMapper.refundSumAndCount(buildOrderStatisticsDto(dto, true)); + + Map walletBatchMap = indexWalletStatistics(walletBatchList); + Map walletReduceMap = indexWalletStatistics(walletReduceList); + Map walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList); + Map walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList); + Map walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList); + Map payMap = indexOrderStatistics(payList); + Map refundMap = indexOrderStatistics(refundList); + + List resultList = new ArrayList<>(deptList.size()); + for (SysDept dept : deptList) { + HardDeptTotalVo statistics = new HardDeptTotalVo(); + statistics.setDeptId(dept.getDeptId()); + statistics.setDeptName(dept.getName()); + + WalletTransactionSumAndCountVo walletBatch = walletBatchMap.get(dept.getDeptId()); + if (walletBatch != null) { + statistics.setWalletPrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletBatch.getSum(), 0L))); + statistics.setWalletCount(ObjectUtil.defaultIfNull(walletBatch.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletReduce = walletReduceMap.get(dept.getDeptId()); + if (walletReduce != null) { + statistics.setWalletReducePrice(Math.abs(ObjectUtil.defaultIfNull(walletReduce.getSum(), 0L))); + statistics.setWalletReduceCount(ObjectUtil.defaultIfNull(walletReduce.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletReturnCanteen = walletReturnCanteenMap.get(dept.getDeptId()); + if (walletReturnCanteen != null) { + statistics.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs( + ObjectUtil.defaultIfNull(walletReturnCanteen.getSum(), 0L)))); + statistics.setWalletReturnCanteenCount(ObjectUtil.defaultIfNull(walletReturnCanteen.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletOnceBatch = walletOnceBatchMap.get(dept.getDeptId()); + if (walletOnceBatch != null) { + statistics.setWalletOncePrice(BigDecimal.valueOf(ObjectUtil.defaultIfNull(walletOnceBatch.getSum(), 0L))); + statistics.setWalletOnceCount(ObjectUtil.defaultIfNull(walletOnceBatch.getCount(), 0L)); + } + WalletTransactionSumAndCountVo walletOnceReduce = walletOnceReduceMap.get(dept.getDeptId()); + if (walletOnceReduce != null) { + statistics.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs( + ObjectUtil.defaultIfNull(walletOnceReduce.getSum(), 0L)))); + statistics.setWalletOnceReduceCount(ObjectUtil.defaultIfNull(walletOnceReduce.getCount(), 0L)); + } + applyOrderStatistics(statistics, payMap.get(dept.getDeptId()), refundMap.get(dept.getDeptId())); + resultList.add(statistics); + } + return resultList; + } + + /** + * 查询统计结果需要展示的部门,并校验 UPMS 远程调用结果。 + * + * @param dto 部门包含或排除筛选条件 + * @return 匹配的部门列表,远程响应数据为空时返回空列表 + * @throws IllegalStateException UPMS 部门远程调用失败时抛出 + */ + private List queryDepartmentList(HardDeptTotalDto dto) { DeptAllListDto deptDto = new DeptAllListDto(); deptDto.setIsInclude(dto.getIsInclude()); if (ObjectUtil.isNotEmpty(dto.getDeptId())) { @@ -405,186 +493,126 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService { deptDto.setDeptId(null); deptDto.setDeptIds(dto.getDeptIds()); } - List deptList = remoteDeptService.getInnerAllDept(deptDto).getData(); - if (ObjectUtil.isEmpty(deptList)) return null; - // List deptIds = deptList.stream().map(SysDept::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - List resultList = deptList.stream().map(v -> { - HardDeptTotalVo item = new HardDeptTotalVo(); - item.setDeptId(v.getDeptId()); - item.setDeptName(v.getName()); - return item; - }).toList(); - // 查询钱包批量充值流水 - CompletableFuture> walletBatchFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包批量扣减流水 - CompletableFuture> walletReduceFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.BATCH.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包退还各自食堂 - CompletableFuture> walletRetrunCanteenFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode())); - // walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包单次充值流水 - CompletableFuture> walletOnceBatchFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_INCOME); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询钱包单次扣减流水 - CompletableFuture> walletOnceReduceFuture = CompletableFuture.supplyAsync(() -> { - WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); - walletDto.setDeptIds(dto.getDeptIds()); - walletDto.setIsInclude(dto.getIsInclude()); - walletDto.setBizTypes(List.of(WalletBizEnum.UPDATE.getCode())); - walletDto.setType(WalletTransactionSumAndCountDto.TYPE_EXPENSE); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - walletDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - walletDto.setWalletTypeId(dto.getWalletTypeId()); - } - return remoteWalletTransactionService.sumAndCount(walletDto).getData(); - }); - // 查询订单支付流水 - CompletableFuture> payFuture = CompletableFuture.supplyAsync(() -> { - HardOrderSumAndCountDto payDto = new HardOrderSumAndCountDto(); - payDto.setUserDeptIds(dto.getDeptIds()); - payDto.setIsInclude(dto.getIsInclude()); - payDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - payDto.setCreateTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - payDto.setWalletTypeId(dto.getWalletTypeId()); - } - return hardOrderMapper.sumAndCount(payDto); - }); - // 查询订单退款流水 - CompletableFuture> refundFuture = CompletableFuture.supplyAsync(() -> { - HardOrderSumAndCountDto refundDto = new HardOrderSumAndCountDto(); - refundDto.setUserDeptIds(dto.getDeptIds()); - refundDto.setIsInclude(dto.getIsInclude()); - refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes()); - refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); - if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) { - refundDto.setRefundTimeList(dto.getCreateTimeList()); - } - if (ObjectUtil.isNotEmpty(dto.getWalletTypeId())) { - refundDto.setWalletTypeId(dto.getWalletTypeId()); - } - return hardOrderMapper.refundSumAndCount(refundDto); - }); - CompletableFuture allFutures = CompletableFuture.allOf(walletBatchFuture, walletReduceFuture, payFuture, refundFuture, walletOnceBatchFuture, walletOnceReduceFuture); - allFutures.join(); - List walletBatchList = walletBatchFuture.join(); - List walletReduceList = walletReduceFuture.join(); - List walletRetrunCanteenList = walletRetrunCanteenFuture.join(); - List walletOnceBatchList = walletOnceBatchFuture.join(); - List walletOnceReduceList = walletOnceReduceFuture.join(); - List payList = payFuture.join(); - List refundList = refundFuture.join(); - // 聚合一下数据 - for (HardDeptTotalVo v : resultList) { - if (ObjectUtil.isNotEmpty(walletBatchList)) { - walletBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletPrice(BigDecimal.valueOf(i.getSum())); - v.setWalletCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletReduceList)) { - walletReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletReducePrice(Math.abs(i.getSum())); - v.setWalletReduceCount(i.getCount()); - }); - } - // 退还各自食堂 - if (ObjectUtil.isNotEmpty(walletRetrunCanteenList)) { - walletRetrunCanteenList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletReturnCanteenPrice(BigDecimal.valueOf(Math.abs(i.getSum()))); - v.setWalletReturnCanteenCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletOnceBatchList)) { - walletOnceBatchList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletOncePrice(BigDecimal.valueOf(i.getSum())); - v.setWalletOnceCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(walletOnceReduceList)) { - walletOnceReduceList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setWalletOnceReducePrice(BigDecimal.valueOf(Math.abs(i.getSum()))); - v.setWalletOnceReduceCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(payList)) { - payList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setOrderPayPrice(BigDecimal.valueOf(i.getSum())); - v.setOrderPayCount(i.getCount()); - }); - } - if (ObjectUtil.isNotEmpty(refundList)) { - refundList.stream().filter(i -> ObjectUtil.equals(i.getDeptId(), v.getDeptId())).findFirst().ifPresent(i -> { - v.setOrderRefundPrice(BigDecimal.valueOf(i.getSum())); - v.setOrderRefundCont(i.getCount()); - }); + R> response = remoteDeptService.getInnerAllDept(deptDto); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询部门交易统计的部门信息失败"); + } + return response.getData() == null ? List.of() : response.getData(); + } + + /** + * 构建钱包部门统计条件,保证五类钱包统计使用完全一致的权限、时间和钱包类型范围。 + * + * @param dto 部门统计页面筛选条件 + * @param bizType 钱包流水业务类型编码 + * @param type 收支方向;为空时不限制流水正负方向 + * @return 钱包流水汇总条件 + */ + private WalletTransactionSumAndCountDto buildWalletStatisticsDto(HardDeptTotalDto dto, String bizType, Integer type) { + WalletTransactionSumAndCountDto walletDto = new WalletTransactionSumAndCountDto(); + walletDto.setDeptIds(dto.getDeptIds()); + walletDto.setIsInclude(dto.getIsInclude()); + walletDto.setBizTypes(List.of(bizType)); + walletDto.setType(type); + walletDto.setCreateTimeList(dto.getCreateTimeList()); + walletDto.setWalletTypeId(dto.getWalletTypeId()); + return walletDto; + } + + /** + * 调用钱包服务查询部门流水统计;远程业务失败时终止本次汇总,避免把失败数据误展示为零。 + * + * @param walletDto 钱包流水汇总条件 + * @return 钱包部门统计,远程响应数据为空时返回空列表 + * @throws IllegalStateException 钱包流水远程调用失败时抛出 + */ + private List queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) { + R> response = remoteWalletTransactionService.sumAndCount(walletDto); + if (response == null || !response.isOk()) { + throw new IllegalStateException("查询钱包部门交易统计失败"); + } + return response.getData() == null ? List.of() : response.getData(); + } + + /** + * 构建硬件订单部门统计条件。退款按售后完成时间统计,并覆盖支付、核销及整单退款状态, + * 因为部分退款不会把原订单状态改成整单退款。 + * + * @param dto 部门统计页面筛选条件 + * @param refund 是否查询已完成退款 + * @return 支付或退款汇总条件 + */ + private HardOrderSumAndCountDto buildOrderStatisticsDto(HardDeptTotalDto dto, boolean refund) { + HardOrderSumAndCountDto orderDto = new HardOrderSumAndCountDto(); + orderDto.setUserDeptIds(dto.getDeptIds()); + orderDto.setIsInclude(dto.getIsInclude()); + orderDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice()); + orderDto.setWalletTypeId(dto.getWalletTypeId()); + if (refund) { + orderDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus())); + orderDto.setRefundTimeList(dto.getCreateTimeList()); + } else { + orderDto.setCreateTimeList(dto.getCreateTimeList()); + } + return orderDto; + } + + /** + * 将钱包统计按部门建立索引,避免每个部门重复遍历全部统计结果。 + * + * @param statisticsList 钱包部门统计列表 + * @return 以部门 ID 为键的统计索引 + */ + private Map indexWalletStatistics(List statisticsList) { + Map statisticsMap = new LinkedHashMap<>(); + if (ObjectUtil.isNotEmpty(statisticsList)) { + for (WalletTransactionSumAndCountVo statistics : statisticsList) { + if (statistics != null && statistics.getDeptId() != null) { + statisticsMap.put(statistics.getDeptId(), statistics); + } } } - // 计算净流水 - for (HardDeptTotalVo v : resultList) { - // 流水金额 - if (ObjectUtil.isNotEmpty(v.getOrderPayPrice())) { - v.setOrderProfitPrice(v.getOrderPayPrice().subtract(v.getOrderRefundPrice())); - } - // 流水次数 - if (ObjectUtil.isNotEmpty(v.getOrderPayCount())) { - v.setOrderProfitCont(v.getOrderPayCount() - v.getOrderRefundCont()); + return statisticsMap; + } + + /** + * 将订单统计按部门建立索引,供支付和退款结果按相同键合并。 + * + * @param statisticsList 订单部门统计列表 + * @return 以部门 ID 为键的统计索引 + */ + private Map indexOrderStatistics(List statisticsList) { + Map statisticsMap = new LinkedHashMap<>(); + if (ObjectUtil.isNotEmpty(statisticsList)) { + for (HardOrderSumAndCountVo statistics : statisticsList) { + if (statistics != null && statistics.getDeptId() != null) { + statisticsMap.put(statistics.getDeptId(), statistics); + } } } - return resultList; + return statisticsMap; + } + + /** + * 合并部门的订单支付与已完成退款,并按原接口口径计算净金额和净次数。 + * + * @param target 部门统计结果 + * @param pay 支付统计,允许为空 + * @param refund 已完成售后退款统计,允许为空 + */ + private void applyOrderStatistics(HardDeptTotalVo target, HardOrderSumAndCountVo pay, HardOrderSumAndCountVo refund) { + long payPrice = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getSum(), 0L); + long payCount = pay == null ? 0L : ObjectUtil.defaultIfNull(pay.getCount(), 0L); + long refundPrice = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getSum(), 0L); + long refundCount = refund == null ? 0L : ObjectUtil.defaultIfNull(refund.getCount(), 0L); + BigDecimal orderPayPrice = BigDecimal.valueOf(payPrice); + BigDecimal orderRefundPrice = BigDecimal.valueOf(refundPrice); + target.setOrderPayPrice(orderPayPrice); + target.setOrderPayCount(payCount); + target.setOrderRefundPrice(orderRefundPrice); + target.setOrderRefundCont(refundCount); + target.setOrderProfitPrice(orderPayPrice.subtract(orderRefundPrice)); + target.setOrderProfitCont(payCount - refundCount); } /** diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java index 605c1c90..2360c6a6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/spu/HardSpuRepertoryServiceImpl.java @@ -4,11 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.bsd.pjcloud.admin.api.entity.SysUser; -import com.bsd.pjcloud.admin.api.feign.RemoteUserService; -import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.common.core.util.CollectionUtils; -import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.hard.api.constant.spu.HardSpuRepertoryTypeEnum; @@ -23,6 +19,7 @@ import com.bsd.pjcloud.hard.api.vo.spu.HardSpuRepertoryVo; import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper; import com.bsd.pjcloud.hard.mapper.spu.HardSpuRepertoryMapper; import com.bsd.pjcloud.hard.service.*; +import com.bsd.pjcloud.hard.support.HardUserIdentitySupport; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -50,10 +47,20 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageJoin(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -235,6 +242,12 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl list, HardSpuRepertoryDto dto) { if (ObjectUtil.isEmpty(list)) return; // 商户 @@ -246,11 +259,24 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl userIds = list.stream().map(HardSpuRepertoryVo::getUserId).distinct().toList(); - R> userList = remoteUserService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } + // 用户身份、姓名、手机号、头像及部门统一由 UPMS 公共装配器回填。 + userIdentitySupport.fillUserInfo(list, true); } -} \ No newline at end of file + + /** + * 将库存上架记录的身份条件转换为最终用户主键集合,保持原有 userIds SQL 过滤契约。 + * + * @param dto 库存记录查询条件;处理后身份字段被最终用户主键集合替代 + */ + private void prepareIdentityQuery(HardSpuRepertoryDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/support/HardUserIdentitySupport.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/support/HardUserIdentitySupport.java new file mode 100644 index 00000000..1181704a --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/support/HardUserIdentitySupport.java @@ -0,0 +1,255 @@ +package com.bsd.pjcloud.hard.support; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +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.BaseUserIdentityQueryInterface; +import com.bsd.pjcloud.common.core.util.CollectionUtils; +import com.bsd.pjcloud.common.core.util.R; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Hardware 后台列表的用户身份查询与展示资料装配器。 + * + *

订单等业务表只保存用户主键和必要快照,身份筛选统一交给 UPMS 数据权限用户接口处理, + * Hardware 仅将命中的用户主键并入原有查询,并为当前页批量回填身份展示资料。

+ */ +@Component +@RequiredArgsConstructor +public class HardUserIdentitySupport { + + /** 单次调用 UPMS 查询的最大用户数量,控制跨服务响应体和内存占用。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 身份条件无匹配用户时使用的无效用户主键,确保下游忽略空集合的 SQL 不会放宽查询。 */ + private static final Long NO_MATCH_USER_ID = -1L; + + /** UPMS 用户远程服务,负责数据权限、身份筛选及身份资料返回。 */ + private final RemoteUserService remoteUserService; + + /** + * 判断查询对象是否携带实际生效的身份或人脸条件。 + * + * @param query Hardware 业务查询对象;为空表示没有身份条件 + * @return 至少存在一个有效身份、人脸或身份扩展条件时返回 {@code true} + */ + public boolean hasIdentityQuery(BaseUserIdentityQueryInterface query) { + if (query == null) { + return false; + } + return query.getIdentityTypes() != null && !query.getIdentityTypes().isEmpty() + || hasStudentQuery(query.getStudentInfo()) + || hasTeacherQuery(query.getTeacherInfo()) + || StrUtil.isNotBlank(query.getHasFace()); + } + + /** + * 在现有用户条件范围内查询符合身份条件的用户主键。 + * + * @param query 身份及人脸查询条件 + * @param userId 页面已有的单用户筛选条件 + * @param userIds 页面已有的多用户筛选条件 + * @return 同时符合已有用户范围、当前操作者数据权限和身份条件的用户主键;无匹配时返回无效主键 + */ + public List findIdentityUserIds(BaseUserIdentityQueryInterface query, Long userId, List userIds) { + List constrainedUserIds = mergeUserConstraints(userId, userIds); + if (constrainedUserIds != null && constrainedUserIds.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + UserDTO userQuery = buildUserQuery(query, constrainedUserIds); + List users = fetchAllUsers(userQuery); + if (users.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + return users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + } + + /** + * 批量补齐 Hardware 列表的用户主资料和身份资料,并按需更新当前用户部门。 + * + * @param rows 当前页 Hardware 业务记录 + * @param updateDepartment 是否使用 UPMS 当前部门覆盖业务记录中的部门快照 + * @param 同时实现用户部门和用户身份展示契约的业务 VO 类型 + */ + public void fillUserInfo(List rows, + boolean updateDepartment) { + if (rows == null || rows.isEmpty()) { + return; + } + List userIds = rows.stream().map(BaseUserDeptInterface::getUserId).filter(Objects::nonNull).distinct().toList(); + if (userIds.isEmpty()) { + return; + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + Map userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId); + for (T row : rows) { + UserVO user = userMap.get(row.getUserId()); + if (user == null) { + continue; + } + // 部分历史 VO 显式声明了标准字段,需同时回填旧字段以兼容既有接口与导出。 + row.setUsername(user.getName()); + row.setUserMobile(user.getPhone()); + row.setUserAvatar(user.getAvatar()); + row.setName(user.getName()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + if (updateDepartment) { + row.setUserDeptId(user.getDeptId()); + row.setUserDeptName(user.getDeptName()); + row.setDeptName(user.getDeptName()); + } + row.setIdentityTypes(copyList(user.getIdentityTypes())); + row.setStudentInfo(copyStudentInfo(user.getStudentInfo())); + row.setTeacherInfo(copyTeacherInfo(user.getTeacherInfo())); + } + } + + /** + * 合并单用户和多用户条件;两者同时存在时取交集,保持原查询的“同时满足”语义。 + * + * @param userId 单用户主键 + * @param userIds 多用户主键集合 + * @return 去重后的用户限制;返回 {@code null} 表示未限制用户,空集合表示条件互斥 + */ + private List mergeUserConstraints(Long userId, List userIds) { + LinkedHashSet constraints = new LinkedHashSet<>(); + if (userIds != null && !userIds.isEmpty()) { + userIds.stream().filter(Objects::nonNull).forEach(constraints::add); + } + if (userId == null) { + return constraints.isEmpty() ? null : new ArrayList<>(constraints); + } + if (constraints.isEmpty()) { + return List.of(userId); + } + return constraints.contains(userId) ? List.of(userId) : List.of(); + } + + /** + * 把 Hardware 查询契约转换为 UPMS 用户查询对象,并复制可变身份条件。 + * + * @param query Hardware 侧身份查询条件 + * @param constrainedUserIds 已有用户筛选范围 + * @return 可独立交给 UPMS 使用的用户查询对象 + */ + private UserDTO buildUserQuery(BaseUserIdentityQueryInterface query, List constrainedUserIds) { + UserDTO userQuery = new UserDTO(); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); + userQuery.setUserIds(copyList(constrainedUserIds)); + return userQuery; + } + + /** + * 分页读取当前操作者权限范围内的全部匹配用户。 + * + * @param query UPMS 用户查询条件 + * @return 按 UPMS 分页顺序合并的用户列表 + */ + private List fetchAllUsers(UserDTO query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + R> response = remoteUserService.getScopeBasicUserPage(current, USER_BATCH_SIZE, query); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询权限用户及身份信息失败")); + } + Page page = response.getData(); + List records = page.getRecords() == null ? List.of() : page.getRecords(); + users.addAll(records); + total = page.getTotal(); + if (records.isEmpty()) { + break; + } + current++; + } while (users.size() < total); + return users; + } + + /** + * 判断学生扩展对象是否包含有效筛选字段。 + * + * @param studentInfo 学生身份扩展条件 + * @return 学号、学校、班级或监护人手机号任一非空时返回 {@code true} + */ + private boolean hasStudentQuery(SysUserStudentDto studentInfo) { + return studentInfo != null && (StrUtil.isNotBlank(studentInfo.getStudentNo()) + || StrUtil.isNotBlank(studentInfo.getSchoolName()) + || StrUtil.isNotBlank(studentInfo.getClassName()) + || StrUtil.isNotBlank(studentInfo.getGuardianPhones())); + } + + /** + * 判断老师扩展对象是否包含有效筛选字段。 + * + * @param teacherInfo 老师身份扩展条件 + * @return 老师学号非空时返回 {@code true} + */ + private boolean hasTeacherQuery(SysUserTeacherDto teacherInfo) { + return teacherInfo != null && StrUtil.isNotBlank(teacherInfo.getTeacherNo()); + } + + /** + * 复制学生身份资料,避免跨服务响应对象被业务列表意外修改。 + * + * @param source 原学生身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份资料,保持查询与展示对象相互独立。 + * + * @param source 原老师身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 复制可变列表并保持空值语义。 + * + * @param source 原列表 + * @param 列表元素类型 + * @return 独立列表;源列表为空值时返回 {@code null} + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml index 6a263393..32d14f06 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardAuthMapper.xml @@ -41,6 +41,12 @@ and a.user_id = #{dto.userId} + + and a.user_id in + + #{v} + + and a.hard_id = #{dto.hardId} @@ -70,4 +76,4 @@ order by a.create_time desc , a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml index 80964a43..78dc2ab0 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardBroadcastMapper.xml @@ -51,6 +51,12 @@ and a.user_id = #{dto.userId} + + and a.user_id in + + #{v} + + and a.hard_id = #{dto.hardId} @@ -87,4 +93,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml index 9aee10fe..c404588d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/HardInvoiceMapper.xml @@ -84,4 +84,4 @@ order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml index 89a5f2b8..cd012608 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAfterSaleMapper.xml @@ -87,18 +87,37 @@ o.total_price as orderTotalPrice, o.extra_price as orderExtraPrice, o.pay_price as orderPayPrice, - o.pay_time as orderPayTime + o.pay_time as orderPayTime, + o.appt_use_date as orderApptUseDate from hard_after_sale a left join hard_order o on o.id = a.order_id - + and o.merchant_id in #{v} + + and o.order_status in + + #{v} + + + + and o.order_type = #{dto.orderType} + + + and o.hour_type in + + #{v} + + + + and o.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]} + order by a.create_time desc,a.id desc - \ No newline at end of file + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml index 69493d93..9269d825 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardAutoRefundTaskMapper.xml @@ -68,4 +68,38 @@ order by t.trigger_time asc, t.id asc + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 1237b604..78fe0bb9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -57,6 +57,21 @@ + + + ( + select refund_item.parent_car_id, + refund_item.product_id, + sum(ifnull(refund_item.product_count, 0)) as product_count, + sum(ifnull(refund_item.total_price, 0)) as total_price + from hard_after_sale_car refund_item + inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id + and refund_order.del_flag = '0' and refund_order.status = '50' + where refund_item.del_flag = '0' + group by refund_item.parent_car_id, refund_item.product_id + ) refund_car + + + + + and o.merchant_id = #{dto.merchantId} + + + and o.merchant_id in + + #{v} + + + + and o.hour_type = #{dto.hourType} + + + + + and o.dept_id in + + + and o.dept_id not in + + + + #{v} + + + + + + and o.user_dept_id in + + + and o.user_dept_id not in + + + + #{v} + + + + and o.user_id in + + #{v} + + + + + + + + and a.product_name like concat('%', #{dto.productName}, '%') + + + + + + ( + select refund_item.parent_car_id, + refund_item.product_id, + sum(ifnull(refund_item.product_count, 0)) as product_count, + sum(ifnull(refund_item.total_price, 0)) as total_price + from hard_after_sale_car refund_item + inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id + and refund_order.del_flag = '0' and refund_order.status = '50' + inner join hard_order o on o.parent_car_id = refund_item.parent_car_id + and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2' + and (o.appt_id is not null or o.appt_rule_id is not null) + and o.order_status = '20' and o.verification_time is not null + and o.hour_type is not null and o.hour_type not in ('-1') + where refund_item.del_flag = '0' + + and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + group by refund_item.parent_car_id, refund_item.product_id + ) refund_car + + - ( + + select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date, o.pay_time as stat_time, o.id as order_id, @@ -142,7 +234,14 @@ where a.del_flag = '0' and o.pay_time is not null and o.order_status in ('5', '20') + + and o.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + union all + + select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date, o.verification_time as stat_time, o.id as order_id, @@ -153,13 +252,13 @@ o.merchant_dept_id, o.hour_type, a.product_id, - a.product_name, - a.product_pic, - a.product_price, - a.product_count, - a.total_price, + max(a.product_name) as product_name, + max(a.product_pic) as product_pic, + max(a.product_price) as product_price, + greatest(sum(ifnull(a.product_count, 0)) - ifnull(refund_car.product_count, 0), 0) as product_count, + greatest(sum(ifnull(a.total_price, 0)) - ifnull(refund_car.total_price, 0), 0) as total_price, 'RESERVATION_VERIFIED' as sale_source - from + from hard_car a inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0' inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2' @@ -167,7 +266,20 @@ and o.order_status = '20' and o.verification_time is not null and o.hour_type is not null and o.hour_type not in ('-1') - ) sale_stats + left join + on refund_car.parent_car_id = a.parent_car_id + and refund_car.product_id = a.product_id + where a.del_flag = '0' + + and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]} + + + + group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id, + o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price + having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0) + + @@ -244,6 +356,45 @@ + + + + + + with sale_stats as ( select * from ) - select export_rows.statisticsDate, + select export_rows.recordLevel, + export_rows.statisticsDate, export_rows.hourType, export_rows.saleSource, + export_rows.merchantId, + export_rows.userDeptId, + export_rows.productId, + export_rows.productName, export_rows.payUserCount, export_rows.timeCount, export_rows.payCount, export_rows.totalPrice from ( - select sale_stats.stat_date as statisticsDate, + select 'SUMMARY' as recordLevel, + sale_stats.stat_date as statisticsDate, sale_stats.hour_type as hourType, null as saleSource, + null as merchantId, + null as userDeptId, + null as productId, + null as productName, count(distinct sale_stats.user_id) as payUserCount, count(distinct sale_stats.order_id) as timeCount, sum(sale_stats.product_count) as payCount, @@ -670,9 +831,14 @@ group by sale_stats.stat_date, sale_stats.hour_type union all - select sale_stats.stat_date as statisticsDate, + select 'SUMMARY' as recordLevel, + sale_stats.stat_date as statisticsDate, sale_stats.hour_type as hourType, sale_stats.sale_source as saleSource, + null as merchantId, + null as userDeptId, + null as productId, + null as productName, count(distinct sale_stats.user_id) as payUserCount, count(distinct sale_stats.order_id) as timeCount, sum(sale_stats.product_count) as payCount, @@ -682,33 +848,15 @@ group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source - ) export_rows - order by export_rows.statisticsDate, - export_rows.hourType, - case when export_rows.saleSource is null then 0 - when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end - - - - - - + + select p.mch_id as mchId, + p.pay_platform_type as payPlatformType, + count(distinct p.id) as recordCount, + coalesce(sum(p.price), 0) as amount + from third_pay_order p + + + and p.status = '20' + + group by p.mch_id, p.pay_platform_type + + + + + + + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml index 9a013c7a..ee5c6ae0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPaymentTaskMapper.xml @@ -68,13 +68,28 @@ AND t.del_flag = '0') ORDER BY p.update_time, p.id LIMIT #{limit} + + SELECT r.tenant_id, #{taskType} AS task_type, + CONCAT('FZ_RELATION_BIND:', r.id) AS task_key, + r.id AS fz_relation_id, 150 AS priority, + COALESCE(r.update_time, r.create_time, #{now}) AS next_run_time + FROM third_mch_fz_relation r + WHERE r.del_flag = '0' AND r.is_default = '1' AND r.enable_fz = '1' + AND r.status IN ('PENDING_BIND', 'BINDING') + AND NOT EXISTS ( + SELECT 1 FROM third_payment_task t + WHERE t.tenant_id = r.tenant_id + AND t.task_key = CONCAT('FZ_RELATION_BIND:', r.id) + AND t.del_flag = '0') + ORDER BY r.update_time, r.id LIMIT #{limit} + SELECT p.tenant_id, #{taskType} AS task_type, CONCAT('FZ_CREATE:', p.pay_no) AS task_key, p.id AS pay_order_id, p.pay_no, 90 AS priority, DATE_ADD(COALESCE(p.pay_time, p.update_time, #{now}), INTERVAL 30 SECOND) AS next_run_time FROM third_pay_order p - WHERE p.del_flag = '0' AND p.status = '20' AND p.enable_fz = '1' - AND p.pay_no IS NOT NULL + WHERE p.del_flag = '0' AND p.status = '20' AND p.pay_no IS NOT NULL + AND (p.pay_platform_type = '20' OR (p.pay_platform_type = '10' AND p.enable_fz = '1')) AND NOT EXISTS ( SELECT 1 FROM third_fz_order f WHERE f.tenant_id = p.tenant_id AND f.pay_no = p.pay_no AND f.del_flag = '0') @@ -118,7 +133,7 @@ p.id AS pay_order_id, f.id AS fz_order_id, f.pay_no, f.fz_no, 100 AS priority, #{now} AS next_run_time FROM third_fz_order f JOIN third_pay_order p ON p.tenant_id = f.tenant_id AND p.pay_no = f.pay_no AND p.del_flag = '0' - WHERE f.del_flag = '0' AND f.status = '10' AND p.pay_platform_type = '20' + WHERE f.del_flag = '0' AND f.status = '10' AND p.pay_platform_type = '20' AND p.enable_fz = '1' AND JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.settleConfirm.status')) = 'SUCCESS' AND (JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.royalty.status')) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.royalty.status')) NOT IN ('SUBMITTED', 'SUCCESS')) @@ -131,7 +146,7 @@ p.id AS pay_order_id, f.id AS fz_order_id, f.pay_no, f.fz_no, 100 AS priority, #{now} AS next_run_time FROM third_fz_order f JOIN third_pay_order p ON p.tenant_id = f.tenant_id AND p.pay_no = f.pay_no AND p.del_flag = '0' - WHERE f.del_flag = '0' AND f.status = '10' AND p.pay_platform_type = '20' + WHERE f.del_flag = '0' AND f.status = '10' AND p.pay_platform_type = '20' AND p.enable_fz = '1' AND JSON_UNQUOTE(JSON_EXTRACT(COALESCE(f.create_res, '{}'), '$.royalty.status')) IN ('SUBMITTED', 'PROCESSING') AND NOT EXISTS (SELECT 1 FROM third_payment_task t WHERE t.tenant_id = f.tenant_id AND t.task_key = CONCAT('FZ_ROYALTY_QUERY:', f.pay_no) AND t.del_flag = '0') @@ -235,11 +250,11 @@ INSERT IGNORE INTO third_payment_task - (id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id, + (id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, fz_relation_id, aggregate_session_id, pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count, create_time, update_time, del_flag) VALUES - (#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId}, + (#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{fzRelationId}, #{aggregateSessionId}, #{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority}, #{attemptCount}, #{createTime}, #{updateTime}, #{delFlag}) @@ -328,4 +343,13 @@ last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime} WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0' + + + UPDATE third_payment_task + SET status = 'READY', next_run_time = #{nextRunTime}, finished_time = NULL, + lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL, + last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime} + WHERE tenant_id = #{tenantId} AND task_key = #{taskKey} + AND status IN ('SUCCESS', 'FAILED', 'RETRY', 'ALERT', 'CANCELLED') AND del_flag = '0' + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java new file mode 100644 index 00000000..78770110 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java @@ -0,0 +1,117 @@ +package com.bsd.pjcloud.thirdparty.mapper.pay; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 第三方支付统计 SQL 契约测试,防止后续维护引入支付与分账扇出或移除租户连接条件。 + */ +class ThirdPayTotalMapperSqlContractTest { + + /** 第三方支付统计 MyBatis 映射资源路径。 */ + private static final String MAPPER_RESOURCE = "mapper/ThirdPayTotalMapper.xml"; + + /** 测试启动时读取的统计 Mapper XML。 */ + private static String mapperXml; + + /** + * 从测试类路径读取本轮实际参与编译的 Mapper XML。 + * + * @throws IOException 资源读取失败时抛出 + */ + @BeforeAll + static void loadMapperXml() throws IOException { + try (InputStream input = ThirdPayTotalMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "第三方支付统计 Mapper XML 必须进入运行时类路径"); + mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 支付成功聚合不得连接分账表,避免一个支付订单因多条分账记录重复累加金额。 + */ + @Test + void paySuccessAggregateShouldNotJoinProfitSharingRows() { + String sql = statement("selectPaySuccessAggregates"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("count(distinct p.id)")), + () -> Assertions.assertTrue(sql.contains("p.status = '20'")), + () -> Assertions.assertFalse(sql.contains("third_fz_order"))); + } + + /** + * 退款快照必须独立读取并在 Service 解析,不允许 SQL 展开 JSON 后制造重复退款事实。 + */ + @Test + void refundRecordsShouldRemainIndependentFromJsonExpansion() { + String sql = statement("selectRefundRecords"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("select distinct p.id")), + () -> Assertions.assertTrue(sql.contains("p.status = '200'")), + () -> Assertions.assertFalse(sql.toLowerCase().contains("json_extract")), + () -> Assertions.assertFalse(sql.toLowerCase().contains("json_table"))); + } + + /** + * 分账聚合必须先按租户和支付单号收敛,再通过相同二元键连接支付基础集合。 + */ + @Test + void profitSharingAggregateShouldJoinByTenantAndPayNoAfterGrouping() { + String sql = statement("selectFzSuccessAggregates"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("group by f.tenant_id, f.pay_no")), + () -> Assertions.assertTrue(sql.contains("fz.tenant_id = base.tenant_id")), + () -> Assertions.assertTrue(sql.contains("fz.pay_no = base.pay_no")), + () -> Assertions.assertTrue(sql.contains("f.status = '20'")), + () -> Assertions.assertTrue(sql.contains("f.del_flag = '0'"))); + } + + /** + * 成功退分账查询同样必须限制租户、支付单号、软删除和状态,不能跨租户串单。 + */ + @Test + void profitSharingRefundShouldKeepTenantAndSoftDeleteConditions() { + String sql = statement("selectFzRefundRecords"); + + Assertions.assertAll( + () -> Assertions.assertTrue(sql.contains("f.tenant_id = base.tenant_id")), + () -> Assertions.assertTrue(sql.contains("f.pay_no = base.pay_no")), + () -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")), + () -> Assertions.assertTrue(sql.contains("f.status = '200'"))); + } + + /** + * 全部查询必须复用支付时间、商户、支付方式和支付订单软删除条件。 + */ + @Test + void baseWhereShouldKeepRequiredFilters() { + Assertions.assertAll( + () -> Assertions.assertTrue(mapperXml.contains("p.del_flag = '0'")), + () -> Assertions.assertTrue(mapperXml.contains("p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}")), + () -> Assertions.assertTrue(mapperXml.contains("p.mch_id = #{query.mchId}")), + () -> Assertions.assertTrue(mapperXml.contains("p.pay_platform_type = #{query.payPlatformType}"))); + } + + /** + * 提取指定 ID 的 select 片段,兼容元素属性换行。 + * + * @param id MyBatis 查询 ID + * @return 对应查询 XML 片段 + */ + private static String statement(String id) { + int idIndex = mapperXml.indexOf("id=\"" + id + "\""); + int start = mapperXml.lastIndexOf("", idIndex); + Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 查询:" + id); + return mapperXml.substring(start, end); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java index 44c603b6..9ea945d0 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPaymentTaskMapperSqlContractTest.java @@ -17,15 +17,19 @@ class ThirdPaymentTaskMapperSqlContractTest { /** MyBatis 支付任务映射资源路径。 */ private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml"; + /** MyBatis 支付订单映射资源路径,支付订单必须回读创建时固定的分账关系主键。 */ + private static final String PAY_ORDER_MAPPER_RESOURCE = "mapper/ThirdPayOrderMapper.xml"; - /** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */ - private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql"; + /** thirdparty 支付统一迁移与高频业务扫描索引相对项目根目录的路径。 */ + private static final String PAYMENT_TASK_MIGRATION = "db/20260825_third_payment_schema.sql"; /** 测试启动时读取的 Mapper XML。 */ private static String mapperXml; /** 测试启动时读取的支付任务数据库迁移。 */ private static String migrationSql; + /** 测试启动时读取的支付订单 Mapper XML。 */ + private static String payOrderMapperXml; /** * 从测试类路径读取本轮实际参与编译的 Mapper XML。 @@ -39,9 +43,23 @@ class ThirdPaymentTaskMapperSqlContractTest { Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径"); mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); } + try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader() + .getResourceAsStream(PAY_ORDER_MAPPER_RESOURCE)) { + Assertions.assertNotNull(input, "支付订单 Mapper XML 必须进入运行时类路径"); + payOrderMapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION); } + /** + * 支付订单管理查询必须投影关系主键,分账和退分账才能使用支付创建时的关系快照。 + */ + @Test + void payOrderProjectionShouldKeepProfitSharingRelationId() { + Assertions.assertTrue(payOrderMapperXml.contains("a.fz_relation_id"), + "支付订单查询必须保留创建时固定的分账关系主键"); + } + /** * 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。 */ @@ -101,6 +119,25 @@ class ThirdPaymentTaskMapperSqlContractTest { () -> Assertions.assertTrue(migrationSql.contains("`idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)"))); } + /** + * 支付宝直付通必须无条件物化确认结算阶段,而平台抽佣及查询候选仍只允许开启分账的订单。 + */ + @Test + void alipaySettlementMaterializationShouldBeIndependentFromProfitSharing() { + String sql = mapperXml; + int createStart = sql.indexOf(""); + int settleStart = sql.indexOf(""); + int royaltyStart = sql.indexOf(""); + int royaltyQueryStart = sql.indexOf(""); + int refundQueryStart = sql.indexOf(""); + Assertions.assertAll( + () -> Assertions.assertTrue(sql.substring(createStart, settleStart).contains( + "p.pay_platform_type = '20' OR (p.pay_platform_type = '10' AND p.enable_fz = '1')")), + () -> Assertions.assertFalse(sql.substring(settleStart, royaltyStart).contains("p.enable_fz = '1'")), + () -> Assertions.assertTrue(sql.substring(royaltyStart, royaltyQueryStart).contains("p.enable_fz = '1'")), + () -> Assertions.assertTrue(sql.substring(royaltyQueryStart, refundQueryStart).contains("p.enable_fz = '1'"))); + } + /** * 提取指定 MyBatis 语句片段。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java index 0d2d22e1..f253a14d 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/ThirdPaymentTaskEnqueuerTest.java @@ -92,4 +92,30 @@ class ThirdPaymentTaskEnqueuerTest { () -> Assertions.assertEquals(105, task.getPriority()), () -> Assertions.assertNotNull(task.getNextRunTime())); } + + /** 验证关系绑定任务使用关系主键构造稳定键,并携带关系结构化字段。 */ + @Test + void ensureRelationBindShouldUseStableRelationTaskKeyWithoutRearm() { + enqueuer.ensureRelationBind(9L, 901L, null, 150); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class); + verify(taskMapper).insertIgnore(captor.capture()); + ThirdPaymentTaskEntity task = captor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("FZ_RELATION_BIND:901", task.getTaskKey()), + () -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND.getCode(), task.getTaskType()), + () -> Assertions.assertEquals(901L, task.getFzRelationId()), + () -> Assertions.assertEquals("READY", task.getStatus())); + } + + /** 配置重新保存才允许显式唤醒原退避任务,普通支付流量不调用 rearm。 */ + @Test + void enqueueRelationBindShouldRearmOnlyWhenExplicitlyRequested() { + enqueuer.enqueueRelationBind(9L, 902L, null, 150); + + verify(taskMapper).insertIgnore(org.mockito.ArgumentMatchers.any(ThirdPaymentTaskEntity.class)); + verify(taskMapper).rearmByTaskKey(org.mockito.ArgumentMatchers.eq(9L), + org.mockito.ArgumentMatchers.eq("FZ_RELATION_BIND:902"), + org.mockito.ArgumentMatchers.any()); + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java index 03928341..abc797fe 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateAliPayServiceImplTest.java @@ -16,6 +16,7 @@ import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch; import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import org.junit.jupiter.api.Assertions; @@ -25,11 +26,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -57,6 +60,10 @@ class ThirdAggregateAliPayServiceImplTest { @Mock private ThirdAppPlusAliPayService thirdAppPlusAliPayService; + /** 支付宝聚合子订单关系校验服务 Mock。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** * 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。 */ @@ -87,6 +94,7 @@ class ThirdAggregateAliPayServiceImplTest { appPlusPayConfig.setAliPayConfig(aliPayConfig); service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService, alipayClient); + ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); context = new ThirdAggregatePayCreateContext(); context.setPayNo("AGP202608050001"); @@ -98,6 +106,7 @@ class ThirdAggregateAliPayServiceImplTest { context.setExpireTime(LocalDateTime.now().plusMinutes(2)); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); aliPaySetting.setAppAuthToken("merchant-auth-token"); paySetting = new MchPaySettingVo(); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); @@ -132,6 +141,7 @@ class ThirdAggregateAliPayServiceImplTest { */ @Test void createPayShouldSetJsapiParametersAndSaveUnpaidOrder() throws Exception { + context.setFzRelationId(9001L); AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); response.setCode("10000"); response.setTradeNo("2026000000000002"); @@ -147,6 +157,7 @@ class ThirdAggregateAliPayServiceImplTest { AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); Assertions.assertEquals("JSAPI_PAY", model.getProductCode()); Assertions.assertEquals("12.34", model.getTotalAmount()); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId()); Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO")); @@ -166,7 +177,9 @@ class ThirdAggregateAliPayServiceImplTest { */ @Test void createPayShouldOmitEmptyAppAuthToken() throws Exception { - paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting())); + MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); + aliPaySetting.setSubMchid("smid-1001"); + paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); AlipayTradeCreateResponse response = new AlipayTradeCreateResponse(); response.setCode("10000"); response.setTradeNo("2026000000000003"); @@ -177,7 +190,30 @@ class ThirdAggregateAliPayServiceImplTest { ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class); verify(alipayClient).execute(requestCaptor.capture()); - Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + AlipayTradeCreateRequest request = requestCaptor.getValue(); + AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); + } + + /** + * 编排层携带的关系未绑定时,底层不得向支付宝创建交易。 + * + * @throws Exception Mockito 校验的支付宝 SDK 方法声明受检异常 + */ + @Test + void createPayShouldRejectUnboundCommissionRelation() throws Exception { + context.setFzRelationId(9001L); + when(mchFzRelationService.requirePaymentRelation(9001L, paySetting)) + .thenThrow(new IllegalStateException("支付宝直付通平台分佣关系尚未绑定")); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, + () -> service.createPay(context, paySetting, + new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null))); + + Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定")); + verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); } /** diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java index 23d23279..e34e516f 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAggregateOrderPayServiceImplTest.java @@ -20,6 +20,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; @@ -80,6 +81,10 @@ class ThirdAggregateOrderPayServiceImplTest { @Mock private ThirdPayOrderService thirdPayOrderService; + /** 商户默认平台分佣关系服务 Mock,用于支付平台选定后的前置校验。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** * 钱包商户远程服务 Mock。 */ @@ -138,7 +143,7 @@ class ThirdAggregateOrderPayServiceImplTest { void setUp() { AppPlusPayConfig appPlusPayConfig = completeApplicationConfig(); service = new ThirdAggregateOrderPayServiceImpl(aggregatePaySessionService, aggregateAliPayService, - aggregateWechatPayService, thirdPayOrderService, remoteMchService, + aggregateWechatPayService, thirdPayOrderService, mchFzRelationService, remoteMchService, remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor, pollingCoordinator); lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); @@ -360,6 +365,7 @@ class ThirdAggregateOrderPayServiceImplTest { private MchManageVo completeMerchant() { MchPaySettingEntity.AliPaySetting aliDetail = new MchPaySettingEntity.AliPaySetting(); aliDetail.setAppAuthToken("merchant-auth-token"); + aliDetail.setSubMchid("smid-1001"); MchPaySettingVo ali = new MchPaySettingVo(); ali.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); ali.setEnable(YesNoEnum.YES.getCode()); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java index 4a2ab590..bdf18cea 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusAliPayServiceImplTest.java @@ -11,6 +11,7 @@ import com.alipay.api.domain.AlipayTradePrecreateModel; import com.alipay.api.domain.AlipayTradeRefundModel; import com.alipay.api.domain.AlipayTradeSettleConfirmModel; import com.alipay.api.domain.OpenApiRoyaltyDetailInfoPojo; +import com.alipay.api.domain.RefundRoyaltyResult; import com.alipay.api.domain.RoyaltyDetail; import com.alipay.api.request.AlipayTradeAppPayRequest; import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest; @@ -30,6 +31,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; @@ -38,10 +40,12 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDt import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService; import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -92,6 +96,10 @@ class ThirdAppPlusAliPayServiceImplTest { @Mock private ThirdAggregatePaySessionService aggregatePaySessionService; + /** 支付订单固定分佣关系服务 Mock,用于禁止抽佣和退分账回退当前平台配置。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + private AppPlusPayConfig appPlusPayConfig; private MchPaySettingVo paySetting; @@ -110,18 +118,15 @@ class ThirdAppPlusAliPayServiceImplTest { aliPayConfig.setBaseCallbackUrl("https://pay.example.com"); aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback"); aliPayConfig.setRefundCallbackUrl("/third-app-pay/app/refund-callback"); - aliPayConfig.setRoyaltyTransIn("2088000000000001"); - aliPayConfig.setRoyaltyTransInType("userId"); - aliPayConfig.setRoyaltyTransInName("资安信平台"); appPlusPayConfig.setAliPayConfig(aliPayConfig); MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting(); aliPaySetting.setAppAuthToken("merchant-auth-token"); aliPaySetting.setSubMchid("smid-1001"); paySetting = new MchPaySettingVo(); + paySetting.setId(102L); paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); paySetting.setEnable(YesNoEnum.YES.getCode()); - paySetting.setFzRatio(10D); paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); payDto = new ThirdAppPlusPayDto(); @@ -131,9 +136,12 @@ class ThirdAppPlusAliPayServiceImplTest { payDto.setUserId(-1L); payDto.setAmount(1234); payDto.setMchId(88L); + payDto.setFzRelationId(9001L); service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient); ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService); ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService); + ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); + org.mockito.Mockito.lenient().when(mchFzRelationService.getById(9001L)).thenReturn(boundAlipayRelation()); } /** @@ -189,6 +197,45 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); } + /** 关闭分账的 App 直付通仍必须携带 smid,且订单不得写入分账关系主键。 */ + @Test + void appPayShouldKeepSubMerchantWhenProfitSharingDisabled() throws Exception { + payDto.setFzRelationId(null); + AlipayTradeAppPayResponse response = new AlipayTradeAppPayResponse(); + response.setBody("signed-direct-alipay-order"); + when(alipayClient.sdkExecute(any(AlipayTradeAppPayRequest.class))).thenReturn(response); + + String result = service.appPay(payDto, paySetting); + + Assertions.assertEquals("signed-direct-alipay-order", result); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeAppPayRequest.class); + verify(alipayClient).sdkExecute(requestCaptor.capture()); + AlipayTradeAppPayRequest request = requestCaptor.getValue(); + AlipayTradeAppPayModel model = (AlipayTradeAppPayModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertNotNull(model.getSettleInfo()); + Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).save(orderCaptor.capture()); + Assertions.assertNull(orderCaptor.getValue().getFzRelationId()); + Assertions.assertEquals(YesNoEnum.NO.getCode(), orderCaptor.getValue().getEnableFz()); + } + + /** 非当前默认关系即使已经绑定也不能用于创建新支付,避免内部调用伪造关系主键。 */ + @Test + void appPayShouldRejectBoundButNonDefaultRelation() throws Exception { + ThirdMchFzRelationEntity relation = boundAlipayRelation(); + relation.setIsDefault(YesNoEnum.NO.getCode()); + when(mchFzRelationService.getById(payDto.getFzRelationId())).thenReturn(relation); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.appPay(payDto, paySetting)); + + Assertions.assertTrue(exception.getMessage().contains("默认且已绑定")); + verify(alipayClient, never()).sdkExecute(any(AlipayTradeAppPayRequest.class)); + verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class)); + } + /** * 验证支付宝明确拒绝商户授权时结束直付通分账,不会降级调用普通平台抽佣接口。 */ @@ -246,6 +293,24 @@ class ThirdAppPlusAliPayServiceImplTest { verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class)); } + /** + * 验证固定分佣关系缺失时平台抽佣直接失败,不会回退到当前支付宝平台接收账号。 + */ + @Test + void directPayRoyaltyShouldRejectMissingFixedRelation() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setCreateRes(JSONUtil.createObj().set("settleConfirm", + JSONUtil.createObj().set("status", "SUCCESS")).toString()); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(null); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + String status = service.submitDirectPayRoyaltyStage(fzOrder, payOrder); + + Assertions.assertEquals("FAILED", status); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + } + /** * 验证确认结算遇到系统不确定结果后使用相同 out_request_no 重试。 */ @@ -313,6 +378,67 @@ class ThirdAppPlusAliPayServiceImplTest { verify(thirdFzOrderService).save(result); } + /** + * 关闭平台抽佣的支付宝直付通订单仍必须创建确认结算阶段记录,但不得读取分佣关系或计算抽佣金额。 + */ + @Test + void ensureDirectPayFzOrderShouldCreateSettlementStageWhenProfitSharingDisabled() { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setEnableFz(YesNoEnum.NO.getCode()); + payOrder.setFzRelationId(null); + + ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder); + + Assertions.assertNotNull(result); + Assertions.assertEquals(0, result.getPrice()); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_UNPAID.getType(), result.getStatus()); + verify(thirdFzOrderService).save(result); + verify(mchFzRelationService, never()).getById(any()); + } + + /** + * 关闭平台抽佣后仍调用支付宝确认结算,并释放全部可结算资金;确认成功后不得调用平台抽佣接口。 + * + * @throws Exception 支付宝 SDK Mock 声明的受检异常 + */ + @Test + void directPayLifecycleShouldConfirmSettlementWithoutRoyaltyWhenProfitSharingDisabled() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setEnableFz(YesNoEnum.NO.getCode()); + payOrder.setFzRelationId(null); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setPrice(0); + when(alipayClient.execute(any(AlipayTradeSettleConfirmRequest.class))) + .thenReturn(successSettleConfirmResponse()); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeSettleConfirmRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeSettleConfirmModel model = (AlipayTradeSettleConfirmModel) requestCaptor.getValue().getBizModel(); + Assertions.assertEquals("smid-1001", model.getSettleInfo().getSettleDetailInfos().get(0).getSettleEntityId()); + Assertions.assertEquals("false", model.getExtendParams().getRoyaltyFreeze()); + Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token")); + verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class)); + verify(mchFzRelationService, never()).getById(any()); + } + + /** 低比例向下取整为零时,支付宝分账事实必须按渠道下限保存为一分钱。 */ + @Test + void ensureDirectPayFzOrderShouldUseOneFenMinimumAmount() { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + ThirdMchFzRelationEntity relation = boundAlipayRelation(); + relation.setFzRatio(0.001D); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation); + + ThirdFzOrderEntity result = service.ensureDirectPayFzOrder(payOrder); + + Assertions.assertEquals(1, result.getPrice()); + verify(thirdFzOrderService).save(result); + } + /** * 验证已完成平台抽佣的支付宝订单退款携带按退款金额折算的 refund_royalty_parameters。 */ @@ -348,6 +474,36 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertTrue(fzOrder.getFzRefundReq().contains("2088000000000001")); } + /** 普通支付宝订单退款不查询关系,也不发送退分账参数。 */ + @Test + void refundShouldOmitRoyaltyParametersForOrdinaryPayment() throws Exception { + ThirdPayOrderEntity payOrder = paidDirectPayOrder(); + payOrder.setEnableFz(YesNoEnum.NO.getCode()); + payOrder.setFzRelationId(null); + MchPaySettingVo ordinarySetting = new MchPaySettingVo(); + ordinarySetting.setId(paySetting.getId()); + ordinarySetting.setMchId(payDto.getMchId()); + ordinarySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + ordinarySetting.setEnable(YesNoEnum.YES.getCode()); + payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(ordinarySetting)); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse(); + response.setCode("10000"); + response.setBody("{\"code\":\"10000\"}"); + when(alipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response); + ThirdMiniWechatRefundDto refundDto = new ThirdMiniWechatRefundDto(); + refundDto.setAfterSaleId(78L); + refundDto.setRefundPrice(500); + + service.refund(payOrder, refundDto); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradeRefundRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradeRefundModel model = (AlipayTradeRefundModel) requestCaptor.getValue().getBizModel(); + Assertions.assertNull(model.getRefundRoyaltyParameters()); + verify(mchFzRelationService, never()).getById(any()); + } + /** * 验证退款查询找不到原请求时使用已保存的相同退款号和参数重提,受理后继续等待查询终态。 */ @@ -391,6 +547,85 @@ class ThirdAppPlusAliPayServiceImplTest { verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); } + /** + * 验证支付宝明确返回退款成功时,即使 subCode 为空也保留退款业务事实,不会误触发退款重提。 + */ + @Test + void queryRefundShouldKeepSuccessWhenSubCodeIsBlank() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund-blank-sub-code"); + AlipayTradeFastpayRefundQueryResponse success = new AlipayTradeFastpayRefundQueryResponse(); + success.setCode("10000"); + success.setMsg("Success"); + success.setRefundStatus("REFUND_SUCCESS"); + success.setRefundAmount("1.00"); + success.setSendBackFee("1.00"); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(success); + + Assertions.assertSame(success, service.queryRefund(payOrder)); + verify(alipayClient, never()).execute(any(AlipayTradeRefundRequest.class)); + } + + /** + * 验证支付宝退款查询已成功且返回退分账明细时,即使明细没有 result_code 也必须推进退分账成功。 + */ + @Test + void fzRefundQueryShouldAcceptSuccessfulRoyaltyWithoutResultCode() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_1787732361123EqtlyENhdQ"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUNDING.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setOriTransOut("zaxpay@qq.com"); + royalty.setRefundAmount("0.01"); + royalty.setRoyaltyType("transfer"); + royalty.setTransIn("2088631724809185"); + royalty.setTransOut("2088580178966130"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setMsg("Success"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundAmount("1.00"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + ThirdFzOrderEntity update = updateCaptor.getValue(); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), update.getStatus()); + Assertions.assertNull(update.getFzRefundError()); + Assertions.assertTrue(update.getFzRefundRes().contains("REFUND_SUCCESS")); + Assertions.assertTrue(update.getFzRefundRes().contains("0.01")); + } + + /** + * 验证人工重试历史误判记录时,可以通过支付宝成功事实把本地退分账失败纠正为成功。 + */ + @Test + void fzRefundQueryShouldCorrectHistoricalFalseFailure() throws Exception { + ThirdPayOrderEntity payOrder = refundingOrderWithRequest("refund_historical_false_failure"); + ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder); + fzOrder.setStatus(ThirdFzOrderStatusEnum.FZ_REFUND_FAILED.getType()); + fzOrder.setFzRefundNo(payOrder.getRefundNo()); + RefundRoyaltyResult royalty = new RefundRoyaltyResult(); + royalty.setRefundAmount("0.01"); + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse(); + response.setCode("10000"); + response.setRefundStatus("REFUND_SUCCESS"); + response.setRefundRoyaltys(List.of(royalty)); + when(alipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response); + when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true); + + service.pollDirectPayLifecycle(fzOrder, payOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdFzOrderEntity.class); + verify(thirdFzOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdFzOrderStatusEnum.FZ_REFUND_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertNull(updateCaptor.getValue().getFzRefundError()); + } + @Test void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception { AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content"); @@ -406,6 +641,7 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertEquals("QR_CODE_OFFLINE", model.getProductCode()); Assertions.assertEquals("12.34", model.getTotalAmount()); Assertions.assertEquals("资安信消费支付", model.getSubject()); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); Assertions.assertEquals(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN), model.getTimeExpire()); Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token")); Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl()); @@ -421,6 +657,27 @@ class ThirdAppPlusAliPayServiceImplTest { Assertions.assertTrue(savedOrder.getPayRes().contains("qrCode")); } + /** + * 验证关闭分账的订单码预创建仍携带直付通 smid,但不发送平台抽佣授权令牌。 + * + * @throws Exception Mock 支付宝客户端声明的受检异常 + */ + @Test + void precreateShouldKeepSubMerchantWhenProfitSharingDisabled() throws Exception { + payDto.setFzRelationId(null); + AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/no-profit-sharing"); + when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(response); + + service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2)); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(AlipayTradePrecreateRequest.class); + verify(alipayClient).execute(requestCaptor.capture()); + AlipayTradePrecreateRequest request = requestCaptor.getValue(); + AlipayTradePrecreateModel model = (AlipayTradePrecreateModel) request.getBizModel(); + Assertions.assertEquals("smid-1001", model.getSubMerchant().getMerchantId()); + Assertions.assertFalse(request.getTextParams().containsKey("app_auth_token")); + } + @Test void precreateShouldSaveFailedOrderWhenAlipayRejectsRequest() throws Exception { AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse(); @@ -588,10 +845,35 @@ class ThirdAppPlusAliPayServiceImplTest { order.setThirdPayNo("202600000001"); order.setPayTime(LocalDateTime.now().minusMinutes(1)); order.setEnableFz(YesNoEnum.YES.getCode()); + order.setFzRelationId(payDto.getFzRelationId()); order.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting)); return order; } + /** + * 构造与测试订单、平台账号和二级商户完全匹配的已绑定支付宝关系。 + * + * @return 可供历史订单抽佣和退分账使用的固定关系快照 + */ + private ThirdMchFzRelationEntity boundAlipayRelation() { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(payDto.getFzRelationId()); + relation.setMchId(payDto.getMchId()); + relation.setPaySettingId(paySetting.getId()); + relation.setPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + relation.setChannelAppId(appPlusPayConfig.getAliPayConfig().getAppId()); + relation.setChannelSubMchId("smid-1001"); + relation.setReceiverType("userId"); + relation.setReceiverAccount("2088000000000001"); + relation.setReceiverName("资安信平台"); + relation.setFzRatio(10D); + relation.setBoundTime(LocalDateTime.now().minusDays(1)); + relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); + relation.setIsDefault(YesNoEnum.YES.getCode()); + relation.setDelFlag("0"); + return relation; + } + /** * 构造包含稳定退款号和原始退款请求快照的退款中订单。 * diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java index e7ec734d..1dc46651 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusServiceImplOrderCodePayTest.java @@ -2,12 +2,14 @@ package com.bsd.pjcloud.thirdparty.service.impl.appplus; import cn.hutool.json.JSONUtil; import com.alipay.api.response.AlipayTradeCancelResponse; +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse; import com.alipay.api.response.AlipayTradeQueryResponse; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.admin.api.feign.RemoteTokenService; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.app.api.feign.RemoteAppUserService; +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; @@ -21,16 +23,19 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.service.*; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; +import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargePackageService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService; import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.WalletManageVo; import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -42,6 +47,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.List; @@ -76,6 +82,10 @@ class ThirdAppPlusServiceImplOrderCodePayTest { @Mock private ThirdPayOrderService thirdPayOrderService; + /** 商户默认平台分佣关系服务 Mock,用于订单码创建前校验渠道绑定状态。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** * 聚合支付服务 Mock,用于验证旧原生订单码创建前执行跨模式互斥检查。 */ @@ -130,8 +140,15 @@ class ThirdAppPlusServiceImplOrderCodePayTest { @BeforeEach void setUp() { + ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true); + ThirdMchFzRelationEntity boundRelation = new ThirdMchFzRelationEntity(); + boundRelation.setId(9001L); + boundRelation.setChannelSubMchId("channel-sub-merchant-1001"); + lenient().when(mchFzRelationService.findBoundDefault(any())).thenReturn(boundRelation); + lenient().when(mchFzRelationService.createPaymentSettingSnapshot(any(), any())) + .thenAnswer(invocation -> invocation.getArgument(0)); AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig(); aliPayConfig.setAppId("2026000000000001"); @@ -177,7 +194,7 @@ class ThirdAppPlusServiceImplOrderCodePayTest { } @Test - void orderCodePayShouldRejectMissingMerchantAndAliPayConfiguration() { + void orderCodePayShouldRejectMissingMerchant() { mockNoExistingOrder(); when(remoteMchService.getById(88L)).thenReturn(R.ok()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); @@ -186,10 +203,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest { merchant.setPaySettingList(List.of()); Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); - merchant = enabledAliPayMerchant(); - merchant.getPaySettingList().get(0).setSettingJson("{}"); - when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); - Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); } @Test @@ -216,6 +229,85 @@ class ThirdAppPlusServiceImplOrderCodePayTest { verify(pollingCoordinator).wakeOrderCodePolling(); } + /** 未配置默认关系时无法取得可信 smid,支付宝直付通订单码必须在渠道调用前拒付。 */ + @Test + void orderCodePayShouldRejectAlipayOrderWithoutDefaultRelation() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(null); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.orderCodePay(request)); + + Assertions.assertTrue(exception.getMessage().contains("二级商户关系")); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + verify(mchFzRelationService, never()).createPaymentSettingSnapshot(any(), any()); + } + + /** 关闭分账的支付宝默认关系仍提供 smid,但订单不得携带分账关系主键。 */ + @Test + void orderCodePayShouldUseAlipayRoutingRelationWithoutProfitSharing() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdMchFzRelationEntity routingRelation = new ThirdMchFzRelationEntity(); + routingRelation.setId(9003L); + routingRelation.setChannelSubMchId("smid-routing-1001"); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(routingRelation); + ThirdOrderCodePayVo expected = orderCodePayVo("alipay-routing-pay-no", "alipay-routing-qr", + LocalDateTime.now().plusMinutes(2)); + when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + Assertions.assertEquals("smid-routing-1001", payDtoCaptor.getValue().getSubMchid()); + verify(mchFzRelationService).createPaymentSettingSnapshot(any(), + org.mockito.ArgumentMatchers.eq(routingRelation)); + } + + /** 关闭分账的微信默认关系仍提供sub_mchid,但订单不得携带分账关系主键。 */ + @Test + void orderCodePayShouldUseWechatRoutingRelationWithoutProfitSharing() { + request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); + merchant = enabledWechatMerchant(); + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + ThirdMchFzRelationEntity routingRelation = new ThirdMchFzRelationEntity(); + routingRelation.setId(9002L); + routingRelation.setChannelSubMchId("wx-routing-sub-1001"); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + when(mchFzRelationService.findBoundDefault(any())).thenReturn(routingRelation); + ThirdOrderCodePayVo expected = orderCodePayVo("wechat-routing-pay-no", "wechat-routing-qr", LocalDateTime.now().plusMinutes(2)); + when(thirdAppPlusWechatNativePayService.precreate(any(), any(), any())).thenReturn(expected); + + ThirdOrderCodePayVo result = service.orderCodePay(request); + + Assertions.assertSame(expected, result); + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdAppPlusWechatNativePayService).precreate(payDtoCaptor.capture(), any(), any()); + Assertions.assertNull(payDtoCaptor.getValue().getFzRelationId()); + Assertions.assertEquals("wx-routing-sub-1001", payDtoCaptor.getValue().getSubMchid()); + verify(mchFzRelationService).createPaymentSettingSnapshot(any(), org.mockito.ArgumentMatchers.eq(routingRelation)); + } + + /** 支付宝订单码启用平台抽佣但关系未绑定时必须拒付,不能继续调用预下单接口。 */ + @Test + void orderCodePayShouldRejectCommissionWhenRelationNotBound() { + mockNoExistingOrder(); + when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant)); + when(mchFzRelationService.findBoundDefault(any())).thenThrow( + new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试")); + + RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request)); + + Assertions.assertTrue(exception.getMessage().contains("尚未绑定")); + verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any()); + } + @Test void orderCodePayShouldCreateWechatNativeOrder() { request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT); @@ -574,6 +666,48 @@ class ThirdAppPlusServiceImplOrderCodePayTest { Assertions.assertEquals(hardOrder.getPayPrice(), payDtoCaptor.getValue().getAmount()); } + /** + * 新建且未绑定实体饭卡的钱包即使未写入绑定状态仍可接收余额充值,支付创建阶段不得再次要求卡号或激活状态。 + */ + @Test + void guardianWardMiniPayShouldAllowUnboundInactiveCard() { + ThirdMiniRemotePayDto payRequest = new ThirdMiniRemotePayDto(); + payRequest.setUserId(10L); + payRequest.setBizId("8001"); + payRequest.setBizType(ThirdPayOrderBizTypeEnum.GUARDIAN_WARD_WALLET_RECHARGE); + payRequest.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY); + + WalletRechargeEntity recharge = new WalletRechargeEntity(); + recharge.setId(8001L); + recharge.setWalletId(20L); + recharge.setPackageId(30L); + recharge.setPayStatus(false); + recharge.setPayPrice(1000); + WalletManageVo wallet = new WalletManageVo(); + wallet.setId(20L); + wallet.setUserId(11L); + wallet.setDeptId(12L); + wallet.setCardNumber(null); + wallet.setStatus(null); + wallet.setIsFreeze(YesNoEnum.NO.getCode()); + MchManageVo targetMerchant = enabledAliPayMerchant(); + + when(remoteWalletRechargeService.getRechargeById(8001L)).thenReturn(R.ok(recharge)); + when(remoteWalletService.getWalletById(20L)).thenReturn(R.ok(wallet)); + when(remoteUserRelationService.listActiveGuardianWardUserIds(10L)).thenReturn(R.ok(List.of(11L))); + when(remoteMchService.getViewsMchByDeptId(12L, + List.of(MerchantTypeEnum.FARMER, MerchantTypeEnum.CANTEEN))) + .thenReturn(R.ok(targetMerchant)); + when(thirdMiniAppAliPayService.appPay(any(), any())).thenReturn("signed-order"); + + service.miniPay(payRequest.getUserId(), payRequest); + + ArgumentCaptor payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class); + verify(thirdMiniAppAliPayService).appPay(payDtoCaptor.capture(), any()); + Assertions.assertEquals(1000, payDtoCaptor.getValue().getAmount()); + Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId()); + } + /** * 验证微信退款 PROCESSING 不提前推进本地订单终态。 */ @@ -621,6 +755,34 @@ class ThirdAppPlusServiceImplOrderCodePayTest { Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_FAILED.getType(), updateCaptor.getAllValues().get(2).getStatus()); } + /** + * 验证支付宝退款成功以 refundStatus 为准,subCode 为空不会阻止订单落库和响应快照回填。 + */ + @Test + void queryRefundResultShouldMapAlipaySuccessWhenSubCodeIsBlank() { + ThirdPayOrderEntity refundingOrder = refundingOrder(205L, "refund-alipay-success"); + refundingOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + AlipayTradeFastpayRefundQueryResponse query = new AlipayTradeFastpayRefundQueryResponse(); + query.setCode("10000"); + query.setMsg("Success"); + query.setRefundStatus("REFUND_SUCCESS"); + query.setRefundAmount("1.00"); + when(thirdMiniAppAliPayService.queryRefund(refundingOrder)).thenReturn(query); + when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true); + ThirdPayOrderEntity latest = refundingOrder(205L, "refund-alipay-success"); + latest.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType()); + latest.setStatus(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType()); + latest.setIsBizCallback(YesNoEnum.YES.getCode()); + when(thirdPayOrderService.getById(205L)).thenReturn(latest); + + service.queryRefundResult(refundingOrder); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class); + verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class)); + Assertions.assertEquals(ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType(), updateCaptor.getValue().getStatus()); + Assertions.assertTrue(updateCaptor.getValue().getRefundCallbackRes().contains("REFUND_SUCCESS")); + } + private void mockNoExistingOrder() { when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L); when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null); @@ -633,7 +795,6 @@ class ThirdAppPlusServiceImplOrderCodePayTest { MchPaySettingVo setting = new MchPaySettingVo(); setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); setting.setEnable(YesNoEnum.YES.getCode()); - setting.setFzRatio(10D); setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting)); MchManageVo result = new MchManageVo(); result.setId(88L); diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java index ebb8df38..87a1dccb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatFzServiceImplTest.java @@ -5,11 +5,14 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper; import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.ThirdAppPlusWechatFzServiceImpl; @@ -17,6 +20,7 @@ import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; import com.wechat.pay.java.service.profitsharing.ProfitsharingService; import com.wechat.pay.java.service.profitsharing.model.OrderStatus; import com.wechat.pay.java.service.profitsharing.model.OrdersEntity; +import com.wechat.pay.java.service.profitsharing.model.CreateOrderRequest; import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest; import com.wechat.pay.java.service.profitsharing.model.QueryReturnOrderRequest; import com.wechat.pay.java.service.profitsharing.model.ReturnOrderStatus; @@ -29,8 +33,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.time.LocalDateTime; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -71,6 +77,10 @@ class ThirdAppPlusWechatFzServiceImplTest { @Mock private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer; + /** 支付订单固定分账关系服务 Mock,用于验证分账接收方不跟随当前配置漂移。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** 被测微信分账服务。 */ @InjectMocks private ThirdAppPlusWechatFzServiceImpl service; @@ -89,9 +99,66 @@ class ThirdAppPlusWechatFzServiceImplTest { setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); payOrder = new ThirdPayOrderEntity(); payOrder.setId(1L); + payOrder.setTenantId(1L); + payOrder.setMchId(88L); payOrder.setPayNo("pay-1001"); payOrder.setThirdPayNo("wx-transaction-1001"); + payOrder.setPrice(200); + payOrder.setStatus(com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + payOrder.setEnableFz(YesNoEnum.YES.getCode()); + payOrder.setFzRelationId(9001L); payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting)); + ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService); + } + + /** + * 验证微信分账请求使用支付创建时固定关系的接收商户,而不是当前服务商配置的默认值。 + */ + @Test + void createShouldUseFixedRelationReceiver() { + AppPlusPayConfig.WechatConfig config = wechatConfig(); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(boundWechatRelation()); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(profitsharingService.createOrder(any(CreateOrderRequest.class))).thenReturn(response); + + service.create(payOrder); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CreateOrderRequest.class); + verify(profitsharingService).createOrder(requestCaptor.capture()); + Assertions.assertEquals("1900000002", requestCaptor.getValue().getReceivers().get(0).getAccount()); + } + + /** 低比例向下取整为零时,微信分账请求必须按渠道下限提交一分钱。 */ + @Test + void createShouldUseOneFenMinimumAmount() { + payOrder.setPrice(11); + ThirdMchFzRelationEntity relation = boundWechatRelation(); + relation.setFzRatio(0.001D); + when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig()); + when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(relation); + OrdersEntity response = new OrdersEntity(); + response.setState(OrderStatus.PROCESSING); + when(profitsharingService.createOrder(any(CreateOrderRequest.class))).thenReturn(response); + + service.create(payOrder); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CreateOrderRequest.class); + verify(profitsharingService).createOrder(requestCaptor.capture()); + Assertions.assertEquals(1L, requestCaptor.getValue().getReceivers().get(0).getAmount()); + } + + /** + * 验证固定关系缺失时微信分账拒绝调用渠道,不会回退到当前服务商商户号。 + */ + @Test + void createShouldRejectMissingFixedRelation() { + when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig()); + + Assertions.assertThrows(IllegalStateException.class, () -> service.create(payOrder)); + + verify(profitsharingService, never()).createOrder(any(CreateOrderRequest.class)); } /** @@ -196,4 +263,37 @@ class ThirdAppPlusWechatFzServiceImplTest { response.setOutReturnNo("fz-refund-pay-1001"); return response; } + + /** + * 构造微信服务商调用配置,接收账号故意与服务商号不同以验证关系快照优先级。 + * + * @return 测试用微信平台配置 + */ + private AppPlusPayConfig.WechatConfig wechatConfig() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-1001"); + config.setMerchantId("1900000001"); + return config; + } + + /** + * 构造与支付订单完全匹配且已绑定的微信分账关系。 + * + * @return 固定平台分佣关系快照 + */ + private ThirdMchFzRelationEntity boundWechatRelation() { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(payOrder.getFzRelationId()); + relation.setMchId(payOrder.getMchId()); + relation.setPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType()); + relation.setChannelAppId("wx-app-1001"); + relation.setChannelSubMchId("wx-sub-1001"); + relation.setReceiverType("MERCHANT_ID"); + relation.setReceiverAccount("1900000002"); + relation.setReceiverName("平台分佣接收方"); + relation.setFzRatio(10D); + relation.setBoundTime(LocalDateTime.now().minusDays(1)); + relation.setDelFlag("0"); + return relation; + } } diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java index 59ae18b4..9a6d0b26 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplPayCallbackTest.java @@ -11,6 +11,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -67,6 +68,10 @@ class ThirdAppPlusWechatServiceImplPayCallbackTest { @Mock private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + /** 分账关系服务 Mock,仅补齐微信支付服务的安全前置依赖。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** * 聚合支付会话服务 Mock,用于严格区分旧 Native 和新 JSAPI 子订单回调。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java index 379dd4bb..baffd061 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/appplus/ThirdAppPlusWechatServiceImplRefundTest.java @@ -8,8 +8,10 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto; import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService; +import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService; import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle; import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.ThirdMiniWechatRefundQueryVo; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -76,6 +78,10 @@ class ThirdAppPlusWechatServiceImplRefundTest { @Mock private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle; + /** 分账关系服务 Mock,用于验证关系表中的非法比例会在微信预下单前被拒绝。 */ + @Mock + private ThirdMchFzRelationService mchFzRelationService; + /** 被测微信 App 支付服务。 */ @InjectMocks private ThirdAppPlusWechatServiceImpl service; @@ -145,19 +151,21 @@ class ThirdAppPlusWechatServiceImplRefundTest { } /** - * 验证非法微信分账比例在创建渠道支付前被拒绝,不会留下无法完成抽佣的已支付交易。 + * 验证关系服务判定抽佣比例非法时在渠道支付前拒绝,不会留下无法完成抽佣的已支付交易。 */ @Test void appPayShouldRejectInvalidProfitSharingRatioBeforeCreatingOrder() { MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); wechatPaySetting.setSubMchid("wx-sub-1001"); MchPaySettingVo setting = new MchPaySettingVo(); - setting.setFzRatio(101D); setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); dto.setAmount(1000); dto.setOutTradeNo("pay-invalid-ratio"); dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setFzRelationId(9001L); + when(mchFzRelationService.requirePaymentRelation(9001L, setting)) + .thenThrow(new IllegalStateException("微信平台抽佣比例必须大于0且不超过100%")); RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.appPay(dto, setting)); @@ -167,6 +175,38 @@ class ThirdAppPlusWechatServiceImplRefundTest { verify(thirdPayOrderService, org.mockito.Mockito.never()).save(any(ThirdPayOrderEntity.class)); } + /** 低比例计算结果不足一分钱时,微信预下单校验应采用一分钱下限而不是拒绝支付。 */ + @Test + void appPayShouldAcceptRatioWhoseCalculatedAmountIsBelowOneFen() { + AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig(); + config.setAppPlatformAppId("wx-app-id"); + config.setMerchantId("wx-service-mch"); + config.setBaseCallbackUrl("https://pay.example.com"); + config.setPayCallbackUrl("/pay-callback"); + when(appPlusPayConfig.getWechatConfig()).thenReturn(config); + PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse(); + response.setPrepayId("prepay-id"); + when(appServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class), any())).thenReturn(response); + MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting(); + wechatPaySetting.setSubMchid("wx-sub-1001"); + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting)); + ThirdAppPlusPayDto dto = new ThirdAppPlusPayDto(); + dto.setAmount(11); + dto.setOutTradeNo("pay-minimum-fz-amount"); + dto.setBizId("order-minimum-fz-amount"); + dto.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY); + dto.setMchId(88L); + dto.setFzRelationId(9001L); + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setFzRatio(0.001D); + when(mchFzRelationService.requirePaymentRelation(9001L, setting)).thenReturn(relation); + + service.appPay(dto, setting); + + verify(appServiceExtension).prepayWithRequestPayment(any(PrepayRequest.class), any()); + } + /** * 验证微信 App 预下单把本地保存的十五分钟截止时间同步传给渠道,避免本地和微信订单生命周期分叉。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java new file mode 100644 index 00000000..ed5f314d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdMchFzRelationServiceImplTest.java @@ -0,0 +1,822 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import cn.hutool.json.JSONUtil; +import com.alipay.api.AlipayClient; +import com.alipay.api.domain.AlipayTradeRoyaltyRelationBindModel; +import com.alipay.api.domain.AntMerchantExpandIndirectZftModifyModel; +import com.alipay.api.domain.RoyaltyEntity; +import com.alipay.api.request.AlipayTradeRoyaltyRelationBatchqueryRequest; +import com.alipay.api.request.AlipayTradeRoyaltyRelationBindRequest; +import com.alipay.api.request.AntMerchantExpandIndirectImageUploadRequest; +import com.alipay.api.request.AntMerchantExpandIndirectZftModifyRequest; +import com.alipay.api.response.AlipayTradeRoyaltyRelationBatchqueryResponse; +import com.alipay.api.response.AlipayTradeRoyaltyRelationBindResponse; +import com.alipay.api.response.AntMerchantExpandIndirectImageUploadResponse; +import com.alipay.api.response.AntMerchantExpandIndirectZftModifyResponse; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.tenant.TenantContextHolder; +import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig; +import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationStatusEnum; +import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdMchFzRelationSaveDto; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdAlipayAliasModifyDto; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper; +import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer; +import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo; +import com.wechat.pay.java.service.profitsharing.ProfitsharingService; +import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse; +import com.wechat.pay.java.service.profitsharing.model.ReceiverRelationType; +import com.wechat.pay.java.service.profitsharing.model.ReceiverType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.apache.ibatis.builder.MapperBuilderAssistant; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 商户微信、支付宝共用默认平台分佣关系测试。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdMchFzRelationServiceImplTest { + + /** 关系事实 Mapper Mock,用于捕获关系快照并模拟唯一键竞争。 */ + @Mock + private ThirdMchFzRelationMapper relationMapper; + + /** 关系任务写入器 Mock,用于区分配置唤醒与支付流量确保任务。 */ + @Mock + private ThirdPaymentTaskEnqueuer taskEnqueuer; + + /** 微信分账 SDK Mock,不发起真实渠道请求。 */ + @Mock + private ProfitsharingService profitsharingService; + + /** 商户内部接口 Mock,用于支付宝 Worker 读取当前授权配置。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 支付宝 SDK 客户端 Mock,用于验证网关成功与业务结果码的组合语义。 */ + @Mock + private AlipayClient alipayClient; + + /** 被测统一分账关系服务。 */ + private ThirdMchFzRelationServiceImpl service; + + /** 测试租户ID,作为关系唯一键和任务隔离边界。 */ + private static final Long TENANT_ID = 9L; + + /** 测试商户ID,对应 mch_manage 主键。 */ + private static final Long MCH_ID = 88L; + + /** 初始化平台默认接收账号与被测服务。 */ + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + ThirdMchFzRelationEntity.class); + AppPlusPayConfig config = new AppPlusPayConfig(); + AppPlusPayConfig.WechatConfig wechat = new AppPlusPayConfig.WechatConfig(); + wechat.setAppPlatformAppId("wx-app-1001"); + wechat.setMerchantId("1900000001"); + wechat.setProfitSharingReceiverAccount("1900000002"); + wechat.setProfitSharingReceiverName("平台服务商"); + config.setWechatConfig(wechat); + + AppPlusPayConfig.AliPayConfig alipay = new AppPlusPayConfig.AliPayConfig(); + alipay.setAppId("2026000000000001"); + alipay.setAppPrivateKey("test-private-key"); + alipay.setAlipayPublicKey("test-public-key"); + config.setAliPayConfig(alipay); + service = new TestableThirdMchFzRelationServiceImpl(relationMapper, taskEnqueuer, + profitsharingService, config, remoteMchService, alipayClient); + TenantContextHolder.setTenantId(TENANT_ID); + } + + /** 清理测试线程租户,防止同进程其他用例继承本类上下文。 */ + @AfterEach + void tearDown() { + TenantContextHolder.clear(); + } + + /** 支付配置同步只处理关系生命周期,不再从支付配置 JSON 自动创建渠道关系。 */ + @Test + void syncDefaultRelationsShouldNotCreateRelationsFromSettingJson() { + MchPaySettingVo wechat = wechatSetting(101L, 3D); + MchPaySettingVo alipay = alipaySetting(102L, 5D, "sensitive-auth-token"); + + service.syncDefaultRelations(merchant(List.of(wechat, alipay))); + + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 未配置默认关系时关系查询返回空值,具体支付渠道再按二级商户路由要求决定是否拒付。 */ + @Test + void findBoundDefaultShouldReturnNullWhenCommissionIsNotConfigured() { + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertNull(result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 默认关系关闭分账时只提供渠道商户路由,不要求正比例或已完成接收方绑定。 */ + @Test + void findBoundDefaultShouldReturnDisabledRoutingRelationWithoutBinding() { + ThirdMchFzRelationEntity routingRelation = pendingWechatRelation(7001L); + routingRelation.setIsDefault(YesNoEnum.YES.getCode()); + routingRelation.setEnableFz(YesNoEnum.NO.getCode()); + routingRelation.setFzRatio(0D); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(routingRelation); + + ThirdMchFzRelationEntity result = service.findBoundDefault(wechatSetting(101L, 3D)); + + Assertions.assertSame(routingRelation, result); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 关闭分账的支付宝关系仍复制直付通 smid,但不复制平台抽佣专属账号和授权令牌。 */ + @Test + void createPaymentSettingSnapshotShouldKeepAlipaySmidWhenProfitSharingDisabled() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7000L); + relation.setEnableFz(YesNoEnum.NO.getCode()); + + MchPaySettingVo snapshot = service.createPaymentSettingSnapshot(setting, relation); + + MchPaySettingEntity.AliPaySetting channel = JSONUtil.toBean( + snapshot.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); + Assertions.assertAll( + () -> Assertions.assertEquals(relation.getChannelSubMchId(), channel.getSubMchid()), + () -> Assertions.assertNull(channel.getAccountKey()), + () -> Assertions.assertNull(channel.getAppAuthToken())); + } + + /** 正比例默认关系尚未绑定时支付入口必须拒绝,并只确保稳定任务存在而不重置既有退避。 */ + @Test + void requireBoundShouldRejectAndEnsureTaskWithoutRearm() { + ThirdMchFzRelationEntity pending = pendingWechatRelation(7005L); + pending.setIsDefault(YesNoEnum.YES.getCode()); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(pending); + + IllegalStateException exception = Assertions.assertThrows(IllegalStateException.class, + () -> service.requireBound(wechatSetting(101L, 3D))); + + Assertions.assertTrue(exception.getMessage().contains("尚未绑定")); + verify(taskEnqueuer).ensureRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(pending.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 零比例关系不满足支付前置条件,不能绕过关系表抽佣规则继续下单。 */ + @Test + void requireBoundShouldRejectWhenDefaultRelationRatioIsZero() { + ThirdMchFzRelationEntity disabled = pendingWechatRelation(7004L); + disabled.setFzRatio(0D); + disabled.setIsDefault(YesNoEnum.YES.getCode()); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(disabled); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.requireBound(wechatSetting(101L, 3D))); + + Assertions.assertTrue(exception.getMessage().contains("大于0")); + verify(taskEnqueuer, never()).ensureRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 微信返回其他接收方时不能把本地关系标记为已绑定。 */ + @Test + void bindWechatShouldRejectMismatchedResponse() { + ThirdMchFzRelationEntity pending = pendingWechatRelation(7002L); + ThirdMchFzRelationEntity binding = pendingWechatRelation(7002L); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7002L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + AddReceiverResponse response = new AddReceiverResponse(); + response.setSubMchid(binding.getChannelSubMchId()); + response.setType(ReceiverType.MERCHANT_ID); + response.setAccount("different-receiver"); + response.setRelationType(ReceiverRelationType.SERVICE_PROVIDER); + when(profitsharingService.addReceiver(any())).thenReturn(response); + + Assertions.assertFalse(service.bind(7002L)); + verify(relationMapper, times(2)).update(any(), any()); + } + + /** 支付宝网关成功但业务 result_code 非 SUCCESS 时不得把关系误记为已绑定。 */ + @Test + void bindAlipayShouldRejectNonSuccessBusinessResult() throws Exception { + ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L); + ThirdMchFzRelationEntity binding = pendingAlipayRelation(7003L); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7003L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + AlipayTradeRoyaltyRelationBindResponse bindResponse = new AlipayTradeRoyaltyRelationBindResponse(); + bindResponse.setCode("10000"); + bindResponse.setResultCode("FAIL"); + when(alipayClient.execute(any(AlipayTradeRoyaltyRelationBindRequest.class))).thenReturn(bindResponse); + AlipayTradeRoyaltyRelationBatchqueryResponse queryResponse = new AlipayTradeRoyaltyRelationBatchqueryResponse(); + queryResponse.setCode("10000"); + queryResponse.setResultCode("FAIL"); + when(alipayClient.execute(any(AlipayTradeRoyaltyRelationBatchqueryRequest.class))).thenReturn(queryResponse); + + Assertions.assertFalse(service.bind(7003L)); + verify(relationMapper, times(2)).update(any(), any()); + } + + /** 支付宝 openId 关系必须写入 account_open_id,不能错误写入 account 或 login_name。 */ + @Test + void bindAlipayShouldMapOpenIdToAccountOpenId() throws Exception { + RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("openId", "alipay-open-id-1001"); + + Assertions.assertAll( + () -> Assertions.assertEquals("openId", receiver.getType()), + () -> Assertions.assertEquals("alipay-open-id-1001", receiver.getAccountOpenId()), + () -> Assertions.assertNull(receiver.getAccount()), + () -> Assertions.assertNull(receiver.getLoginName())); + } + + /** 支付宝卡编号关系使用 account 承载 cardAliasNo,类型字段明确区分于 userId。 */ + @Test + void bindAlipayShouldMapCardAliasNoToAccount() throws Exception { + RoyaltyEntity receiver = bindAlipayAndCaptureReceiver("cardAliasNo", "card-alias-1001"); + + Assertions.assertAll( + () -> Assertions.assertEquals("cardAliasNo", receiver.getType()), + () -> Assertions.assertEquals("card-alias-1001", receiver.getAccount()), + () -> Assertions.assertNull(receiver.getAccountOpenId()), + () -> Assertions.assertNull(receiver.getLoginName())); + } + + /** 支付宝商户简称修改提交 smid、alias_name 和完整服务列表,并透传关系内的代调用授权 token。 */ + @Test + void modifyAlipayAliasShouldSubmitSmidAndAliasName() throws Exception { + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7016L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(alipaySetting(102L, 5D, + "legacy-setting-token"))))); + AntMerchantExpandIndirectZftModifyResponse response = new AntMerchantExpandIndirectZftModifyResponse(); + response.setCode("10000"); + response.setOrderId("zft-order-7016"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AntMerchantExpandIndirectZftModifyRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setTradeScene(List.of("TINY_APP", "OFFLINE")); + + var result = service.modifyAlipayAlias(relation.getId(), dto); + + AntMerchantExpandIndirectZftModifyRequest request = requestCaptor.getValue(); + AntMerchantExpandIndirectZftModifyModel model = + (AntMerchantExpandIndirectZftModifyModel) request.getBizModel(); + Assertions.assertAll( + () -> Assertions.assertEquals("zft-order-7016", result.getOrderId()), + () -> Assertions.assertEquals("smid-1001", model.getSmid()), + () -> Assertions.assertEquals("青鸟同文", model.getAliasName()), + () -> Assertions.assertEquals("青鸟同文", model.getName()), + () -> Assertions.assertEquals("01", model.getMerchantType()), + () -> Assertions.assertEquals(List.of("当面付", "app支付"), model.getService()), + () -> Assertions.assertEquals(List.of("TINY_APP", "OFFLINE"), model.getTradeScene()), + () -> Assertions.assertEquals("5812", model.getMcc()), + () -> Assertions.assertEquals("13800000000", model.getServicePhone()), + () -> Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"))); + } + + /** 支付宝线上会强制校验商户服务,服务列表为空时应在本地直接拒绝。 */ + @Test + void modifyAlipayAliasShouldRejectEmptyServices() throws Exception { + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7018L, fullAlipayModifyDtoWithServices(List.of()))); + + Assertions.assertTrue(exception.getMessage().contains("已开通服务不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 商户名称是全量资料的核心字段,应在调用支付宝之前返回明确错误。 */ + @Test + void modifyAlipayAliasShouldRejectEmptyMerchantName() throws Exception { + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setName(" "); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7019L, dto)); + + Assertions.assertTrue(exception.getMessage().contains("商户名称不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 当面付缺少门头照时不提交,避免支付宝按字段逐个报错。 */ + @Test + void modifyAlipayAliasShouldRejectMissingFaceToFaceImages() throws Exception { + ThirdAlipayAliasModifyDto dto = fullAlipayModifyDto(); + dto.setOutDoorImages(List.of()); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(7020L, dto)); + + Assertions.assertTrue(exception.getMessage().contains("门头照不能为空")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 商户材料图片上传必须透传关系授权,并返回支付宝 image_id。 */ + @Test + void uploadAlipayMerchantImageShouldReturnImageId() throws Exception { + ThirdMchFzRelationEntity relation = pendingAlipayRelation(7021L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + AntMerchantExpandIndirectImageUploadResponse response = new AntMerchantExpandIndirectImageUploadResponse(); + response.setCode("10000"); + response.setImageId("A*merchant-image-id"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AntMerchantExpandIndirectImageUploadRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + String imageId = service.uploadAlipayMerchantImage(relation.getId(), "license.PNG", + new byte[]{1, 2, 3}, "image/png"); + + AntMerchantExpandIndirectImageUploadRequest request = requestCaptor.getValue(); + Assertions.assertAll( + () -> Assertions.assertEquals("A*merchant-image-id", imageId), + () -> Assertions.assertEquals("png", request.getImageType()), + () -> Assertions.assertArrayEquals(new byte[]{1, 2, 3}, request.getImageContent().getContent()), + () -> Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"))); + } + + /** 非支付宝关系不能调用二级商户资料修改接口。 */ + @Test + void modifyAlipayAliasShouldRejectNonAlipayRelation() throws Exception { + ThirdMchFzRelationEntity relation = pendingWechatRelation(7017L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(relation); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.modifyAlipayAlias(relation.getId(), fullAlipayModifyDtoWithServices(List.of("app支付")))); + + Assertions.assertTrue(exception.getMessage().contains("只有支付宝")); + verify(alipayClient, never()).execute(any(AntMerchantExpandIndirectZftModifyRequest.class)); + } + + /** 修改接收方账号必须创建新关系版本,旧关系仅退出默认候选并保留给历史订单。 */ + @Test + void saveForManageShouldVersionRelationWhenReceiverAccountChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7006L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverAccount("1900000099"); + + var result = service.saveForManage(dto); + + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + verify(relationMapper).insert(relationCaptor.capture()); + ThirdMchFzRelationEntity replacement = relationCaptor.getValue(); + Assertions.assertAll( + () -> Assertions.assertNotEquals(existing.getId(), replacement.getId()), + () -> Assertions.assertEquals(replacement.getId(), result.getId()), + () -> Assertions.assertEquals("1900000099", replacement.getReceiverAccount()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), replacement.getStatus()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode(), existing.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), existing.getIsDefault())); + verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(replacement.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + } + + /** 修改接收方名称不改变历史接收账号,但必须清空旧结果并唤醒原关系绑定任务。 */ + @Test + void saveForManageShouldRebindCurrentRelationWhenReceiverNameChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7007L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverName("新的平台名称"); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(existing.getId(), result.getId()), + () -> Assertions.assertEquals("新的平台名称", result.getReceiverName()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(), result.getStatus()), + () -> Assertions.assertNull(result.getLastExternalStatus()), + () -> Assertions.assertNull(result.getBoundTime())); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer).enqueueRelationBind(org.mockito.ArgumentMatchers.eq(TENANT_ID), + org.mockito.ArgumentMatchers.eq(existing.getId()), any(), org.mockito.ArgumentMatchers.eq(150)); + } + + /** 只修改抽佣比例属于本地结算规则,不需要重复调用渠道关系绑定接口。 */ + @Test + void saveForManageShouldNotRebindWhenOnlyRatioChanges() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7008L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setFzRatio(2.125D); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(2.125D, result.getFzRatio()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 接收方类型变化时不能沿用旧类型的隐藏账号,管理端必须提交完整新标识。 */ + @Test + void saveForManageShouldRejectTypeChangeWithoutFullAccount() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity existing = boundWechatRelation(7009L); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing); + ThirdMchFzRelationSaveDto dto = manageEditDto(existing); + dto.setReceiverType("PERSONAL_OPENID"); + + IllegalArgumentException exception = Assertions.assertThrows(IllegalArgumentException.class, + () -> service.saveForManage(dto)); + + Assertions.assertTrue(exception.getMessage().contains("完整的新接收方标识")); + verify(relationMapper, never()).insert(any(ThirdMchFzRelationEntity.class)); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新建支付宝关系的绑定幂等号必须满足支付宝32字符限制,并保留可审计的平台前缀。 */ + @Test + void saveForManageShouldGenerateShortAlipayOutRequestNo() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "merchant-auth-token"); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setMchId(MCH_ID); + dto.setPaySettingId(setting.getId()); + dto.setChannelSubMchId("smid-1001"); + dto.setReceiverType("userId"); + dto.setReceiverAccount("2088000000000001"); + dto.setReceiverName("平台支付宝账号"); + dto.setFzRatio(5D); + + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + when(relationMapper.insert(relationCaptor.capture())).thenReturn(1); + + service.saveForManage(dto); + + String outRequestNo = relationCaptor.getValue().getOutRequestNo(); + Assertions.assertAll( + () -> Assertions.assertTrue(outRequestNo.startsWith("ar_")), + () -> Assertions.assertTrue(outRequestNo.length() <= 32), + () -> Assertions.assertTrue(outRequestNo.matches("[a-z0-9_]+"))); + } + + /** 新增与历史关系完全相同的配置时应恢复原主键,避免唯一键冲突和重复渠道绑定。 */ + @Test + void saveForManageShouldRestoreIdenticalSupersededRelation() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7013L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getIsDefault())); + verify(relationMapper).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 管理端直接恢复历史关系时无需重新输入脱敏账号,并应将原关系恢复为默认关系。 */ + @Test + void saveForManageShouldRestoreSupersededRelationAsDefault() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + ThirdMchFzRelationEntity historical = boundWechatRelation(7014L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical, historical); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setIsDefault(YesNoEnum.YES.getCode()); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertEquals(YesNoEnum.YES.getCode(), result.getIsDefault())); + verify(relationMapper, times(3)).update(any(), any()); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新增相同支付宝历史关系时,管理端未提交完整授权令牌也必须保留服务端原值。 */ + @Test + void saveForManageShouldPreserveAlipayTokenWhenRestoringSupersededRelation() { + MchPaySettingVo setting = alipaySetting(102L, 5D, "legacy-setting-token"); + ThirdMchFzRelationEntity historical = pendingAlipayRelation(7015L); + historical.setStatus(ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()); + historical.setIsDefault(YesNoEnum.NO.getCode()); + historical.setBoundTime(LocalDateTime.now().minusMinutes(1)); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.insert(any(ThirdMchFzRelationEntity.class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate relation")); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(historical); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(1L); + when(relationMapper.update(any(), any())).thenReturn(1); + ThirdMchFzRelationSaveDto dto = manageEditDto(historical); + dto.setId(null); + dto.setReceiverAccount(historical.getReceiverAccount()); + dto.setAppAuthToken(null); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(historical.getId(), result.getId()), + () -> Assertions.assertEquals(ThirdMchFzRelationStatusEnum.BOUND.getCode(), result.getStatus()), + () -> Assertions.assertTrue(result.getAppAuthTokenConfigured())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 新建关闭分账的关系仅保存支付路由,不创建渠道接收方绑定任务。 */ + @Test + void saveForManageShouldNotEnqueueBindingWhenProfitSharingDisabled() { + MchPaySettingVo setting = wechatSetting(101L, 3D); + when(remoteMchService.getById(MCH_ID)).thenReturn(R.ok(merchant(List.of(setting)))); + when(relationMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setMchId(MCH_ID); + dto.setPaySettingId(setting.getId()); + dto.setChannelSubMchId("wx-sub-1001"); + dto.setFzRatio(0D); + dto.setEnableFz(YesNoEnum.NO.getCode()); + ArgumentCaptor relationCaptor = ArgumentCaptor.forClass(ThirdMchFzRelationEntity.class); + when(relationMapper.insert(relationCaptor.capture())).thenReturn(1); + + var result = service.saveForManage(dto); + + Assertions.assertAll( + () -> Assertions.assertEquals(YesNoEnum.NO.getCode(), result.getEnableFz()), + () -> Assertions.assertEquals(0D, result.getFzRatio()), + () -> Assertions.assertEquals("wx-sub-1001", result.getChannelSubMchId()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverType()), + () -> Assertions.assertEquals("", relationCaptor.getValue().getReceiverAccount())); + verify(taskEnqueuer, never()).enqueueRelationBind(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt()); + } + + /** 删除唯一默认关系时不再强制准备替代关系;已绑定关系仅转为历史状态。 */ + @Test + void removeForManageShouldAllowDeletingDefaultWithoutReplacement() { + ThirdMchFzRelationEntity existing = boundWechatRelation(7010L); + when(relationMapper.selectOne(any(Wrapper.class))).thenReturn(existing, null); + when(relationMapper.update(any(), any())).thenReturn(1); + + Assertions.assertTrue(service.removeForManage(List.of(existing.getId()))); + + verify(relationMapper, times(1)).update(any(), any()); + } + + /** 构造商户关系同步快照。 */ + private MchManageVo merchant(List settings) { + MchManageVo merchant = new MchManageVo(); + merchant.setId(MCH_ID); + merchant.setTenantId(TENANT_ID); + merchant.setPaySettingList(settings); + return merchant; + } + + /** 构造微信子商户支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */ + private MchPaySettingVo wechatSetting(Long id, Double ratio) { + MchPaySettingEntity.WechatPaySetting detail = new MchPaySettingEntity.WechatPaySetting(); + detail.setSubMchid("wx-sub-1001"); + return setting(id, AppPlusPlatformPayType.WECHAT.getType(), ratio, JSONUtil.toJsonStr(detail)); + } + + /** 构造支付宝直付通支付配置;ratio 参数仅保留测试调用兼容,比例不再属于支付配置。 */ + private MchPaySettingVo alipaySetting(Long id, Double ratio, String appAuthToken) { + MchPaySettingEntity.AliPaySetting detail = new MchPaySettingEntity.AliPaySetting(); + detail.setSubMchid("smid-1001"); + detail.setAppAuthToken(appAuthToken); + return setting(id, AppPlusPlatformPayType.ALI_PAY.getType(), ratio, JSONUtil.toJsonStr(detail)); + } + + /** 构造关系服务使用的通用支付配置字段。 */ + private MchPaySettingVo setting(Long id, String platformType, Double ratio, String settingJson) { + MchPaySettingVo setting = new MchPaySettingVo(); + setting.setId(id); + setting.setTenantId(TENANT_ID); + setting.setMchId(MCH_ID); + setting.setPlatformType(platformType); + setting.setEnable(YesNoEnum.YES.getCode()); + setting.setSettingJson(settingJson); + return setting; + } + + /** 构造等待绑定的微信关系事实。 */ + private ThirdMchFzRelationEntity pendingWechatRelation(Long id) { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(id); + relation.setTenantId(TENANT_ID); + relation.setMchId(MCH_ID); + relation.setPaySettingId(101L); + relation.setPlatformType(AppPlusPlatformPayType.WECHAT.getType()); + relation.setChannelAppId("wx-app-1001"); + relation.setChannelSubMchId("wx-sub-1001"); + relation.setReceiverType(ReceiverType.MERCHANT_ID.name()); + relation.setReceiverAccount("1900000002"); + relation.setReceiverName("平台服务商"); + relation.setFzRatio(3D); + relation.setEnableFz(YesNoEnum.YES.getCode()); + relation.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name()); + relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + relation.setDelFlag("0"); + return relation; + } + + /** 构造已完成渠道绑定且正在承接新支付的微信关系。 */ + private ThirdMchFzRelationEntity boundWechatRelation(Long id) { + ThirdMchFzRelationEntity relation = pendingWechatRelation(id); + relation.setOutRequestNo("wxrel_" + id); + relation.setStatus(ThirdMchFzRelationStatusEnum.BOUND.getCode()); + relation.setIsDefault(YesNoEnum.YES.getCode()); + relation.setBindRes("{\"success\":true}"); + relation.setLastExternalStatus("SUCCESS"); + relation.setLastQueryTime(LocalDateTime.now().minusMinutes(1)); + relation.setBoundTime(LocalDateTime.now().minusMinutes(1)); + return relation; + } + + /** 构造编辑关系的管理端参数;接收方账号故意留空以模拟脱敏列表的真实提交方式。 */ + private ThirdMchFzRelationSaveDto manageEditDto(ThirdMchFzRelationEntity relation) { + ThirdMchFzRelationSaveDto dto = new ThirdMchFzRelationSaveDto(); + dto.setId(relation.getId()); + dto.setMchId(relation.getMchId()); + dto.setPaySettingId(relation.getPaySettingId()); + dto.setChannelSubMchId(relation.getChannelSubMchId()); + dto.setReceiverType(relation.getReceiverType()); + dto.setReceiverAccount(""); + dto.setReceiverName(relation.getReceiverName()); + dto.setFzRatio(relation.getFzRatio()); + dto.setEnableFz(relation.getEnableFz()); + dto.setChannelRelationType(relation.getChannelRelationType()); + dto.setIsDefault(YesNoEnum.NO.getCode()); + return dto; + } + + /** 构造等待绑定的支付宝直付通平台分佣关系事实。 */ + private ThirdMchFzRelationEntity pendingAlipayRelation(Long id) { + ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity(); + relation.setId(id); + relation.setTenantId(TENANT_ID); + relation.setMchId(MCH_ID); + relation.setPaySettingId(102L); + relation.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType()); + relation.setChannelAppId("2026000000000001"); + relation.setChannelSubMchId("smid-1001"); + relation.setReceiverType("userId"); + relation.setReceiverAccount("2088000000000001"); + relation.setReceiverName("平台支付宝账号"); + relation.setFzRatio(5D); + relation.setEnableFz(YesNoEnum.YES.getCode()); + relation.setAppAuthToken("merchant-auth-token"); + relation.setOutRequestNo("alirel_7003"); + relation.setStatus(ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode()); + relation.setDelFlag("0"); + return relation; + } + + /** 构造接口要求的最小完整支付宝资料,模拟企业当面付+APP商户。 */ + private ThirdAlipayAliasModifyDto fullAlipayModifyDto() { + return fullAlipayModifyDtoWithServices(List.of("当面付", "app支付")); + } + + private ThirdAlipayAliasModifyDto fullAlipayModifyDtoWithServices(List services) { + ThirdAlipayAliasModifyDto dto = new ThirdAlipayAliasModifyDto(); + dto.setAliasName("青鸟同文"); + dto.setName("青鸟同文"); + dto.setMerchantType("01"); + dto.setMcc("5812"); + dto.setServices(services); + dto.setServicePhone("13800000000"); + dto.setSignTimeWithIsv("2026-01-01"); + dto.setCertType("201"); + dto.setLegalName("张斌"); + dto.setLegalCertNo("330000000000000000"); + dto.setLegalCertType("100"); + dto.setAlipayLogonId("merchant@example.com"); + + ThirdAlipayAliasModifyDto.ContactInfoDto contact = new ThirdAlipayAliasModifyDto.ContactInfoDto(); + contact.setName("张斌"); + contact.setMobile("13800000000"); + contact.setType("LEGAL_PERSON"); + dto.setContact(contact); + + ThirdAlipayAliasModifyDto.BusinessAddressDto address = new ThirdAlipayAliasModifyDto.BusinessAddressDto(); + address.setProvinceCode("330000"); + address.setCityCode("330100"); + address.setDistrictCode("330106"); + address.setAddress("浙江省杭州市西湖区测试路1号"); + dto.setBusinessAddress(address); + dto.setOutDoorImages(List.of("oss-door")); + dto.setInDoorImages(List.of("oss-inside")); + dto.setCertImage("oss-cert"); + + ThirdAlipayAliasModifyDto.SiteInfoDto site = new ThirdAlipayAliasModifyDto.SiteInfoDto(); + site.setSiteType("02"); + site.setSiteName("青鸟同文APP"); + dto.setSites(List.of(site)); + return dto; + } + + /** 执行一次支付宝绑定并返回 SDK 请求中的接收方模型。 */ + private RoyaltyEntity bindAlipayAndCaptureReceiver(String receiverType, String receiverAccount) throws Exception { + ThirdMchFzRelationEntity pending = pendingAlipayRelation(7003L); + pending.setReceiverType(receiverType); + pending.setReceiverAccount(receiverAccount); + ThirdMchFzRelationEntity binding = pendingAlipayRelation(7003L); + binding.setReceiverType(receiverType); + binding.setReceiverAccount(receiverAccount); + binding.setStatus(ThirdMchFzRelationStatusEnum.BINDING.getCode()); + when(relationMapper.selectById(7003L)).thenReturn(pending, binding); + when(relationMapper.update(any(), any())).thenReturn(1); + AlipayTradeRoyaltyRelationBindResponse response = new AlipayTradeRoyaltyRelationBindResponse(); + response.setCode("10000"); + response.setResultCode("SUCCESS"); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(AlipayTradeRoyaltyRelationBindRequest.class); + when(alipayClient.execute(requestCaptor.capture())).thenReturn(response); + + Assertions.assertTrue(service.bind(7003L)); + AlipayTradeRoyaltyRelationBindModel model = + (AlipayTradeRoyaltyRelationBindModel) requestCaptor.getValue().getBizModel(); + return model.getReceiverList().get(0); + } + + /** 固定返回 Mock 支付宝客户端的关系服务测试实现。 */ + private static final class TestableThirdMchFzRelationServiceImpl extends ThirdMchFzRelationServiceImpl { + + /** 当前测试共享的支付宝 SDK 客户端 Mock。 */ + private final AlipayClient testAlipayClient; + + /** + * 创建可替换支付宝客户端的关系服务。 + * + * @param relationMapper 关系事实 Mapper Mock + * @param taskEnqueuer 关系绑定任务写入器 Mock + * @param profitsharingService 微信分账 SDK Mock + * @param appPlusPayConfig 平台支付配置 + * @param remoteMchService 商户内部接口 Mock + * @param testAlipayClient 支付宝 SDK 客户端 Mock + */ + private TestableThirdMchFzRelationServiceImpl(ThirdMchFzRelationMapper relationMapper, + ThirdPaymentTaskEnqueuer taskEnqueuer, + ProfitsharingService profitsharingService, + AppPlusPayConfig appPlusPayConfig, + RemoteMchService remoteMchService, + AlipayClient testAlipayClient) { + super(relationMapper, taskEnqueuer, profitsharingService, appPlusPayConfig, remoteMchService); + this.testAlipayClient = testAlipayClient; + } + + /** 返回测试注入的支付宝 SDK 客户端,避免发起真实渠道请求。 */ + @Override + protected AlipayClient client(AppPlusPayConfig.AliPayConfig config) { + return testAlipayClient; + } + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java index ff87a0b9..cd3172cb 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayOrderServiceImplTest.java @@ -132,6 +132,24 @@ class ThirdPayOrderServiceImplTest { Assertions.assertNull(order.getRoyaltyReq()); } + /** + * 关闭平台抽佣的支付宝订单仍展示确认结算结果,平台抽佣和退分账明确显示为未配置。 + */ + @Test + void disabledProfitSharingShouldStillProjectAlipaySettlementStage() { + ThirdPayOrderVo order = alipayOrder(); + order.setEnableFz(YesNoEnum.NO.getCode()); + ThirdFzOrderEntity fzOrder = fzOrder(order.getPayNo()); + + ReflectionTestUtils.invokeMethod(service, "fillProfitSharingStage", order, fzOrder); + + Assertions.assertEquals("SUCCESS", order.getSettleConfirmStatus()); + Assertions.assertEquals("NOT_CONFIGURED", order.getRoyaltyStatus()); + Assertions.assertEquals("NOT_CONFIGURED", order.getFzRefundStatus()); + Assertions.assertNull(order.getRoyaltyReq()); + Assertions.assertNull(order.getRoyaltyRes()); + } + /** * 验证同一支付单的告警任务优先于较新的成功任务展示,人工重试不会误选已完成任务。 */ diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java new file mode 100644 index 00000000..bc291d7a --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java @@ -0,0 +1,254 @@ +package com.bsd.pjcloud.thirdparty.service.impl.pay; + +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto; +import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord; +import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo; +import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.Mockito.when; + +/** + * 第三方支付统计服务测试,覆盖历史快照解析、范围校验、聚合完整性和商户服务降级。 + */ +@ExtendWith(MockitoExtension.class) +class ThirdPayTotalServiceImplTest { + + /** 支付统计 Mapper Mock,分别返回支付、退款、分账和退分账事实。 */ + @Mock + private ThirdPayTotalMapper thirdPayTotalMapper; + + /** 商户远程服务 Mock,用于验证名称补充和异常降级。 */ + @Mock + private RemoteMchService remoteMchService; + + /** 被测第三方支付统计服务。 */ + @InjectMocks + private ThirdPayTotalServiceImpl service; + + /** + * 支付时间必须完整、正序且跨度不超过一年,防止空条件扫描全历史数据。 + */ + @Test + void validateQueryShouldRejectInvalidPayTimeRange() { + ThirdPayTotalQueryDto missing = new ThirdPayTotalQueryDto(); + ThirdPayTotalQueryDto incomplete = new ThirdPayTotalQueryDto(); + incomplete.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), null}); + ThirdPayTotalQueryDto reversed = query(LocalDateTime.of(2026, 2, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0)); + ThirdPayTotalQueryDto overOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0, 1)); + ThirdPayTotalQueryDto exactlyOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 0)); + + Assertions.assertAll( + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(null)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(missing)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(incomplete)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(reversed)), + () -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(overOneYear)), + () -> Assertions.assertDoesNotThrow(() -> service.validateQuery(exactlyOneYear))); + } + + /** + * 微信退款应兼容 V2 字段、V3 amount.refund,并严格遵循回调优先于响应和请求的顺序。 + */ + @Test + void parseRefundAmountShouldSupportWechatV2AndV3Snapshots() { + ThirdPayTotalRefundRecord v2 = refundRecord("10"); + v2.setRefundCallbackRes("{\"refund_fee\":\"125\"}"); + v2.setRefundRes("{\"refund_fee\":999}"); + ThirdPayTotalRefundRecord v3 = refundRecord("10"); + v3.setRefundRes("{\"amount\":{\"total\":1000,\"refund\":234}}"); + + Assertions.assertEquals(125L, service.parseRefundAmount(v2)); + Assertions.assertEquals(234L, service.parseRefundAmount(v3)); + } + + /** + * 支付宝退款金额按元使用 BigDecimal 精确转分,超过分精度或损坏快照必须标记为不可解析。 + */ + @Test + void parseRefundAmountShouldConvertAlipayYuanPrecisely() { + ThirdPayTotalRefundRecord precise = refundRecord("20"); + precise.setRefundCallbackRes("{\"refundAmount\":\"12.34\"}"); + ThirdPayTotalRefundRecord detail = refundRecord("20"); + detail.setRefundRes("{\"refund_detail_item_list\":[{\"amount\":\"0.07\"}]}"); + ThirdPayTotalRefundRecord subFen = refundRecord("20"); + subFen.setRefundRep("{\"refundAmount\":\"1.005\"}"); + ThirdPayTotalRefundRecord invalid = refundRecord("20"); + invalid.setRefundRep("not-json"); + + Assertions.assertAll( + () -> Assertions.assertEquals(1234L, service.parseRefundAmount(precise)), + () -> Assertions.assertEquals(7L, service.parseRefundAmount(detail)), + () -> Assertions.assertNull(service.parseRefundAmount(subFen)), + () -> Assertions.assertNull(service.parseRefundAmount(invalid))); + } + + /** + * 退分账解析应兼容微信结构化响应和支付宝多条分账退款明细,且不能读取普通整单退款金额。 + */ + @Test + void parseFzRefundAmountShouldReadOnlyRoyaltyRefundAmounts() { + ThirdPayTotalFzRefundRecord wechat = fzRefundRecord("10"); + wechat.setFzRefundRes("{\"refund\":{\"response\":{\"amount\":300}}}"); + ThirdPayTotalFzRefundRecord alipayRequest = fzRefundRecord("20"); + alipayRequest.setFzRefundReq("[{\"amount\":\"1.20\"},{\"amount\":\"0.03\"}]"); + ThirdPayTotalFzRefundRecord alipayResponse = fzRefundRecord("20"); + alipayResponse.setFzRefundRes("{\"response\":{\"refundRoyaltys\":[{\"amount\":\"2.50\"}]}}"); + ThirdPayTotalFzRefundRecord unrelatedAmount = fzRefundRecord("20"); + unrelatedAmount.setFzRefundRes("{\"refundAmount\":\"10.00\",\"amount\":\"10.00\"}"); + + Assertions.assertAll( + () -> Assertions.assertEquals(300L, service.parseFzRefundAmount(wechat)), + () -> Assertions.assertEquals(123L, service.parseFzRefundAmount(alipayRequest)), + () -> Assertions.assertEquals(250L, service.parseFzRefundAmount(alipayResponse)), + () -> Assertions.assertNull(service.parseFzRefundAmount(unrelatedAmount))); + } + + /** + * 四类事实应在商户与支付方式维度合并,未知退分账金额保留次数并使净收款标为部分可核对。 + */ + @Test + void statisticsShouldMergeIndependentFactsAndMarkIncompleteNetAmount() { + ThirdPayTotalQueryDto query = validQuery(); + when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 1000L))); + ThirdPayTotalRefundRecord parsedRefund = refundRecord("10"); + parsedRefund.setMchId(1L); + parsedRefund.setRefundCallbackRes("{\"amount\":{\"refund\":200}}"); + ThirdPayTotalRefundRecord unknownRefund = refundRecord("10"); + unknownRefund.setMchId(1L); + when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of(parsedRefund, unknownRefund)); + when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 300L))); + ThirdPayTotalFzRefundRecord parsedFzRefund = fzRefundRecord("10"); + parsedFzRefund.setMchId(1L); + parsedFzRefund.setFzRefundReq("{\"amount\":100}"); + ThirdPayTotalFzRefundRecord unknownFzRefund = fzRefundRecord("10"); + unknownFzRefund.setMchId(1L); + when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of(parsedFzRefund, unknownFzRefund)); + MchManageVo merchant = new MchManageVo(); + merchant.setId(1L); + merchant.setName("测试商户"); + when(remoteMchService.getByIds(List.of(1L))).thenReturn(R.ok(List.of(merchant))); + + ThirdPayTotalStatisticsVo result = service.statistics(query); + ThirdPayTotalRowVo row = result.getRows().get(0); + + Assertions.assertAll( + () -> Assertions.assertEquals("测试商户", row.getMchName()), + () -> Assertions.assertEquals(2L, row.getPayOrderCount()), + () -> Assertions.assertEquals(1000L, row.getPayAmount()), + () -> Assertions.assertEquals(2L, row.getRefundOrderCount()), + () -> Assertions.assertEquals(200L, row.getRefundAmount()), + () -> Assertions.assertEquals(1L, row.getRefundAmountUnavailableCount()), + () -> Assertions.assertEquals(2L, row.getFzSuccessCount()), + () -> Assertions.assertEquals(300L, row.getFzSuccessAmount()), + () -> Assertions.assertEquals(2L, row.getFzRefundSuccessCount()), + () -> Assertions.assertEquals(100L, row.getFzRefundSuccessAmount()), + () -> Assertions.assertEquals(1L, row.getFzRefundAmountUnavailableCount()), + () -> Assertions.assertEquals(200L, row.getPlatformNetAmount()), + () -> Assertions.assertFalse(row.isPlatformNetAmountComplete()), + () -> Assertions.assertEquals(row.getPlatformNetAmount(), result.getTotal().getPlatformNetAmount()), + () -> Assertions.assertFalse(result.getTotal().isPlatformNetAmountComplete())); + } + + /** + * 商户远程调用异常时应保留原商户 ID 和未知支付方式编码,避免历史统计行静默丢失。 + */ + @Test + void statisticsShouldKeepRowsWhenMerchantServiceFails() { + ThirdPayTotalQueryDto query = validQuery(); + when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(9L, "99", 1L, 500L))); + when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of()); + when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of()); + when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of()); + when(remoteMchService.getByIds(List.of(9L))).thenThrow(new IllegalStateException("remote unavailable")); + + ThirdPayTotalRowVo row = service.statistics(query).getRows().get(0); + + Assertions.assertAll( + () -> Assertions.assertEquals(9L, row.getMchId()), + () -> Assertions.assertEquals("商户信息不可用", row.getMchName()), + () -> Assertions.assertEquals("99", row.getPayPlatformType()), + () -> Assertions.assertEquals(500L, row.getPayAmount())); + } + + /** + * 构造合法的最近统计范围。 + * + * @return 包含完整时间范围的查询条件 + */ + private ThirdPayTotalQueryDto validQuery() { + return query(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59, 59)); + } + + /** + * 构造指定开始和结束时间的统计查询。 + * + * @param start 支付开始时间 + * @param end 支付结束时间 + * @return 支付统计查询条件 + */ + private ThirdPayTotalQueryDto query(LocalDateTime start, LocalDateTime end) { + ThirdPayTotalQueryDto query = new ThirdPayTotalQueryDto(); + query.setPayTimeList(new LocalDateTime[]{start, end}); + return query; + } + + /** + * 构造 SQL 聚合返回值。 + * + * @param mchId 商户主键 + * @param platform 支付平台编码 + * @param count 记录数 + * @param amount 金额,单位分 + * @return 聚合记录 + */ + private ThirdPayTotalAggregateRecord aggregate(Long mchId, String platform, Long count, Long amount) { + ThirdPayTotalAggregateRecord record = new ThirdPayTotalAggregateRecord(); + record.setMchId(mchId); + record.setPayPlatformType(platform); + record.setRecordCount(count); + record.setAmount(amount); + return record; + } + + /** + * 构造支付退款快照记录。 + * + * @param platform 支付平台编码 + * @return 退款记录 + */ + private ThirdPayTotalRefundRecord refundRecord(String platform) { + ThirdPayTotalRefundRecord record = new ThirdPayTotalRefundRecord(); + record.setPayPlatformType(platform); + return record; + } + + /** + * 构造退分账快照记录。 + * + * @param platform 支付平台编码 + * @return 退分账记录 + */ + private ThirdPayTotalFzRefundRecord fzRefundRecord(String platform) { + ThirdPayTotalFzRefundRecord record = new ThirdPayTotalFzRefundRecord(); + record.setPayPlatformType(platform); + return record; + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java index a3c45539..f7329fb9 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPaymentTaskServiceImplTest.java @@ -1,10 +1,12 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay; +import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum; import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum; import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity; +import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity; import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPaymentTaskEntity; import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPaymentTaskAttemptMapper; @@ -200,6 +202,52 @@ class ThirdPaymentTaskServiceImplTest { Assertions.assertTrue(nowCaptor.getValue().isAfter(dto.getTriggerTime())); } + /** + * 关闭平台抽佣的支付宝订单确认结算成功后任务直接完成,不得继续排平台抽佣。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void settleConfirmShouldFinishWithoutRoyaltyWhenProfitSharingDisabled() throws Exception { + ThirdPayOrderEntity payOrder = disabledProfitSharingOrder(); + ThirdFzOrderEntity fzOrder = settlementStage(payOrder); + when(fzOrderService.getById(fzOrder.getId())).thenReturn(fzOrder); + when(payOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + when(aliPayService.confirmDirectPaySettlementStage(fzOrder, payOrder)).thenReturn("SUCCESS"); + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setTaskType(ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM.getCode()); + task.setFzOrderId(fzOrder.getId()); + + Object result = runTask(task); + + Assertions.assertTrue(resultSuccess(result)); + Assertions.assertEquals("SUCCESS", resultExternalStatus(result)); + verify(aliPayService).confirmDirectPaySettlementStage(fzOrder, payOrder); + verify(paymentTaskEnqueuer, never()).enqueueFzRoyalty(any(), any(), any(), any(), any()); + } + + /** + * 修复前已生成的抽佣任务在订单关闭抽佣后必须本地收敛,不能再访问支付宝抽佣接口。 + * + * @throws Exception 反射调用私有任务分派方法失败时抛出 + */ + @Test + void staleRoyaltyTaskShouldNotCallAlipayWhenProfitSharingDisabled() throws Exception { + ThirdPayOrderEntity payOrder = disabledProfitSharingOrder(); + ThirdFzOrderEntity fzOrder = settlementStage(payOrder); + when(fzOrderService.getById(fzOrder.getId())).thenReturn(fzOrder); + when(payOrderService.getByPayNo(payOrder.getPayNo())).thenReturn(payOrder); + ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity(); + task.setTaskType(ThirdPaymentTaskTypeEnum.FZ_ROYALTY.getCode()); + task.setFzOrderId(fzOrder.getId()); + + Object result = runTask(task); + + Assertions.assertTrue(resultSuccess(result)); + Assertions.assertEquals("NOT_CONFIGURED", resultExternalStatus(result)); + verifyNoInteractions(aliPayService); + } + /** * 构造历史聚合会话,状态保持 EXPIRED 以模拟服务重启后的恢复记录。 * @@ -216,6 +264,35 @@ class ThirdPaymentTaskServiceImplTest { return session; } + /** + * 构造关闭平台抽佣但仍需确认结算的支付宝支付成功订单。 + * + * @return 关闭平台抽佣的支付订单 + */ + private ThirdPayOrderEntity disabledProfitSharingOrder() { + ThirdPayOrderEntity order = new ThirdPayOrderEntity(); + order.setId(401L); + order.setTenantId(9L); + order.setPayNo("pay-settle-1001"); + order.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()); + order.setEnableFz(YesNoEnum.NO.getCode()); + return order; + } + + /** + * 构造承载支付宝确认结算状态的本地阶段记录。 + * + * @param payOrder 对应支付订单 + * @return 待确认结算阶段记录 + */ + private ThirdFzOrderEntity settlementStage(ThirdPayOrderEntity payOrder) { + ThirdFzOrderEntity fzOrder = new ThirdFzOrderEntity(); + fzOrder.setId(payOrder.getId()); + fzOrder.setPayNo(payOrder.getPayNo()); + fzOrder.setFzNo("fz_" + payOrder.getPayNo()); + return fzOrder; + } + /** * 构造关联指定聚合会话的任务事实。 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index d71761cf..12c6605d 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -67,8 +67,7 @@ public interface RemoteUserService { * @return 含准确总数的轻量用户分页;调用方必须检查业务状态和空数据 */ @PostMapping("/user/basic-page") - R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, - @RequestBody UserDTO userDTO); + R> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size, @RequestBody UserDTO userDTO); /** * 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。 @@ -143,6 +142,12 @@ public interface RemoteUserService { @PostMapping("/user/inner/list") R> getInnerUserById(@RequestBody List userIds); + /** + * 按用户 ID 批量查询用户、部门及身份协议编码,供跨服务业务校验和展示使用。 + * + * @param userIds 待查询用户 ID 列表 + * @return 用户、部门和身份资料;不存在的用户不返回记录 + */ @PostMapping("/user/user-dept") R> getUserDeptByIds(@RequestBody List userIds); diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java index fa62123c..9a546563 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserDeptInterface.java @@ -4,9 +4,10 @@ package com.bsd.pjcloud.admin.api.vo.base; * 用户及其所属部门的通用数据契约。 * *

历史业务模型使用 {@code userMobile}、{@code userAvatar}、{@code userDeptName} 等字段, - * 标准别名用于直接适配管理端 {@code UserInfoCell} 组件,同时通过默认方法保持已有实现兼容。

+ * 标准别名用于直接适配管理端 {@code UserInfoCell} 组件,同时通过默认方法保持已有实现兼容。 + * 本接口继承基础用户契约,使实现类可以直接用于只要求用户主资料的通用装配方法。

*/ -public interface BaseUserDeptInterface { +public interface BaseUserDeptInterface extends BaseUserInterface, BaseUserPhoneInterface { /** * 获取用户主键。 * @@ -114,6 +115,7 @@ public interface BaseUserDeptInterface { * * @return 用户手机号;未绑定时为空 */ + @Override default String getPhone() { return getUserMobile(); } @@ -123,6 +125,7 @@ public interface BaseUserDeptInterface { * * @param phone 用户手机号,未绑定时可为空 */ + @Override default void setPhone(String phone) { setUserMobile(phone); } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java index b470cb47..cfd87c97 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java @@ -9,9 +9,10 @@ import java.util.List; * 管理端用户身份信息组件使用的通用数据契约。 * *

字段与 {@code UserIdentityInfo} 组件的输入保持一致,业务 VO 实现该接口后可直接承载 - * UPMS 批量查询得到的身份类型、学生资料和老师资料。

+ * UPMS 批量查询得到的身份类型、学生资料和老师资料。标准手机号方法继承自 + * {@link BaseUserPhoneInterface},避免与用户部门接口的历史字段默认适配产生继承冲突。

*/ -public interface BaseUserIdentityInterface { +public interface BaseUserIdentityInterface extends BaseUserPhoneInterface { /** * 获取用户主键,用于批量查询和回填身份资料。 @@ -27,20 +28,6 @@ public interface BaseUserIdentityInterface { */ void setUserId(Long userId); - /** - * 获取默认用户身份展示的手机号。 - * - * @return 用户绑定手机号;未绑定时可为空 - */ - String getPhone(); - - /** - * 设置默认用户身份展示的手机号。 - * - * @param phone 用户绑定手机号,未绑定时可为空 - */ - void setPhone(String phone); - /** * 获取用户拥有的身份协议编码。 * diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java new file mode 100644 index 00000000..56b034e7 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserPhoneInterface.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.admin.api.vo.base; + +/** + * 用户标准手机号的公共数据契约。 + * + *

用户部门与用户身份接口都会使用标准手机号。将方法声明收敛到共同父接口,可让部门接口继续 + * 提供历史字段适配的默认实现,同时避免两个无关接口组成交叉类型时产生默认方法与抽象方法冲突。

+ */ +public interface BaseUserPhoneInterface { + + /** + * 获取用户标准手机号。 + * + * @return 用户绑定手机号;未绑定或尚未补充用户资料时可为空 + */ + String getPhone(); + + /** + * 设置用户标准手机号。 + * + * @param phone 用户绑定手机号;未绑定或需要清空展示值时可为空 + */ + void setPhone(String phone); +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java index 509c3265..1542b90d 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/user/SysUserGuardianWardOptionVo.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; +import java.util.List; /** * 监护人的有效被监护人选择项。 @@ -31,4 +32,10 @@ public class SysUserGuardianWardOptionVo implements Serializable { */ @Schema(description = "被监护人头像") private String avatar; + + /** + * 被监护人的用户身份协议编码,用于按商户订餐身份配置筛选可选人员。 + */ + @Schema(description = "被监护人的用户身份协议编码") + private List identityTypes; } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 5c8dc258..e9cb3b33 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -97,10 +97,14 @@ public class SysUserController { private final SysUserService userService; - /** 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 */ + /** + * 用户 Excel 导入导出服务,统一承接用户模块的文件转换与导入编排。 + */ private final SysUserImportExportService userImportExportService; - /** 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 */ + /** + * 用户 Excel 导出服务,独立承接模板填充、身份资料组装和结果文件保存。 + */ private final SysUserExportService userExportService; private final RemoterVisitorService remoterVisitorService; @@ -110,7 +114,9 @@ public class SysUserController { private final SysRoleService sysRoleService; private final SysUserRoleService sysUserRoleService; - /** 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 */ + /** + * 学生身份服务,用于跨服务开卡导入按学校和学号精确定位学生。 + */ private final SysUserStudentService sysUserStudentService; private final SysMessageService sysMessageService; /* ************************************* start 回调 start ************************************* */ @@ -162,7 +168,7 @@ public class SysUserController { * 根据学校和学号精确查询学生用户,供钱包学生开卡导入校验使用。 * * @param schoolName 学生身份扩展信息中的学校名称 - * @param studentNo 学生身份扩展信息中的学号 + * @param studentNo 学生身份扩展信息中的学号 * @return 唯一学生用户;不存在时返回失败响应 */ @GetMapping("/get-student-by-school-and-no") @@ -336,14 +342,15 @@ public class SysUserController { * *

该接口供无需返回角色、岗位、人脸和审批信息、但可能按人脸录入状态筛选的业务列表调用,避免复用管理端完整用户分页产生额外查询。

* - * @param page 分页参数 + * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @return 包含用户总数、基础信息、部门和身份资料的轻量分页 */ @ApiCryptoAes @PostMapping("/basic-page") public R> getBasicUserPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) { - return R.ok(userService.getBasicUsersPage(page, userDTO, DataScope.of())); + DataScope dataScope = DataScope.of(); + return R.ok(userService.getBasicUsersPage(page, userDTO, dataScope)); } /** @@ -351,7 +358,7 @@ public class SysUserController { * *

该接口供跨服务后台导出分批读取,返回账号主资料、角色岗位和身份扩展,跳过人脸及审批流程。

* - * @param page 分页参数 + * @param page 分页参数 * @param userDTO 用户关键字、部门树和指定用户筛选条件 * @return 包含用户总数和完整导出资料的分页 */ @@ -405,6 +412,7 @@ public class SysUserController { * @return success/false */ @SysLog("修改个人信息") + @ApiCryptoAes @PutMapping("/personal/edit") public R updateUserInfo(@Valid @RequestBody UserDTO userDto) { if (StrUtil.isBlank(userDto.getUsername())) { @@ -467,11 +475,11 @@ public class SysUserController { } /** - * 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入后台用户基础信息,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 * - * @param excelVOList Excel 中解析出的后台用户数据 + * @param excelVOList Excel 中解析出的后台用户数据 * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import") @HasPermission("sys_user_import") @@ -480,11 +488,11 @@ public class SysUserController { } /** - * 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入学生用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 * * @param studentUserExcelList Excel 中解析出的学生用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-student") @HasPermission("sys_student_user_export") @@ -494,12 +502,12 @@ public class SysUserController { } /** - * 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入学生家长用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 *

手机号和部门必填;姓名为空时按“手机号 + 学生家长”生成,手机号同时作为登录账号和初始密码。

* * @param guardianUserExcelList Excel 中解析出的学生家长用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-guardian") @HasPermission("sys_guardian_user_export") @@ -509,12 +517,12 @@ public class SysUserController { } /** - * 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 + * 导入老师用户,接口同步校验整份 Excel,校验通过后返回异步任务供调用端轮询。 *

模板中的手机号作为登录账号,学号写入老师扩展表,老师身份单独保存为 TEACHER。

* * @param teacherUserExcelList Excel 中解析出的老师用户数据 - * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 + * @param bindingResult Excel 通用校验产生的错误信息 + * @return 校验失败时包含对应行号和错误原因;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-teacher") @HasPermission("sys_teacher_user_export") @@ -524,12 +532,12 @@ public class SysUserController { } /** - * 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。 + * 导入用户及 Excel 中内嵌的人脸图片,接口同步校验并复制图片字节,校验通过后返回异步任务供调用端轮询。 * - * @param file 原始 Excel 文件,用于读取内嵌的人脸图片 - * @param excelList Excel 中解析出的用户数据 + * @param file 原始 Excel 文件,用于读取内嵌的人脸图片 + * @param excelList Excel 中解析出的用户数据 * @param bindingResult Excel 通用校验产生的错误信息 - * @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 + * @return 校验失败时包含校验或图片处理错误;校验通过时包含异步任务 ID 和初始进度 */ @PostMapping("/import-face") @HasPermission("sys_user_import") @@ -635,6 +643,12 @@ public class SysUserController { return R.ok(userService.list(Wrappers.lambdaQuery().in(SysUser::getUserId, userIds))); } + /** + * 按用户 ID 批量查询用户、部门及身份协议编码。 + * + * @param userIds 待查询用户 ID 列表 + * @return 用户、部门和身份资料;不存在的用户不返回记录 + */ @PostMapping("/user-dept") public R> getUserDeptByIds(@RequestBody List userIds) { return R.ok(userService.getUserDeptByIds(userIds)); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java index ee2128f9..84defee7 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserIdentityService.java @@ -41,6 +41,13 @@ public interface SysUserIdentityService extends IService */ void fillUserIdentity(UserVO userVo); + /** + * 仅批量填充用户身份协议编码,不返回学生、老师扩展资料。 + * + * @param userVos 已查询到基础数据的用户展示对象列表 + */ + void fillUserIdentityTypes(List userVos); + /** * 批量填充用户身份及对应扩展信息,供分页列表避免逐用户查询。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index 91aaac01..6a20ba1e 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -207,6 +207,12 @@ public interface SysUserService extends IService { */ R resetPasswordBySms(ForgotPasswordDTO forgotPasswordDTO); + /** + * 按用户 ID 批量查询用户、部门及身份协议编码,不应用当前操作者数据权限筛选。 + * + * @param userIds 待查询用户 ID 列表 + * @return 与有效用户 ID 对应的用户、部门和身份资料;不存在的用户不返回记录 + */ List getUserDeptByIds(List userIds); /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 7996b942..d81352d0 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -342,7 +342,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { + // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getUserVosPage(page, userDTO, dataScope); @@ -378,8 +379,8 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { - // 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。 + if (dataScope != null) { + // DataScope 继承 HashMap,不能使用集合非空判断;此查询只按所属部门过滤。 dataScope.setScopeUserName(null); } IPage list = baseMapper.getBasicUserPage(page, userDTO, dataScope); @@ -401,7 +402,7 @@ public class SysUserServiceImpl extends ServiceImpl impl */ @Override public IPage getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope) { - if (ObjectUtil.isNotEmpty(dataScope)) { + if (dataScope != null) { // 导出与列表使用相同的部门数据权限口径,不按创建人额外缩小用户范围。 dataScope.setScopeUserName(null); } @@ -1099,9 +1100,18 @@ public class SysUserServiceImpl extends ServiceImpl impl return baseMapper.selectList(Wrappers.lambdaQuery().in(SysUser::getDeptId, deptIdList)); } + /** + * 按用户 ID 批量查询用户、部门及身份协议编码,供跨服务业务校验和展示使用。 + * + * @param userIds 待查询用户 ID 列表 + * @return 与有效用户 ID 对应的用户、部门和身份资料 + */ @Override public List getUserDeptByIds(List userIds) { - return baseMapper.selectVoListByScope(new UserDTO(), userIds.toArray(new Long[0]), null); + List users = baseMapper.selectVoListByScope(new UserDTO(), userIds.toArray(new Long[0]), null); + // 跨服务订餐资格校验复用该批量接口,必须返回身份协议编码,避免各业务服务直接查询 UPMS 身份表。 + sysUserIdentityService.fillUserIdentityTypes(users); + return users; } @Override diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index 185a4ead..bdc0cd2f 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -181,6 +181,35 @@ public class SysUserIdentityServiceImpl extends ServiceImpl userVos) { + if (CollUtil.isEmpty(userVos)) { + return; + } + List userIds = userVos.stream() + .filter(userVo -> userVo != null && userVo.getUserId() != null) + .map(UserVO::getUserId) + .distinct() + .toList(); + if (CollUtil.isEmpty(userIds)) { + return; + } + Map> identityTypesByUserId = list(Wrappers.lambdaQuery() + .in(SysUserIdentityEntity::getUserId, userIds) + .orderByAsc(SysUserIdentityEntity::getCreateTime)) + .stream() + .collect(Collectors.groupingBy(SysUserIdentityEntity::getUserId, + Collectors.mapping(SysUserIdentityEntity::getIdentityType, Collectors.toList()))); + userVos.stream() + .filter(userVo -> userVo != null && userVo.getUserId() != null) + .forEach(userVo -> userVo.setIdentityTypes(identityTypesByUserId.getOrDefault(userVo.getUserId(), List.of()))); + } + /** * 分批查询身份、学生资料和老师资料,并按用户 ID 回填分页记录。 *

固定执行至多三次批量查询,避免用户列表随分页条数产生 N+1 查询。

@@ -201,12 +230,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl> identityTypesByUserId = list(Wrappers.lambdaQuery() - .in(SysUserIdentityEntity::getUserId, userIds) - .orderByAsc(SysUserIdentityEntity::getCreateTime)) - .stream() - .collect(Collectors.groupingBy(SysUserIdentityEntity::getUserId, - Collectors.mapping(SysUserIdentityEntity::getIdentityType, Collectors.toList()))); + fillUserIdentityTypes(userVos); Map studentByUserId = sysUserStudentService.listByIds(userIds).stream() .collect(Collectors.toMap(SysUserStudentEntity::getUserId, Function.identity(), (first, ignored) -> first)); Map teacherByUserId = sysUserTeacherService.listByIds(userIds).stream() @@ -216,8 +240,7 @@ public class SysUserIdentityServiceImpl extends ServiceImpl identityTypes = identityTypesByUserId.getOrDefault(userVo.getUserId(), List.of()); - userVo.setIdentityTypes(identityTypes); + List identityTypes = userVo.getIdentityTypes() == null ? List.of() : userVo.getIdentityTypes(); userVo.setStudentInfo(null); userVo.setTeacherInfo(null); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java index b8c86fbd..851f69f9 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserRelationServiceImpl.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationEntity; import com.bsd.pjcloud.admin.api.entity.user.SysUserRelationTypeEntity; +import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.admin.api.vo.user.SysUserGuardianWardOptionVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationGraphVo; import com.bsd.pjcloud.admin.api.vo.user.SysUserRelationRecipientVo; @@ -288,8 +289,12 @@ public class SysUserRelationServiceImpl extends ServiceImpl wardUserMap = sysUserService.listByIds(wardUserIds).stream() - .collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first, LinkedHashMap::new)); + List wardUsers = sysUserService.getUserDeptByIds(wardUserIds); + if (CollUtil.isEmpty(wardUsers)) { + return List.of(); + } + Map wardUserMap = wardUsers.stream() + .collect(Collectors.toMap(UserVO::getUserId, Function.identity(), (first, second) -> first, LinkedHashMap::new)); return wardUserIds.stream() .map(wardUserMap::get) .filter(Objects::nonNull) @@ -406,14 +411,15 @@ public class SysUserRelationServiceImpl extends ServiceImpl merchantIdList; private Long walletTypeId; private List userIds; + /** 勾选导出的开卡记录主键集合;为空时按页面其余筛选条件导出全部匹配记录。 */ + private List ids; + + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private Boolean isInclude; private List deptIds; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java index 15ef3972..f74a5a41 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletCodeUseDto.java @@ -1,13 +1,30 @@ package com.bsd.pjcloud.wallet.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.wallet.api.entity.WalletCodeUseEntity; import lombok.Data; import java.util.List; +/** 二维码使用记录查询条件,支持按申请用户及其身份资料筛选。 */ @Data -public class WalletCodeUseDto extends WalletCodeUseEntity { +public class WalletCodeUseDto extends WalletCodeUseEntity implements BaseUserIdentityQueryInterface { + /** 身份预筛选后用于二维码记录查询的用户主键集合。 */ private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletForeignDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletForeignDto.java index 5e10aa07..8a684b8e 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletForeignDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletForeignDto.java @@ -1,17 +1,30 @@ package com.bsd.pjcloud.wallet.api.dto; -import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.WalletForeignEntity; import lombok.Data; import java.util.List; @Data -public class WalletForeignDto extends WalletForeignEntity { +public class WalletForeignDto extends WalletForeignEntity implements BaseUserIdentityQueryInterface { private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private List deptIds; private Boolean isFindCheckUser = false; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletFreezeDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletFreezeDto.java index dc7b04ea..be846ef1 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletFreezeDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletFreezeDto.java @@ -1,12 +1,15 @@ package com.bsd.pjcloud.wallet.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.wallet.api.entity.WalletFreezeEntity; import lombok.Data; import java.util.List; @Data -public class WalletFreezeDto extends WalletFreezeEntity { +public class WalletFreezeDto extends WalletFreezeEntity implements BaseUserIdentityQueryInterface { private Long walletTypeId; @@ -14,6 +17,18 @@ public class WalletFreezeDto extends WalletFreezeEntity { private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private Boolean isFindCheckUser = false; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java index 10cb177e..aff2e849 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageBalanceDto.java @@ -1,12 +1,16 @@ package com.bsd.pjcloud.wallet.api.dto; 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 lombok.Data; import java.util.List; +/** 钱包记录余额查询条件,身份字段用于按用户身份快速筛选记录。 */ @Data -public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface { +public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { private String walletRecordMonth; @@ -22,4 +26,16 @@ public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface { private List scopeDeptIds; + /** 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageDto.java index c2611195..79e59223 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletManageDto.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.wallet.api.dto; 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.wallet.api.entity.WalletManageEntity; import lombok.Data; @@ -8,8 +11,9 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +/** 钱包管理后台查询条件,身份字段用于按用户身份快速筛选钱包。 */ @Data -public class WalletManageDto extends WalletManageEntity implements BaseIsIncludeTreeInterface { +public class WalletManageDto extends WalletManageEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { private List userIds; @@ -27,4 +31,16 @@ public class WalletManageDto extends WalletManageEntity implements BaseIsInclude */ private Boolean isInclude; -} \ No newline at end of file + /** 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; + +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStrideDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStrideDto.java index 032900d9..30fa7244 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStrideDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletStrideDto.java @@ -1,15 +1,30 @@ package com.bsd.pjcloud.wallet.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.wallet.api.entity.WalletStrideEntity; import lombok.Data; import java.util.List; @Data -public class WalletStrideDto extends WalletStrideEntity { +public class WalletStrideDto extends WalletStrideEntity implements BaseUserIdentityQueryInterface { private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private List deptIds; private Boolean isFindCheckUser = false; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java index b63a5612..727f2bb4 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTopupActionDto.java @@ -1,12 +1,15 @@ package com.bsd.pjcloud.wallet.api.dto; import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +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 lombok.Data; import java.util.List; @Data -public class WalletTopupActionDto implements BaseIsIncludeTreeInterface { +public class WalletTopupActionDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** * 用户id @@ -28,4 +31,19 @@ public class WalletTopupActionDto implements BaseIsIncludeTreeInterface { */ private Boolean isInclude; + /** 身份协议编码筛选条件;为空时不限制用户身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件;为空或字段为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件;为空或字段为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态筛选条件:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; + + /** 经过 UPMS 身份筛选后的用户主键集合,供钱包 SQL 使用。 */ + private List userIds; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java index 4a98183d..4131053c 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/WalletTransactionDto.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.wallet.api.dto; 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.wallet.api.entity.WalletTransactionEntity; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -8,8 +11,9 @@ import lombok.Data; import java.time.LocalDate; import java.util.List; +/** 钱包流水后台查询条件,身份字段沿用 UPMS 用户查询契约。 */ @Data -public class WalletTransactionDto extends WalletTransactionEntity implements BaseIsIncludeTreeInterface { +public class WalletTransactionDto extends WalletTransactionEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** * 类型 - 收入 @@ -71,4 +75,16 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas */ private List priceList; + /** 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件;字段语义由 UPMS 用户查询统一解释。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件;老师学号为空时不限制。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeApplyDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeApplyDto.java index fc072b9d..187ab4f3 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeApplyDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeApplyDto.java @@ -1,5 +1,8 @@ package com.bsd.pjcloud.wallet.api.dto.recharge; +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.wallet.api.entity.recharge.WalletRechargeApplyEntity; import lombok.Data; @@ -7,10 +10,22 @@ import java.time.LocalDateTime; import java.util.List; @Data -public class WalletRechargeApplyDto extends WalletRechargeApplyEntity { +public class WalletRechargeApplyDto extends WalletRechargeApplyEntity implements BaseUserIdentityQueryInterface { private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private List walletTypeIds; /** diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeBindInvoiceDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeBindInvoiceDto.java index 4141081e..592015b2 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeBindInvoiceDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeBindInvoiceDto.java @@ -1,5 +1,8 @@ package com.bsd.pjcloud.wallet.api.dto.recharge; +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.wallet.api.entity.recharge.WalletRechargeInvoiceEntity; import lombok.Data; @@ -10,7 +13,7 @@ import java.util.List; * 充值记录绑定发票 */ @Data -public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity { +public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity implements BaseUserIdentityQueryInterface { /** * 充值/扣款申请id @@ -22,6 +25,18 @@ public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity { */ private List userIds; + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + /** * 创建时间 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java index 32b451d6..9edac9c0 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/recharge/WalletRechargeImportDto.java @@ -1,14 +1,18 @@ package com.bsd.pjcloud.wallet.api.dto.recharge; 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.wallet.api.entity.recharge.WalletRechargeImportEntity; import lombok.Data; import java.time.LocalDateTime; import java.util.List; +/** 充值水单查询条件,身份字段用于筛选充值对应用户。 */ @Data -public class WalletRechargeImportDto extends WalletRechargeImportEntity implements BaseIsIncludeTreeInterface { +public class WalletRechargeImportDto extends WalletRechargeImportEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { private String processStatus; @@ -16,6 +20,18 @@ public class WalletRechargeImportDto extends WalletRechargeImportEntity implemen private List userIds; + /** 用户身份协议编码集合;为空表示不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展筛选条件。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展筛选条件。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */ + private String hasFace; + private List userUniqueIds; /** diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/rule/WalletRuleTransactionDto.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/rule/WalletRuleTransactionDto.java index a9f5e960..71743763 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/rule/WalletRuleTransactionDto.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/rule/WalletRuleTransactionDto.java @@ -1,6 +1,9 @@ package com.bsd.pjcloud.wallet.api.dto.rule; +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.BaseIsIncludeTreeInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface; import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity; import lombok.Data; @@ -9,7 +12,7 @@ import java.time.LocalDateTime; import java.util.List; @Data -public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implements BaseIsIncludeTreeInterface { +public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface { /** * 树状结构搜索 @@ -20,6 +23,21 @@ public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implem private Long userId; + /** 身份预筛选后用于钱包转账流水查询的用户主键集合。 */ + private List userIds; + + /** UPMS 用户身份协议编码集合;为空时不限制身份。 */ + private List identityTypes; + + /** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */ + private SysUserTeacherDto teacherInfo; + + /** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */ + private String hasFace; + private LocalDate[] createTimeList; private LocalDate[] refundTimeList; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java index 1b6736d4..c818e448 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java @@ -177,6 +177,13 @@ public class MchManageEntity extends Model { @TableField(typeHandler = MchManageJacksonTypeHandler.class) private TableReservationConf tableReservationConf; + /** + * 充值配置 + */ + @Schema(description = "充值配置") + @TableField(typeHandler = MchManageJacksonTypeHandler.class) + private TopUpConfig topUpConfig; + /** * 钱包别名 */ @@ -258,6 +265,11 @@ public class MchManageEntity extends Model { * 允许代订餐的用户关系编码,对应 sys_user_relation_type 字典和 UserRelationTypeEnum。 */ private List userRelationTypeIdList; + + /** + * 允许订餐的用户身份协议编码,对应 user_identity_type 字典和 UserIdentityTypeEnum;为空时不限制身份。 + */ + private List userIdentityTypeList; } @Data @@ -313,4 +325,17 @@ public class MchManageEntity extends Model { } + /** + * 充值配置 + */ + @Data + public static class TopUpConfig { + + /** + * 允许使用钱包充值能力的用户身份协议编码列表。 + * 值对应 user_identity_type 字典及 UserIdentityTypeEnum;为空时不限制用户身份,以兼容历史配置。 + */ + private List userIdentityTypeList; + } + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java index 7d87b4e0..13d7397a 100755 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchPaySettingEntity.java @@ -56,12 +56,6 @@ public class MchPaySettingEntity extends Model { @Schema(description = "支付json配置") private String settingJson; - /** - * 平台抽佣比例,单位为百分比,取值范围为大于0且不超过100。 - */ - @Schema(description = "平台抽佣比例,单位为百分比,取值范围为(0,100]") - private Double fzRatio; - /** * 创建人 */ @@ -108,7 +102,7 @@ public class MchPaySettingEntity extends Model { static public class WechatPaySetting { /** - * 子商户号 + * 订单创建时从分账关系复制的微信子商户号;不得写回 mch_pay_setting.setting_json。 */ private String subMchid; @@ -117,17 +111,17 @@ public class MchPaySettingEntity extends Model { @Data static public class AliPaySetting { /** - * 支付宝直付通二级商户标识,保存支付宝分配的 smid/ip_role_id,不是平台应用商户号。 + * 订单创建时从分账关系复制的支付宝 smid/ip_role_id;不得写回支付配置。 */ private String subMchid; /** - * 支付宝第三方代调用授权令牌;无需代调用授权的账号允许为空。 + * 订单创建时从分账关系复制的第三方代调用授权令牌;无需代调用授权时为空。 */ private String appAuthToken; /** - * 平台支付宝命名账号键,来源于现有支付配置 JSON;为空时使用平台默认账号。 + * 订单创建时从分账关系复制的平台支付宝命名账号键;为空时使用平台默认账号。 */ private String accountKey; } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java index 56595767..5fdca61a 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteMchService.java @@ -14,60 +14,190 @@ import org.springframework.web.bind.annotation.RequestParam; import java.util.List; +/** + * 钱包服务商户管理远程调用接口。 + * + *

用于跨服务查询商户详情、部门与商户映射关系,以及当前用户可管理的商户范围。 + * 调用方必须同时检查 {@link R} 的业务状态和数据内容,不能仅以 Feign 调用未抛异常作为成功依据。

+ */ @FeignClient(contextId = "remoteMchService", value = ServiceNameConstants.WALLET_SERVICE) public interface RemoteMchService { + /** + * 按商户条件查询列表,不应用当前登录用户的数据权限。 + * + * @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选 + * @return 商户列表响应;未匹配到数据时响应成功且数据可能为空 + */ @NoToken @PostMapping("/mch-manage/list") R> list(@RequestBody MchManageDto body); + /** + * 按商户条件查询当前登录用户数据权限范围内的商户列表。 + * + * @param body 商户筛选条件,可按主键、部门、类型、父商户等字段筛选 + * @return 权限范围内的商户列表响应;未匹配到数据时响应成功且数据可能为空 + */ @PostMapping("/mch-manage/scope-list") R> scopeList(@RequestBody MchManageDto body); + /** + * 按多个商户类型编码查询商户列表。 + * + * @param types 商户类型持久化编码集合,对应 {@link MerchantTypeEnum#getType()} + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-types") R> getByTypes(@RequestParam List types); + /** + * 按商户主键查询商户完整信息。 + * + *

返回内容包含部门名称、启用的支付配置以及钱包别名等关联信息。

+ * + * @param id 商户主键 + * @return 商户详情响应;商户不存在时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-id") R getById(@RequestParam Long id); + /** + * 查询指定父商户的直属子商户。 + * + * @param parentId 父商户主键 + * @return 直属子商户列表响应;没有子商户时返回成功的空集合 + */ @NoToken @GetMapping("/mch-manage/get-by-parent-id") R> getByParentId(@RequestParam Long parentId); + /** + * 按商户主键集合批量查询商户。 + * + * @param ids 商户主键集合 + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-ids") R> getByIds(@RequestParam List ids); + /** + * 查询直接绑定在指定部门上的指定类型商户。 + * + *

该方法只匹配商户自身绑定的部门,不沿部门父级或子级关系查找。

+ * + * @param deptId 商户直接绑定的系统部门主键 + * @param type 商户类型枚举 + * @return 匹配的商户响应;商户不存在时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-dept-id") R getByDeptId(@RequestParam("deptId") Long deptId, @RequestParam("type") MerchantTypeEnum type); + /** + * 按商户直接绑定的部门主键集合批量查询商户。 + * + * @param deptIds 系统部门主键集合 + * @return 匹配的商户列表响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-by-dept-ids") R> getByDeptIds(@RequestParam List deptIds); + /** + * 按传入部门顺序查询第一个直接绑定且类型匹配的商户。 + * + *

该方法不会自行查询部门层级,调用方需要传入已经按业务优先级排列的部门集合。

+ * + * @param deptIds 待匹配的系统部门主键集合,集合顺序决定商户匹配优先级 + * @param mchType 允许匹配的商户类型 + * @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-first-mch") R getFirstMch(@RequestParam List deptIds, @RequestParam MerchantTypeEnum mchType); + /** + * 沿指定部门的父级链查询第一个类型匹配的商户。 + * + *

服务端先解析部门父级链,再按部门链顺序选择直接绑定的商户;返回内容包含启用的支付配置。

+ * + * @param deptId 起始系统部门主键 + * @param mchType 允许匹配的商户类型 + * @return 首个匹配商户的响应;没有匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-first-mch-by-dept-id") R getFirstMchByDeptId(@RequestParam Long deptId, @RequestParam MerchantTypeEnum mchType); + /** + * 通过商户部门关系视图查询指定部门归属的食堂商户。 + * + *

可从商户绑定部门的下级部门反向定位食堂商户,并优先返回关系层级最近的商户; + * 返回内容包含启用的支付配置。

+ * + * @param deptId 待解析归属关系的系统部门主键 + * @return 归属食堂商户响应;不存在归属食堂时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-views-canteen-mch-by-dept-id") R getViewsCanteenMchByDeptId(@RequestParam Long deptId); + /** + * 通过商户部门关系视图查询指定部门归属的指定类型商户。 + * + *

可从商户绑定部门的下级部门反向定位商户,并优先返回关系层级最近的匹配项; + * 返回内容包含启用的支付配置。

+ * + * @param deptId 待解析归属关系的系统部门主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 归属商户响应;不存在匹配商户时返回业务失败响应 + */ @NoToken @GetMapping("/mch-manage/get-views-mch-by-dept-id") R getViewsMchByDeptId(@RequestParam Long deptId, @RequestParam List mchTypeList); + /** + * 查询指定食堂商户覆盖的用户部门主键集合。 + * + *

服务端以商户直接绑定部门及其下级部门为候选范围,再通过商户部门关系视图筛选 + * 与该商户类型关联的用户部门。商户不存在或没有关联部门时返回业务失败响应。

+ * + * @param mchId 食堂商户主键 + * @return 去重后的用户部门主键集合响应 + */ @NoToken @GetMapping("/mch-manage/get-self-canteen-user-dept-id-list") R> getSelfCanteenUserDeptIdList(@RequestParam Long mchId); + /** + * 查询指定用户所属目标类型商户覆盖的用户部门主键集合。 + * + *

钱包服务内部先根据用户所属部门和商户类型定位商户,再复用商户部门范围查询逻辑, + * 调用方只需发起一次远程调用。用户、所属部门、匹配商户或关联部门不存在时返回业务失败响应。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 去重后的目标商户用户部门主键集合响应 + */ + @NoToken + @GetMapping("/mch-manage/get-self-mch-user-dept-id-list-by-user-id") + R> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId, @RequestParam List mchTypeList); + + /** + * 查询指定用户可管理的指定类型商户主键集合。 + * + *

服务端以用户所属部门及其下级部门作为权限范围,通过商户部门关系视图匹配商户。 + * 当用户信息不存在或没有匹配商户时,当前实现返回包含 {@code -1L} 的占位集合,调用方 + * 不应将该值作为有效商户主键使用。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 用户可管理的商户主键集合响应;无匹配数据时可能仅包含 {@code -1L} + */ @NoToken @GetMapping("/mch-manage/get-self-mch-id-list") R> getSelfMchIdList(@RequestParam Long userId, @RequestParam List mchTypeList); diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java index 5b181214..9416eb5d 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletRechargeService.java @@ -38,6 +38,16 @@ public interface RemoteWalletRechargeService { @PostMapping("/wallet-recharge/refund") R refund(@RequestBody WalletRechargeRefundDto dto); + /** + * 冲正人工审核退款,将已入账金额从原钱包强制扣回;重复请求按钱包退款状态幂等返回。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包退款已冲正时返回 {@code true} + */ + @NoToken + @PostMapping("/wallet-recharge/refund/rollback/{afterSaleId}") + R rollbackRefund(@PathVariable("afterSaleId") Long afterSaleId); + /** * 按售后单 ID 查询已经持久化的退款记录,用于 Wallet 已到账但 Hardware 本地事务回滚后的恢复判断。 * diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletActiveVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletActiveVo.java index 9fe4c4cc..95f5743b 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletActiveVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletActiveVo.java @@ -1,15 +1,20 @@ package com.bsd.pjcloud.wallet.api.vo; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.WalletActiveEntity; import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; +import java.util.List; + @Data -public class WalletActiveVo extends WalletActiveEntity implements BaseUserInterface, BaseDeptInterface, BaseMchInterface, BaseWalletTypeInterface { +public class WalletActiveVo extends WalletActiveEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseWalletTypeInterface { /** * 用户相关 */ @@ -17,8 +22,26 @@ public class WalletActiveVo extends WalletActiveEntity implements BaseUserInterf 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-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java index f3a2c34c..67c734c5 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletCodeUseVo.java @@ -1,19 +1,47 @@ package com.bsd.pjcloud.wallet.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.admin.api.vo.base.BaseUserInterface; import com.bsd.pjcloud.wallet.api.entity.WalletCodeUseEntity; import lombok.Data; -@Data -public class WalletCodeUseVo extends WalletCodeUseEntity implements BaseUserInterface { +import java.util.List; - /** - * 用户相关 - */ +/** 二维码使用记录展示对象,包含申请用户主资料、部门和身份信息。 */ +@Data +public class WalletCodeUseVo extends WalletCodeUseEntity implements BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface { + + /** 申请用户姓名,保留历史接口字段名。 */ private String username; + /** 申请用户手机号,保留历史接口字段名。 */ private String userMobile; + /** 申请用户头像地址,保留历史接口字段名。 */ private String userAvatar; + /** 用户标准手机号字段,供统一身份组件展示。 */ + private String phone; + + /** 用户当前所属部门主键,由 UPMS 用户资料回填。 */ + private Long userDeptId; + + /** 用户当前所属部门名称,由 UPMS 用户资料回填。 */ + private String userDeptName; + + /** 用户所属部门展示名称,供统一用户信息组件读取。 */ + private String deptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + } diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletForeignVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletForeignVo.java index 36e4eddb..787bffee 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletForeignVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletForeignVo.java @@ -1,12 +1,17 @@ package com.bsd.pjcloud.wallet.api.vo; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.WalletForeignEntity; import lombok.Data; +import java.util.List; + @Data -public class WalletForeignVo extends WalletForeignEntity implements BaseUserInterface, BaseDeptInterface { +public class WalletForeignVo extends WalletForeignEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface { /** * 用户相关 @@ -15,8 +20,26 @@ public class WalletForeignVo extends WalletForeignEntity 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; + /** * 部门相关 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletFreezeVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletFreezeVo.java index 45d3b4d4..a22c6235 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletFreezeVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletFreezeVo.java @@ -1,12 +1,17 @@ package com.bsd.pjcloud.wallet.api.vo; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.WalletFreezeEntity; import lombok.Data; +import java.util.List; + @Data -public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserInterface, BaseDeptInterface { +public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface { /** * 用户相关 */ @@ -14,8 +19,26 @@ public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserInterf 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-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java index 26c9536f..40131385 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletManageVo.java @@ -1,7 +1,10 @@ package com.bsd.pjcloud.wallet.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.wallet.api.entity.WalletManageEntity; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; @@ -9,9 +12,9 @@ import lombok.Data; import java.time.LocalDateTime; import java.util.List; - +/** 钱包管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class WalletManageVo extends WalletManageEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface { +public class WalletManageVo extends WalletManageEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface { /** * 钱包类型 @@ -33,13 +36,65 @@ public class WalletManageVo extends WalletManageEntity implements BaseUserInterf private String userMobile; + /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ + private String phone; + private String userAvatar; + /** 用户身份协议编码集合;没有身份时为空集合或空值。 */ + private List identityTypes; + + /** 学生身份扩展资料;没有学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展资料;没有老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + /** * 部门相关 */ private String deptName; + /** + * 获取用户当前部门主键,沿用钱包实体的部门字段。 + * + * @return 钱包记录关联的用户部门主键;未记录时为空 + */ + @Override + public Long getUserDeptId() { + return getDeptId(); + } + + /** + * 设置用户当前部门主键,沿用钱包实体的部门字段。 + * + * @param userDeptId UPMS 返回的用户当前部门主键 + */ + @Override + public void setUserDeptId(Long userDeptId) { + setDeptId(userDeptId); + } + + /** + * 获取用户当前部门名称。 + * + * @return 用户当前部门名称;未分配时为空 + */ + @Override + public String getUserDeptName() { + return deptName; + } + + /** + * 设置用户当前部门名称。 + * + * @param userDeptName UPMS 返回的用户当前部门名称 + */ + @Override + public void setUserDeptName(String userDeptName) { + this.deptName = userDeptName; + } + /** * 导出相关 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletStrideVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletStrideVo.java index f4223a94..2fc42ca8 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletStrideVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/WalletStrideVo.java @@ -1,12 +1,17 @@ package com.bsd.pjcloud.wallet.api.vo; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.WalletStrideEntity; import lombok.Data; +import java.util.List; + @Data -public class WalletStrideVo extends WalletStrideEntity implements BaseUserInterface, BaseDeptInterface { +public class WalletStrideVo extends WalletStrideEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface { /** * 用户相关 @@ -15,8 +20,26 @@ public class WalletStrideVo extends WalletStrideEntity implements BaseUserInterf 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-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java new file mode 100644 index 00000000..ef5088c8 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletActiveExportExcelVo.java @@ -0,0 +1,50 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +import com.alibaba.excel.annotation.ExcelProperty; +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 lombok.EqualsAndHashCode; + +/** + * 钱包开卡记录导出行,按用户、身份、商户和饭卡组织中文二级表头。 + * + *

用户与身份资料由 UPMS 完整导出接口按分片补齐;未绑定用户的空闲饭卡保留空白用户列, + * 业务列仍输出商户、卡号、绑定和硬件激活状态,便于开卡人员核对库存。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class WalletActiveExportExcelVo extends WalletUserExportExcelVo { + + /** 当前开卡记录在本次导出结果中的一基序号。 */ + @ExcelProperty(value = {"开卡", "序号"}, index = 10) + private String lineNumber; + + /** 开卡记录所属商户名称;商户资料已经删除或无法匹配时为空。 */ + @ExcelProperty(value = {"商户", "所属商户"}, index = 11) + private String merchantName; + + /** 开卡记录保存的饭卡卡号。 */ + @ExcelProperty(value = {"饭卡", "用户卡号"}, index = 12) + @ColumnWidth(22) + private String cardNumber; + + /** 饭卡是否同时关联用户和用户钱包,使用“是/否”展示。 */ + @ExcelProperty(value = {"饭卡", "用户绑定状态"}, index = 13) + private String bindStatus; + + /** 饭卡是否已经在硬件设备完成激活,未知历史编码保留原值。 */ + @ExcelProperty(value = {"饭卡", "硬件激活状态"}, index = 14) + private String hardwareActiveStatus; + + /** 开卡记录创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"开卡", "创建时间"}, index = 15) + @ColumnWidth(22) + private String createTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletManageExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletManageExportExcelVo.java new file mode 100644 index 00000000..54ba1636 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletManageExportExcelVo.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +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 lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * 钱包账户余额及月度钱包记录导出行。 + * + *

账户余额导出使用当前累计金额,月度记录导出使用指定月份末的历史余额;两种场景共享完整用户、 + * 身份和钱包二级表头,不依赖固定 Excel 模板。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class WalletManageExportExcelVo extends WalletUserExportExcelVo { + + /** 当前钱包在本次导出结果中的一基序号。 */ + @ExcelProperty(value = {"钱包", "序号"}, index = 10) + private String lineNumber; + + /** 月度记录查询月份,账户当前余额导出时为空。 */ + @ExcelProperty(value = {"钱包", "统计月份"}, index = 11) + private String recordMonth; + + /** 钱包类型中文名称;类型资料缺失时为空。 */ + @ExcelProperty(value = {"钱包", "钱包类型"}, index = 12) + private String walletTypeName; + + /** 钱包实体卡号;未绑定实体卡时为空。 */ + @ExcelProperty(value = {"钱包", "卡号"}, index = 13) + private String cardNumber; + + /** 当前或指定月份末钱包余额,单位为元并保留两位小数。 */ + @ExcelProperty(value = {"金额", "余额(元)"}, index = 14) + @NumberFormat("#,##0.00") + private BigDecimal balance; + + /** 钱包累计消费金额,单位为元并保留两位小数。 */ + @ExcelProperty(value = {"金额", "累计消费(元)"}, index = 15) + @NumberFormat("#,##0.00") + private BigDecimal totalExpense; + + /** 钱包累计充值金额,单位为元并保留两位小数。 */ + @ExcelProperty(value = {"金额", "累计充值(元)"}, index = 16) + @NumberFormat("#,##0.00") + private BigDecimal totalRecharge; + + /** 钱包冻结状态中文名称;空值表示历史数据未记录。 */ + @ExcelProperty(value = {"钱包", "冻结状态"}, index = 17) + private String freezeStatus; + + /** 指定月份末余额对应的最后流水时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"钱包", "最后变动时间"}, index = 18) + @ColumnWidth(22) + private String lastTopUpTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargeImportExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargeImportExportExcelVo.java new file mode 100644 index 00000000..abe5efbd --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargeImportExportExcelVo.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +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 lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * 批量充值扣款水单导出行,按用户、身份、审批和金额组织二级表头。 + * + *

用户与身份资料由 UPMS 完整导出接口按批补齐,金额统一转换为元;扣款和食堂退还场景保持负数, + * 以便财务人员直接汇总。业务类型仅参与后台金额方向计算,不写入财务导出工作簿。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class WalletRechargeImportExportExcelVo extends WalletUserExportExcelVo { + + /** 当前水单明细在本次导出结果中的一基序号。 */ + @ExcelProperty(value = {"水单", "序号"}, index = 10) + private String lineNumber; + + /** 批量充值或扣款所属月份,格式为 yyyy-MM。 */ + @ExcelProperty(value = {"水单", "业务月份"}, index = 11) + private String importMonth; + + /** 钱包类型中文名称;类型资料缺失时为空。 */ + @ExcelProperty(value = {"钱包", "钱包类型"}, index = 12) + private String walletTypeName; + + /** 操作前钱包金额,单位为元并保留两位小数。 */ + @ExcelProperty(value = {"金额", "操作前金额(元)"}, index = 13) + @NumberFormat("#,##0.00") + private BigDecimal beforePrice; + + /** 本次操作金额,单位为元并保留两位小数;扣款和退还场景为负数。 */ + @ExcelProperty(value = {"金额", "操作金额(元)"}, index = 14) + @NumberFormat("#,##0.00") + private BigDecimal price; + + /** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */ + @ExcelProperty(value = {"水单", "财务标签"}, index = 15) + private String tags; + + /** 审批流程状态原始字典值,便于与管理端字典核对。 */ + @ExcelProperty(value = {"审批", "审批状态"}, index = 16) + private String processStatus; + + /** 审批处理时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"审批", "审批时间"}, index = 17) + @ColumnWidth(22) + private String processTime; + + /** 水单明细创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"水单", "创建时间"}, index = 18) + @ColumnWidth(22) + private String createTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java new file mode 100644 index 00000000..de14d46f --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletRechargePersonExportExcelVo.java @@ -0,0 +1,20 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 个人充值统计导出行。 + * + *

在钱包流水公共用户、身份和金额列基础上补充所属食堂,保留历史个人充值统计 + * 的财务核对维度;导出使用 EasyExcel 二级表头,不依赖固定模板。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class WalletRechargePersonExportExcelVo extends WalletTransactionExportExcelVo { + + /** 流水所属食堂名称,来源于钱包商户部门视图;无法匹配时为空。 */ + @ExcelProperty(value = {"食堂", "所属食堂"}, index = 19) + private String canteenName; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java new file mode 100644 index 00000000..ecdc94ee --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletTransactionExportExcelVo.java @@ -0,0 +1,67 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +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 lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * 钱包流水导出行,按用户、身份、钱包和流水业务域组织二级表头。 + * + *

全部流水、充值、记账扣款、消费、退款、食堂退还及个人充值统计共用该结构;金额统一以元展示。 + * 业务类型仅用于页面查询和列表展示,不写入财务导出工作簿。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@ColumnWidth(15) +@HeadRowHeight(15) +@HeadFontStyle(fontHeightInPoints = 10) +@ContentFontStyle(fontHeightInPoints = 10) +public class WalletTransactionExportExcelVo extends WalletUserExportExcelVo { + + /** 当前流水在本次导出结果中的一基序号。 */ + @ExcelProperty(value = {"流水", "序号"}, index = 10) + private String lineNumber; + + /** 钱包类型中文名称;类型资料缺失时为空。 */ + @ExcelProperty(value = {"钱包", "钱包类型"}, index = 11) + private String walletTypeName; + + /** 钱包卡号;未绑定实体卡时为空。 */ + @ExcelProperty(value = {"钱包", "卡号"}, index = 12) + private String cardNumber; + + /** 流水业务标题,来源于交易发生时保存的标题快照。 */ + @ExcelProperty(value = {"流水", "标题"}, index = 13) + private String title; + + /** 本次交易金额,单位为元并保留两位小数,支出保留负数。 */ + @ExcelProperty(value = {"流水", "交易金额(元)"}, index = 14) + @NumberFormat("#,##0.00") + private BigDecimal price; + + /** 交易完成后的钱包余额,单位为元并保留两位小数。 */ + @ExcelProperty(value = {"流水", "交易后余额(元)"}, index = 15) + @NumberFormat("#,##0.00") + private BigDecimal balance; + + /** 财务统计标签原始字典值,多个值使用英文逗号分隔。 */ + @ExcelProperty(value = {"流水", "财务标签"}, index = 16) + private String tags; + + /** 后台补充的流水业务备注;历史记录允许为空。 */ + @ExcelProperty(value = {"流水", "备注"}, index = 17) + @ColumnWidth(24) + private String remark; + + /** 流水创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */ + @ExcelProperty(value = {"流水", "交易时间"}, index = 18) + @ColumnWidth(22) + private String createTime; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletUserExportExcelVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletUserExportExcelVo.java new file mode 100644 index 00000000..c06365a1 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/export/WalletUserExportExcelVo.java @@ -0,0 +1,54 @@ +package com.bsd.pjcloud.wallet.api.vo.export; + +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.Data; + +/** + * 钱包业务用户导出的公共用户与身份列。 + * + *

用户资料统一来源于 UPMS 完整导出接口,业务数据只在用户已经删除或历史资料缺失时作为展示回退。 + * 该对象仅定义二级表头的公共列,不参与数据库持久化和业务接口传输。

+ */ +@Data +public abstract class WalletUserExportExcelVo { + + /** 用户姓名,优先使用 UPMS 姓名,其次使用钱包业务历史展示名。 */ + @ExcelProperty(value = {"用户", "姓名"}, index = 0) + private String userName; + + /** 用户绑定手机号,优先使用 UPMS 当前手机号。 */ + @ExcelProperty(value = {"用户", "手机号"}, index = 1) + private String phone; + + /** 用户当前所属部门名称,UPMS 资料缺失时回退业务记录中的部门名称。 */ + @ExcelProperty(value = {"用户", "部门"}, index = 2) + private String deptName; + + /** 用户当前岗位名称,多个岗位使用中文逗号分隔。 */ + @ExcelProperty(value = {"用户", "岗位"}, index = 3) + private String postNames; + + /** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */ + @ExcelProperty(value = {"身份", "身份类型"}, index = 4) + private String identityTypeNames; + + /** 学生身份学号;用户没有学生身份时为空。 */ + @ExcelProperty(value = {"身份", "学号"}, index = 5) + private String studentNo; + + /** 学生身份学校名称;用户没有学生身份时为空。 */ + @ExcelProperty(value = {"身份", "学校"}, index = 6) + private String schoolName; + + /** 学生身份班级名称;用户没有学生身份时为空。 */ + @ExcelProperty(value = {"身份", "班级"}, index = 7) + private String className; + + /** 学生身份监护人手机号,多个号码沿用 UPMS 业务存储格式。 */ + @ExcelProperty(value = {"身份", "监护人手机号"}, index = 8) + private String guardianPhones; + + /** 老师身份工号或学号;用户没有老师身份时为空。 */ + @ExcelProperty(value = {"身份", "老师学号"}, index = 9) + private String teacherNo; +} diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeApplyVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeApplyVo.java index 4d06416c..3f697ec8 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeApplyVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeApplyVo.java @@ -1,13 +1,18 @@ package com.bsd.pjcloud.wallet.api.vo.recharge; import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface; -import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface; +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.wallet.api.entity.recharge.WalletRechargeApplyEntity; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; +import java.util.List; + @Data -public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface { +public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface { /** * 钱包类型相关 */ @@ -28,8 +33,26 @@ public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements 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-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeImportVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeImportVo.java index d864f22b..6d4b355f 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeImportVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeImportVo.java @@ -1,16 +1,21 @@ package com.bsd.pjcloud.wallet.api.vo.recharge; 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.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; +/** 充值水单管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class WalletRechargeImportVo extends WalletRechargeImportEntity implements BaseUserDeptInterface, BaseWalletTypeInterface, BaseUserInterface { +public class WalletRechargeImportVo extends WalletRechargeImportEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface, BaseUserInterface { /** * 用户相关 */ @@ -18,8 +23,20 @@ public class WalletRechargeImportVo extends WalletRechargeImportEntity implement private String userMobile; + /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ + private String phone; + private String userAvatar; + /** 用户身份协议编码集合;没有身份时为空集合或空值。 */ + private List identityTypes; + + /** 学生身份扩展资料;没有学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展资料;没有老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + private Long userDeptId; private String userDeptName; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeInvoiceVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeInvoiceVo.java index f57be8b9..2ddd1d5c 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeInvoiceVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/WalletRechargeInvoiceVo.java @@ -1,11 +1,47 @@ package com.bsd.pjcloud.wallet.api.vo.recharge; +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.wallet.api.entity.recharge.WalletRechargeInvoiceEntity; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.List; + @Data -public class WalletRechargeInvoiceVo extends WalletRechargeInvoiceEntity { +public class WalletRechargeInvoiceVo extends WalletRechargeInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface { + + /** 发票申请人的 UPMS 用户主键,来源于 apply_user_id 查询别名。 */ + private Long userId; + + /** 发票申请人的姓名,由 UPMS 用户资料回填。 */ + private String username; + + /** 发票申请人的手机号,由 UPMS 用户资料回填。 */ + private String userMobile; + + /** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */ + private String phone; + + /** 发票申请人的头像地址,由 UPMS 用户资料回填。 */ + private String userAvatar; + + /** 发票申请人的当前部门主键,由 UPMS 用户资料回填。 */ + private Long userDeptId; + + /** 发票申请人的当前部门名称,由 UPMS 用户资料回填。 */ + private String userDeptName; + + /** 用户拥有的 UPMS 身份协议编码集合。 */ + private List identityTypes; + + /** 用户的学生身份扩展资料;无学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 用户的老师身份扩展资料;无老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; private String totalPriceCnStr; diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java index 1993cba5..d089286c 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/recharge/app/GuardianWardRechargeItemVo.java @@ -2,6 +2,8 @@ package com.bsd.pjcloud.wallet.api.vo.recharge.app; import lombok.Data; +import java.util.List; + /** * 用户业务摘要列表项,兼容被监护人代办与本人及关系人列表,仅返回移动端展示所需的脱敏信息。 */ @@ -73,6 +75,11 @@ public class GuardianWardRechargeItemVo { */ private String disabledReason; + /** + * 目标用户拥有的身份协议编码,供移动端与目标食堂充值身份配置求交集。 + */ + private List identityTypes; + /** * 审核通过且当前可用的人脸数量。 */ diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/rule/WalletRuleTransactionVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/rule/WalletRuleTransactionVo.java index 4ca19430..3e0c0a56 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/rule/WalletRuleTransactionVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/rule/WalletRuleTransactionVo.java @@ -1,11 +1,16 @@ package com.bsd.pjcloud.wallet.api.vo.rule; +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.wallet.api.entity.rule.WalletRuleTransactionEntity; import lombok.Data; +import java.util.List; + @Data -public class WalletRuleTransactionVo extends WalletRuleTransactionEntity implements BaseUserDeptInterface { +public class WalletRuleTransactionVo extends WalletRuleTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface { /** * 用户相关 @@ -16,12 +21,24 @@ public class WalletRuleTransactionVo extends WalletRuleTransactionEntity impleme 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-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java index 51e6c6b6..6f95628f 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/vo/transaction/WalletTransactionVo.java @@ -1,16 +1,21 @@ package com.bsd.pjcloud.wallet.api.vo.transaction; +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.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface; import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; +/** 钱包流水管理端展示对象,包含公共用户主资料、部门及身份信息。 */ @Data -public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface, BaseWalletInterface { +public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface, BaseWalletInterface { /** * 字符串金额用于导出 @@ -26,8 +31,20 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base private String userMobile; + /** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */ + private String phone; + private String userAvatar; + /** 用户身份协议编码集合;没有身份时为空集合或空值。 */ + private List identityTypes; + + /** 学生身份扩展资料;没有学生身份时为空。 */ + private SysUserStudentDto studentInfo; + + /** 老师身份扩展资料;没有老师身份时为空。 */ + private SysUserTeacherDto teacherInfo; + /** * 部门相关 */ @@ -35,6 +52,46 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base private String deptName; + /** + * 获取用户当前部门主键,沿用流水中的部门快照字段。 + * + * @return 用户部门主键;未记录时为空 + */ + @Override + public Long getUserDeptId() { + return deptId; + } + + /** + * 设置用户当前部门主键并写入流水展示字段。 + * + * @param userDeptId UPMS 返回的用户部门主键 + */ + @Override + public void setUserDeptId(Long userDeptId) { + this.deptId = userDeptId; + } + + /** + * 获取用户当前部门名称。 + * + * @return 用户部门名称;未分配时为空 + */ + @Override + public String getUserDeptName() { + return deptName; + } + + /** + * 设置用户当前部门名称。 + * + * @param userDeptName UPMS 返回的用户部门名称 + */ + @Override + public void setUserDeptName(String userDeptName) { + this.deptName = userDeptName; + } + /** * 钱包相关 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java index e977cb01..985e44d5 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletActiveController.java @@ -2,7 +2,6 @@ package com.bsd.pjcloud.wallet.controller; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; -import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -14,6 +13,7 @@ import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.core.validation.Group; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.excel.annotation.RequestExcel; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -27,9 +27,10 @@ import com.bsd.pjcloud.wallet.api.vo.WalletActiveVo; import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletActiveService; import com.bsd.pjcloud.wallet.service.WalletActiveImportService; +import com.bsd.pjcloud.wallet.service.WalletUserExportService; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; -import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.HttpHeaders; @@ -59,6 +60,8 @@ public class WalletActiveController { private final WalletActiveService walletActiveService; /** 钱包开卡 Excel 导入服务,隔离默认和学生模板的校验及异步编排。 */ private final WalletActiveImportService walletActiveImportService; + /** 钱包用户导出服务,负责开卡记录分片查询、中文二级表头和异步进度。 */ + private final WalletUserExportService walletUserExportService; private final MchManageService mchManageService; private final RemoteDeptService remoteDeptService; @@ -227,11 +230,28 @@ public class WalletActiveController { return R.ok(walletActiveService.removeWithWalletCardByIds(CollUtil.toList(ids))); } - @ResponseExcel + /** + * 使用中文二级表头同步分片导出开卡记录,保留旧 GET 地址供既有调用方兼容。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + */ @GetMapping("/export") @PreAuthorize("@pms.hasPermission('wallet_walletActive_export')") - public List export(WalletActiveEntity walletActive, Long[] ids) { - return walletActiveService.list(Wrappers.lambdaQuery(walletActive).in(ArrayUtil.isNotEmpty(ids), WalletActiveEntity::getId, ids)); + public void export(HttpServletResponse response, @ParameterObject WalletActiveDto dto) { + walletUserExportService.exportWalletActive(response, dto); + } + + /** + * 提交钱包开卡记录异步分片导出任务,由后台补齐用户身份并实时上报生成进度。 + * + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-async") + @PreAuthorize("@pms.hasPermission('wallet_walletActive_export')") + public R submitExport(@RequestBody WalletActiveDto dto) { + return R.ok(walletUserExportService.submitWalletActiveExport(dto), "钱包开卡导出任务已提交"); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java index 43a92a41..b5937363 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java @@ -25,8 +25,8 @@ import com.bsd.pjcloud.wallet.api.vo.WalletQbTotalVo; import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; import com.bsd.pjcloud.wallet.mapper.mch.view.MchDeptViewMapper; import com.bsd.pjcloud.wallet.service.WalletCodeUseService; -import com.bsd.pjcloud.wallet.service.WalletManageExportService; import com.bsd.pjcloud.wallet.service.WalletManageService; +import com.bsd.pjcloud.wallet.service.WalletUserExportService; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springdoc.core.annotations.ParameterObject; @@ -51,8 +51,8 @@ public class WalletManageController { private final WalletManageService walletManageService; private final WalletCodeUseService walletCodeUseService; - /** 钱包管理异步导出服务,负责提交任务并生成可轮询的文件结果。 */ - private final WalletManageExportService walletManageExportService; + /** 钱包用户导出服务,统一生成余额和月度记录二级表头文件。 */ + private final WalletUserExportService walletUserExportService; private final WalletManageMapper walletManageMapper; private final MchDeptViewMapper mchDeptViewMapper; @@ -237,7 +237,7 @@ public class WalletManageController { @GetMapping("/app/detail") public R appGetAll(@RequestParam Long walletId) { PjcloudUser user = SecurityUtils.getUser(); - return R.ok(walletManageService.join(walletId)); + return R.ok(walletManageService.appDetail(user, walletId)); } @ApiCryptoAes @@ -371,7 +371,7 @@ public class WalletManageController { @PostMapping("/export") @PreAuthorize("@pms.hasPermission('wallet_walletManage_export')") public R export(@RequestBody WalletManageDto dto) { - return R.ok(walletManageExportService.submitExport(dto), "账户余额导出任务已提交"); + return R.ok(walletUserExportService.submitWalletBalanceExport(dto), "账户余额导出任务已提交"); } /** @@ -383,6 +383,6 @@ public class WalletManageController { @PostMapping("/export-qb") // @PreAuthorize("@pms.hasPermission('wallet_walletManage_export')") public R exportQb(@RequestBody WalletManageBalanceDto dto) { - return R.ok(walletManageExportService.submitQbExport(dto), "钱包记录导出任务已提交"); + return R.ok(walletUserExportService.submitWalletRecordExport(dto), "钱包记录导出任务已提交"); } } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java index 55e41a33..d830e394 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletTransactionController.java @@ -5,6 +5,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum; import com.bsd.pjcloud.common.log.annotation.SysLog; @@ -23,6 +24,8 @@ import com.bsd.pjcloud.wallet.api.vo.WalletTypeTotalVo; import com.bsd.pjcloud.wallet.api.vo.transaction.WalletTransactionVo; import com.bsd.pjcloud.wallet.mapper.WalletTransactionMapper; import com.bsd.pjcloud.wallet.service.WalletTransactionService; +import com.bsd.pjcloud.wallet.service.WalletTransactionExportScene; +import com.bsd.pjcloud.wallet.service.WalletUserExportService; import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -54,6 +57,9 @@ public class WalletTransactionController { private final WalletTransactionMapper walletTransactionMapper; + /** 钱包用户导出服务,负责异步分片、用户身份补齐和自定义二级表头文件生成。 */ + private final WalletUserExportService walletUserExportService; + /* ************************************* start 远程调用 start ************************************* */ @Inner @PostMapping("/add-balance") @@ -217,18 +223,16 @@ public class WalletTransactionController { walletTransactionService.exportCzTotal(response, dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); } + /** + * 使用自定义二级表头同步导出个人充值统计明细,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前充值金额、时间、标签和部门筛选条件;金额单位为元 + */ @Operation(summary = "后台充值统计按照个人(导出)", description = "后台充值统计按照个人(导出)") @PostMapping("/export-cz-total-person") public void exportCzTotalPerson(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(WalletBizEnum.getTopUpCodes()); - dto.setType(WalletTransactionDto.TYPE_INCOME); - if (ObjectUtil.isNotEmpty(dto.getPrice())) { - dto.setPrice(dto.getPrice() * 100); - } - if (ObjectUtil.isNotEmpty(dto.getPriceList())) { - dto.setPriceList(dto.getPriceList().stream().map(item -> item * 100).toList()); - } - walletTransactionService.exportCzTotalPerson(response, dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RECHARGE_PERSON_TOTAL); } @Operation(summary = "后台充值统计", description = "后台充值统计") @@ -282,6 +286,21 @@ public class WalletTransactionController { return R.ok(walletTransactionService.scopePageList(page, dto, dataScope)); } + /** + * 按页面筛选条件分页查询当前操作者数据权限范围内的全部类型钱包流水。 + * + * @param page 分页参数 + * @param dto 用户、部门、身份、收支方向、交易类型、标签和时间等筛选条件 + * @return 当前数据权限范围内的钱包流水分页 + */ + @Operation(summary = "后台全部钱包流水分页", description = "按数据权限查询全部类型的钱包流水") + @PostMapping("/page-all") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_view')") + public R> pageAll(@ParameterObject Page page, + @RequestBody WalletTransactionDto dto) { + return R.ok(walletTransactionService.scopePageList(page, dto, DataScope.of())); + } + @Operation(summary = "分页查询复杂查询", description = "分页查询复杂查询") @GetMapping("/page-join") @@ -330,18 +349,43 @@ public class WalletTransactionController { return R.ok(walletTransactionService.removeBatchByIds(CollUtil.toList(ids))); } + /** + * 使用自定义二级表头同步导出充值流水,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前充值流水筛选条件 + */ @Operation(summary = "充值导出", description = "充值导出") @PostMapping("/export-cz") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") public void exportCz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(WalletBizEnum.getTopUpCodes()); - dto.setType(WalletTransactionDto.TYPE_INCOME); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RECHARGE); + } - Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); + /** + * 提交充值流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。 + * + * @param dto 页面当前充值流水筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-cz-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitCzExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.RECHARGE), + "充值记录导出任务已提交"); + } - List list = walletTransactionService.scopeList(dto, DataScope.of()); - walletTransactionService.exportByTemplate(response, dto, "file/个人充值明细.xlsx", list); + /** + * 提交全部类型钱包流水异步分片导出任务,沿用页面交易类型和收支方向筛选条件。 + * + * @param dto 页面当前用户、部门、身份、收支方向、交易类型、标签和时间等筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-all-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitAllExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.ALL), + "钱包流水导出任务已提交"); } @Operation(summary = "充值统计", description = "充值统计") @@ -352,18 +396,30 @@ public class WalletTransactionController { return R.ok(walletTransactionService.walletTypetotal(dto, DataScope.of())); } + /** + * 使用自定义二级表头同步导出记账扣款流水,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前记账扣款筛选条件 + */ @Operation(summary = "记账扣款导出", description = "记账扣款导出") @PostMapping("/export-jz") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") public void exportJz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(WalletBizEnum.getTopUpCodes()); - dto.setType(WalletTransactionDto.TYPE_EXPENSE); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.ACCOUNTING); + } - Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); - - List list = walletTransactionService.scopeList(dto, DataScope.of()); - walletTransactionService.exportByTemplate(response, dto, "file/个人记账扣款明细.xlsx", list); + /** + * 提交记账扣款异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。 + * + * @param dto 页面当前记账扣款筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-jz-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitJzExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.ACCOUNTING), + "记账扣款导出任务已提交"); } @Operation(summary = "记账扣款统计", description = "记账扣款统计") @@ -376,17 +432,30 @@ public class WalletTransactionController { } + /** + * 使用自定义二级表头同步导出消费流水,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前消费流水筛选条件 + */ @Operation(summary = "消费导出", description = "消费导出") @PostMapping("/export-xf") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") public void exportXf(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(List.of(WalletBizEnum.PAYMENT.getCode())); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.CONSUMPTION); + } - Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); - - List list = walletTransactionService.scopeList(dto, DataScope.of()); - walletTransactionService.exportByTemplate(response, dto, "file/个人消费明细.xlsx", list); + /** + * 提交消费流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。 + * + * @param dto 页面当前消费流水筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-xf-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitXfExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.CONSUMPTION), + "消费记录导出任务已提交"); } @Operation(summary = "消费统计", description = "消费统计") @@ -397,18 +466,30 @@ public class WalletTransactionController { return R.ok(walletTransactionService.walletTypetotal(dto, dataScope)); } + /** + * 使用自定义二级表头同步导出退款流水,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前退款流水筛选条件 + */ @Operation(summary = "退款导出", description = "退款导出") @PostMapping("/export-tk") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") public void exportTk(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(List.of(WalletBizEnum.REFUND.getCode())); - dto.setIsFindOrder(true); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.REFUND); + } - Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); - - List list = walletTransactionService.scopeList(dto, DataScope.of()); - walletTransactionService.exportByTemplate(response, dto, "file/个人退款明细.xlsx", list); + /** + * 提交退款流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。 + * + * @param dto 页面当前退款流水筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-tk-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitTkExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.REFUND), + "退款记录导出任务已提交"); } @Operation(summary = "退款统计", description = "退款统计") @@ -420,18 +501,44 @@ public class WalletTransactionController { return R.ok(walletTransactionService.walletTypetotal(dto, dataScope)); } + /** + * 使用自定义二级表头同步导出食堂退还流水,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前食堂退还流水筛选条件 + */ @Operation(summary = "后台退还各自食堂记录导出", description = "后台退还各自食堂记录导出") @PostMapping("/export-th") @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") public void exportTh(HttpServletResponse response, @RequestBody WalletTransactionDto dto) { - dto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode())); - dto.setIsFindOrder(false); + walletUserExportService.exportTransaction(response, dto, WalletTransactionExportScene.RETURN_CANTEEN); + } - Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT)); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); + /** + * 提交食堂退还流水异步分片导出任务,由后台按稳定顺序分页生成二级表头工作簿。 + * + * @param dto 页面当前食堂退还流水筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-th-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitThExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, WalletTransactionExportScene.RETURN_CANTEEN), + "食堂退还记录导出任务已提交"); + } - List list = walletTransactionService.scopeList(dto, DataScope.of()); - walletTransactionService.exportByTemplate(response, dto, "file/退还各自食堂明细.xlsx", list); + /** + * 提交个人充值统计异步分片导出任务,并保留历史金额及排除部门统计口径。 + * + * @param dto 页面当前充值金额、时间、标签和部门筛选条件;金额单位为元 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-cz-total-person-async") + @PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')") + public R submitCzTotalPersonExport(@RequestBody WalletTransactionDto dto) { + return R.ok(walletUserExportService.submitTransactionExport(dto, + WalletTransactionExportScene.RECHARGE_PERSON_TOTAL), + "个人充值统计导出任务已提交"); } @Operation(summary = "退还各自食堂统计", description = "退还各自食堂统计") diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java index 7893cfdc..13043fec 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchManageController.java @@ -160,6 +160,22 @@ public class MchManageController { return R.ok(userDeptIdList); } + /** + * 查询指定用户所属目标类型商户覆盖的用户部门主键集合。 + * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;无法定位用户、匹配商户或关联部门时返回业务失败响应 + */ + @Inner + @GetMapping("/get-self-mch-user-dept-id-list-by-user-id") + public R> getSelfMchUserDeptIdListByUserId(@RequestParam Long userId, + @RequestParam List mchTypeList) { + List userDeptIdList = mchManageService.getSelfMchUserDeptIdListByUserId(userId, mchTypeList); + if (ObjectUtil.isEmpty(userDeptIdList)) return R.failed("商户不存在"); + return R.ok(userDeptIdList); + } + @Inner @GetMapping("/get-self-mch-id-list") public R> getSelfMchIdList(@RequestParam Long userId, @RequestParam List mchTypeList) { diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java index 6847a892..fa21af85 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/mch/MchPaySettingController.java @@ -1,7 +1,6 @@ package com.bsd.pjcloud.wallet.controller.mch; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; -import cn.hutool.core.collection.CollUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; @@ -101,6 +100,6 @@ public class MchPaySettingController { @DeleteMapping @PreAuthorize("@pms.hasPermission('wallet_mchPaySetting_del')") public R removeById(@RequestBody Long[] ids) { - return R.ok(mchPaySettingService.removeBatchByIds(CollUtil.toList(ids))); + return R.ok(mchPaySettingService.delete(List.of(ids))); } } diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java index 2e5d6111..00cbc243 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeController.java @@ -61,6 +61,19 @@ public class WalletRechargeController { return R.ok(walletRechargeService.refund(dto)); } + /** + * 供 Hardware 冲正人工审核退款,实际资金目标与金额只读取 Wallet 已持久化的原退款记录。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 钱包资金已扣回时返回 {@code true} + */ + @Inner + @Operation(summary = "远程调用-冲正人工退款") + @PostMapping("/refund/rollback/{afterSaleId}") + public R rollbackRefund(@PathVariable Long afterSaleId) { + return R.ok(walletRechargeService.rollbackRefund(afterSaleId)); + } + /** * 查询售后单是否已经形成钱包退款记录,仅用于跨服务一致性恢复,不提供管理端修改能力。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeImportController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeImportController.java index 4f96418b..eed17601 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeImportController.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/recharge/WalletRechargeImportController.java @@ -1,31 +1,19 @@ package com.bsd.pjcloud.wallet.controller.recharge; -import cn.hutool.core.date.DatePattern; -import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.collection.CollUtil; -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.common.core.exception.ServiceException; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.idempotent.annotation.Idempotent; import com.bsd.pjcloud.common.log.annotation.SysLog; -import com.bsd.pjcloud.wallet.api.constant.WalletBizRechargeEnum; -import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity; -import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo; -import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeImportMapper; import com.bsd.pjcloud.wallet.service.WalletRechargeImportService; +import com.bsd.pjcloud.wallet.service.WalletUserExportService; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.prepost.PreAuthorize; import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springdoc.core.annotations.ParameterObject; @@ -33,15 +21,9 @@ import org.springframework.http.HttpHeaders; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - /** * 充值导入表 * @@ -56,7 +38,9 @@ import java.util.Map; public class WalletRechargeImportController { private final WalletRechargeImportService walletRechargeImportService; - private final WalletRechargeImportMapper walletRechargeImportMapper; + + /** 钱包用户导出服务,负责充值水单的异步分片和用户身份补齐。 */ + private final WalletUserExportService walletUserExportService; @Operation(summary = "恢复", description = "恢复") @Idempotent(expireTime = 5, info = "正在处理中,请勿重复操作") @@ -112,50 +96,28 @@ public class WalletRechargeImportController { return R.ok(walletRechargeImportService.removeBatchByIds(CollUtil.toList(ids))); } + /** + * 使用自定义二级表头同步导出充值扣款水单,保留旧接口供现有调用方兼容使用。 + * + * @param response 当前 HTTP 文件下载响应 + * @param dto 页面当前水单、审批、用户身份及部门筛选条件 + */ @ResponseExcel @PostMapping("/export") @PreAuthorize("@pms.hasPermission('wallet_walletRechargeImport_export')") - public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) throws IOException { - DataScope dataScope = DataScope.of(); - Long count = walletRechargeImportMapper.count(dto, dataScope); - if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围"); - List list = walletRechargeImportService.scopeList(dto, dataScope); - 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.getCreateTimeList())) { - map.put("timeSpan", " 到 "); - map.put("startTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)); - map.put("endTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)); - } - if (ObjectUtil.isNotEmpty(list)) { - BigDecimal priceStrSum = BigDecimal.ZERO; - Long lineNumber = 1L; - for (WalletRechargeImportVo v : list) { - v.setLineNumber(lineNumber); - lineNumber++; - if (ObjectUtil.isNotEmpty(v.getPrice())) { - if (WalletBizRechargeEnum.reductionCodes().contains(v.getBizType()) && v.getPrice() > 0) { - v.setPrice(v.getPrice() * -1); - } - priceStrSum = priceStrSum.add(BigDecimal.valueOf(v.getPrice())); - v.setPriceStr(BigDecimal.valueOf(v.getPrice()).divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - } - if (ObjectUtil.isNotEmpty(v.getImportTime())) { - v.setImportTimeStr(LocalDateTimeUtil.format(v.getImportTime(), DatePattern.NORM_MONTH_PATTERN)); - } - if (ObjectUtil.isNotEmpty(v.getBizType())) { - v.setBizType(WalletBizRechargeEnum.valueOfByType(v.getBizType()).getDesc()); - } - if (ObjectUtil.isNotEmpty(v.getWalletTypeUniqueCode())) { - v.setWalletTypeUniqueCode(WalletTypeEnum.valueOfByCode(v.getWalletTypeUniqueCode()).getDesc()); - } - } - map.put("priceStrSum", priceStrSum.divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP).toString()); - } - excelWriter.fill(map, writeSheet); - excelWriter.fill(list, fillConfig, writeSheet); - } + public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) { + walletUserExportService.exportRechargeImport(response, dto); } -} \ No newline at end of file + + /** + * 提交充值扣款水单异步分片导出任务,由后台补齐用户身份并实时上报生成进度。 + * + * @param dto 页面当前水单、审批、用户身份及部门筛选条件 + * @return 包含任务编号和初始进度的统一响应 + */ + @PostMapping("/export-async") + @PreAuthorize("@pms.hasPermission('wallet_walletRechargeImport_export')") + public R submitExport(@RequestBody WalletRechargeImportDto dto) { + return R.ok(walletUserExportService.submitRechargeImportExport(dto), "充值扣款水单导出任务已提交"); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java index 85918f4a..fe176d9b 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletActiveMapper.java @@ -24,11 +24,21 @@ public interface WalletActiveMapper extends PjcloudBaseMapper查询应用卡号、激活状态、部门、用户、商户、勾选主键及包含下级部门等筛选条件, + * 并按饭卡顺序、记录主键倒序返回稳定分页;未绑定及硬件激活条件沿用现有 1/0 业务语义。

+ * + * @param page 页码、每页数量及是否统计总数的分页参数 + * @param dto 开卡页面或导出任务冻结后的筛选条件 + * @return 当前数据权限范围内的开卡记录分页,记录转换为 {@link WalletActiveVo} + */ default Page scopePageList(Page page, WalletActiveDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .like(ObjectUtil.isNotEmpty(dto.getCardNumber()), WalletActiveEntity::getCardNumber, dto.getCardNumber()) @@ -36,7 +46,9 @@ public interface WalletActiveMapper extends PjcloudBaseMapper openCardList(Page page, @Param("dto") WalletActiveDto dto); Integer openCardCount(@Param("dto") WalletRemoteOpenCardCountDto dto); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletFreezeMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletFreezeMapper.java index 4325ccba..3bb84919 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletFreezeMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletFreezeMapper.java @@ -23,11 +23,11 @@ public interface WalletFreezeMapper extends PjcloudBaseMapper scopePageList(Page page, @Param("dto") WalletFreezeDto dto, DataScope dataScope); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java index a3ba4a21..4bd7c9ae 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java @@ -25,6 +25,12 @@ import java.util.List; @Mapper public interface WalletManageMapper extends PjcloudBaseMapper { + /** + * 构建钱包账户通用筛选条件,并以钱包主键作为最终排序条件保证分片导出顺序稳定。 + * + * @param dto 钱包账户、用户、部门和身份预筛选后的查询条件 + * @return 已应用业务筛选和稳定排序的钱包查询包装器 + */ private LambdaQueryWrapper commonQuery(WalletManageDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getUserId()), WalletManageEntity::getUserId, dto.getUserId()) @@ -43,7 +49,8 @@ public interface WalletManageMapper extends PjcloudBaseMapper { + /** + * 构建钱包流水通用筛选条件;排序末尾使用流水主键保证深分页期间顺序稳定。 + * + * @param dto 钱包流水筛选条件 + * @return 已应用业务、用户、部门、时间和标签条件的查询包装器 + */ default LambdaQueryWrapper commonWrapperQuery(WalletTransactionDto dto) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getWalletId()), WalletTransactionEntity::getWalletId, dto.getWalletId()) @@ -44,7 +50,8 @@ public interface WalletTransactionMapper extends PjcloudBaseMapper scopeRechargePersonExportPage(Page page, WalletTransactionDto dto, + DataScope dataScope, List excludedDeptIds) { + LambdaQueryWrapper wrapper = commonWrapperQuery(dto) + .eq(ObjectUtil.isNotEmpty(dto.getPrice()), WalletTransactionEntity::getPrice, dto.getPrice()) + .in(ObjectUtil.isNotEmpty(dto.getPriceList()), WalletTransactionEntity::getPrice, dto.getPriceList()) + .notIn(excludedDeptIds != null && !excludedDeptIds.isEmpty(), + WalletTransactionEntity::getDeptId, excludedDeptIds); + Page list = selectPageByScope(page, wrapper, dataScope); + list.setRecords(BeanUtils.toBean(list.getRecords(), WalletTransactionVo.class)); + return list; + } + WalletTypeCountVo walletTypetotal(@Param("dto") WalletTransactionDto dto, @Param("dataScope") DataScope dataScope); List walletTypetotalByDept(@Param("dto") WalletTransactionDto dto, @Param("dataScope") DataScope dataScope); diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeImportMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeImportMapper.java index 7d7a70c5..455e4caf 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeImportMapper.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/recharge/WalletRechargeImportMapper.java @@ -16,6 +16,14 @@ import java.util.List; @Mapper public interface WalletRechargeImportMapper extends PjcloudBaseMapper { + /** + * 按数据权限分页查询充值扣款水单,XML 使用创建时间和水单主键倒序保证分片顺序稳定。 + * + * @param page 分页参数;异步导出仅在第一页查询总数 + * @param dto 水单、审批、用户和部门筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 已关联充值申请字段的水单分页结果 + */ Page scopePageList(Page page, @Param("dto") WalletRechargeImportDto dto, DataScope dataScope); @@ -28,6 +36,8 @@ public interface WalletRechargeImportMapper extends PjcloudBaseMapper wrapper = new MPJLambdaWrapper() .eqIfExists(WalletRechargeImportEntity::getId, dto.getId()) .eqIfExists(WalletRechargeImportEntity::getApplyId, dto.getApplyId()) + .eqIfExists(WalletRechargeImportEntity::getUserId, dto.getUserId()) + .in(org.apache.commons.collections4.CollectionUtils.isNotEmpty(dto.getUserIds()), WalletRechargeImportEntity::getUserId, dto.getUserIds()) .orderByDesc(WalletRechargeImportEntity::getCreateTime) .orderByDesc(WalletRechargeImportEntity::getUserId); return selectJoinList(WalletRechargeImportVo.class, wrapper); } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java index 72add9ef..e54bec4a 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchManageService.java @@ -52,6 +52,15 @@ public interface MchManageService extends IService { */ MchManageVo getViewsCanteenMchByDeptId(Long deptId); + /** + * 判断目标用户身份是否命中商户钱包充值配置。 + * + * @param merchant 目标饭卡所属食堂商户 + * @param identityTypes 目标用户身份协议编码 + * @return 商户未配置身份限制或目标用户至少命中一种允许身份时返回 {@code true} + */ + boolean isTopUpIdentityAllowed(MchManageVo merchant, List identityTypes); + /** * 刷新商户二维码 */ @@ -72,6 +81,15 @@ public interface MchManageService extends IService { */ List getSelfCanteenUserDeptIdList(Long mchId); + /** + * 根据用户主键和商户类型查询所属商户覆盖的用户部门主键集合。 + * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;用户、匹配商户或关联部门不存在时返回空集合 + */ + List getSelfMchUserDeptIdListByUserId(Long userId, List mchTypeList); + /** * 判断用户是否能查看到所有食堂 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java index b332028a..b8339780 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/MchPaySettingService.java @@ -71,6 +71,14 @@ public interface MchPaySettingService extends IService { ) MchPaySettingEntity edit(MchPaySettingDto dto); + /** + * 逻辑删除支付配置并同步停用其默认平台分佣关系,历史关系仍由 thirdparty 保留。 + * + * @param ids 当前租户内待删除的支付配置主键 + * @return 全部配置删除成功时返回 {@code true} + */ + boolean delete(List ids); + /** * 查询用户可用的微信、支付宝支付方式,返回客户端前移除支付宝授权令牌。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java index 08ed8da4..62fe5bcf 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletActiveService.java @@ -64,6 +64,22 @@ public interface WalletActiveService extends IService { Page scopePageList(Page page, WalletActiveDto dto); + /** + * 按数据权限分页读取开卡导出记录,仅补齐商户资料,用户与身份由统一导出服务按分片处理。 + * + * @param page 导出页码、分片大小和是否统计总数的分页参数 + * @param dto 已冻结身份及勾选记录范围的开卡筛选条件 + * @return 保持稳定卡序和主键顺序的开卡导出分页 + */ + Page scopeExportPageList(Page page, WalletActiveDto dto); + + /** + * 将身份筛选条件冻结为开卡表可直接使用的用户主键集合。 + * + * @param dto 开卡页面筛选条件;方法会清空身份扩展条件并保留最终用户主键范围 + */ + void prepareIdentityQuery(WalletActiveDto dto); + /** * 远程调用开卡并且绑定用户的列表 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java index 4f4d42af..5e6f0de4 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java @@ -18,6 +18,13 @@ import java.util.List; public interface WalletManageService extends IService { + /** + * 将钱包身份条件转换为最终用户主键集合,供统计、分页和异步导出共用。 + * + * @param dto 钱包查询条件;方法会原地写入最终用户主键并清理已消费的身份条件 + */ + void prepareIdentityQuery(WalletManageDto dto); + /** * 创建钱包 */ @@ -75,12 +82,44 @@ public interface WalletManageService extends IService { Page scopePageList(Page page, WalletManageDto dto, DataScope dataScope); + /** + * 分页查询账户余额导出所需的钱包业务资料。 + * + *

调用方必须先冻结身份筛选。本方法保留数据权限、部门和钱包类型装配,但不执行普通列表的 + * UPMS 用户身份回填,避免统一导出服务在同一分片重复查询用户。

+ * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份的账户余额查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 不重复查询用户资料的钱包分页 + */ + Page scopeExportPageList(Page page, WalletManageDto dto, DataScope dataScope); + Page appPageList(PjcloudUser user, Page page, WalletManageDto dto, DataScope dataScope); + /** + * 查询当前登录用户自己的钱包详情,并补齐充值身份判断所需的身份协议编码。 + * + * @param user 当前登录用户 + * @param walletId 钱包主键 + * @return 属于当前用户的钱包详情;钱包不存在或不属于当前用户时返回空 + */ + WalletManageVo appDetail(PjcloudUser user, Long walletId); + List scopeList(WalletManageDto dto, DataScope dataScope); Page scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope); + /** + * 分页计算月末钱包余额并装配导出所需业务资料。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份且包含统计月份的钱包记录查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 不重复查询用户资料的月度钱包记录分页 + */ + Page scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope); + WalletQbTotalVo qbTotal(WalletManageBalanceDto dto, DataScope dataScope); /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeImportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeImportService.java index b1e0dbfb..781e63c2 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeImportService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeImportService.java @@ -12,13 +12,33 @@ import java.util.List; public interface WalletRechargeImportService extends IService { + /** + * 将充值水单身份条件转换为最终用户主键集合,供数量统计、分页和导出共用。 + * + * @param dto 充值水单查询条件;方法会原地写入最终用户主键并清理已消费的身份条件 + */ + void prepareIdentityQuery(WalletRechargeImportDto dto); + Page pageJoin(Page page, WalletRechargeImportDto dto); List listJoin(WalletRechargeImportDto dto); Page scopePageList(Page page, WalletRechargeImportDto dto, DataScope dataScope); + /** + * 分页查询统一导出所需的充值扣款水单业务资料。 + * + *

调用方必须先冻结身份筛选。本方法保留数据权限、审批关联字段和钱包类型装配,但不执行普通 + * 列表的 UPMS 用户身份回填,完整用户资料由统一导出服务按同一分片一次性查询。

+ * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份的水单筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 不重复查询用户资料的水单分页 + */ + Page scopeExportPageList(Page page, WalletRechargeImportDto dto, DataScope dataScope); + List scopeList(WalletRechargeImportDto dto, DataScope dataScope); Boolean recover(Long id); -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java index db549501..8c05c138 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletRechargeService.java @@ -80,6 +80,14 @@ public interface WalletRechargeService extends IService { */ WalletRechargeVo refund(WalletRechargeRefundDto dto); + /** + * 冲正已完成的人工钱包退款并强制扣回原到账金额,退款状态用于保证重复调用不重复扣款。 + * + * @param afterSaleId Hardware 售后单 ID + * @return 已完成或已处于冲正状态时返回 {@code true} + */ + Boolean rollbackRefund(Long afterSaleId); + /** * 按 Hardware 售后单 ID 查询钱包退款记录。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionExportScene.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionExportScene.java new file mode 100644 index 00000000..0aa5d2de --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionExportScene.java @@ -0,0 +1,110 @@ +package com.bsd.pjcloud.wallet.service; + +import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; + +import java.util.List; + +/** + * 钱包流水导出场景定义。 + * + *

各场景固定 Redis 命名空间、任务名称、文件名和流水筛选条件;这些编码属于前后端异步进度协议, + * 已发布后不得随意修改。

+ */ +public enum WalletTransactionExportScene { + + /** 全部类型钱包流水,保留页面传入的交易类型和收支方向筛选条件。 */ + ALL("wallet:user:export:all-transaction", "钱包流水导出", "钱包流水", List.of(), null, false), + + /** 个人充值明细,限定充值类业务和正向收入流水。 */ + RECHARGE("wallet:user:export:cz", "充值记录导出", "充值记录", List.copyOf(WalletBizEnum.getTopUpCodes()), + WalletTransactionDto.TYPE_INCOME, false), + + /** 个人记账扣款明细,限定充值类业务和负向支出流水。 */ + ACCOUNTING("wallet:user:export:jz", "记账扣款导出", "记账扣款记录", List.copyOf(WalletBizEnum.getTopUpCodes()), + WalletTransactionDto.TYPE_EXPENSE, false), + + /** 充值统计页面的个人充值明细,沿用页面金额、时间、标签和部门筛选。 */ + RECHARGE_PERSON_TOTAL("wallet:user:export:cz-total", "个人充值统计导出", "个人充值统计", + List.copyOf(WalletBizEnum.getTopUpCodes()), WalletTransactionDto.TYPE_INCOME, false), + + /** 个人消费明细,限定钱包支付流水并保留关联订单号。 */ + CONSUMPTION("wallet:user:export:xf", "消费记录导出", "消费记录", + List.of(WalletBizEnum.PAYMENT.getCode()), null, true), + + /** 个人退款明细,限定钱包退款流水并保留关联订单号。 */ + REFUND("wallet:user:export:tk", "退款记录导出", "退款记录", + List.of(WalletBizEnum.REFUND.getCode()), null, true), + + /** 各自食堂退还明细,限定食堂退还业务且不解析为订单。 */ + RETURN_CANTEEN("wallet:user:export:th", "食堂退还记录导出", "食堂退还记录", + List.of(WalletBizEnum.RETURN_CANTEEN.getCode()), null, false); + + /** 前端进度轮询使用的固定 Redis Key 业务命名空间。 */ + private final String redisKey; + + /** 异步进度弹窗展示的任务名称。 */ + private final String taskName; + + /** 导出文件和工作表使用的业务名称,不包含时间戳和扩展名。 */ + private final String fileNamePrefix; + + /** 当前场景固定的钱包流水业务编码集合;空集合表示保留页面传入的交易类型条件。 */ + private final List bizTypes; + + /** 收支方向编码;为空表示不额外限制金额正负。 */ + private final Integer transactionType; + + /** 是否将关联业务编号按订单号语义补充到分页展示对象。 */ + private final boolean findOrder; + + /** + * 创建固定钱包流水导出场景。 + * + * @param redisKey 前端进度轮询使用的固定命名空间 + * @param taskName 异步任务展示名称 + * @param fileNamePrefix 导出文件和工作表业务名称 + * @param bizTypes 固定的钱包流水业务编码集合;空集合表示不覆盖页面交易类型条件 + * @param transactionType 收支方向编码,允许为空 + * @param findOrder 是否解析关联订单号 + */ + WalletTransactionExportScene(String redisKey, String taskName, String fileNamePrefix, List bizTypes, + Integer transactionType, boolean findOrder) { + this.redisKey = redisKey; + this.taskName = taskName; + this.fileNamePrefix = fileNamePrefix; + this.bizTypes = bizTypes; + this.transactionType = transactionType; + this.findOrder = findOrder; + } + + /** @return 前端进度轮询使用的固定 Redis Key 业务命名空间 */ + public String getRedisKey() { + return redisKey; + } + + /** @return 异步进度弹窗展示的任务名称 */ + public String getTaskName() { + return taskName; + } + + /** @return 导出文件和工作表使用的业务名称 */ + public String getFileNamePrefix() { + return fileNamePrefix; + } + + /** @return 当前场景固定的钱包流水业务编码集合;空集合表示不覆盖页面交易类型条件 */ + public List getBizTypes() { + return bizTypes; + } + + /** @return 收支方向编码;为空表示不限制金额正负 */ + public Integer getTransactionType() { + return transactionType; + } + + /** @return 是否按订单语义解析关联业务编号 */ + public boolean isFindOrder() { + return findOrder; + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java index 6dbf4961..83f1d0c7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletTransactionService.java @@ -17,8 +17,40 @@ import java.util.List; public interface WalletTransactionService extends IService { + /** + * 将钱包流水身份条件转换为最终用户主键集合,供统计、分页和导出共用。 + * + * @param dto 钱包流水查询条件;方法会原地写入最终用户主键并清理已消费的身份条件 + */ + void prepareIdentityQuery(WalletTransactionDto dto); + Page scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope); + /** + * 分页查询统一导出所需的钱包流水业务资料。 + * + *

调用方必须先冻结身份筛选。本方法保留数据权限、部门、钱包和订单号装配,但不调用普通列表的 + * UPMS 用户回填,完整用户、岗位和身份资料由统一导出服务在同一分片内一次性查询。

+ * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份和固定业务场景的流水查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 不重复查询用户资料的流水导出分页 + */ + Page scopeExportPageList(Page page, WalletTransactionDto dto, DataScope dataScope); + + /** + * 分页查询个人充值统计导出流水,并沿用流水列表的数据权限和钱包资料装配。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份、充值金额和部门筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @param excludedDeptIds 统计场景固定排除的部门主键 + * @return 携带部门、钱包类型和卡号业务资料且不重复查询用户资料的分页结果 + */ + Page scopeRechargePersonExportPage(Page page, WalletTransactionDto dto, + DataScope dataScope, List excludedDeptIds); + /** * 分页查询登录用户本人或其有效被监护人的 EAT 饭卡流水。 * diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java new file mode 100644 index 00000000..f6e862ef --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletUserExportService.java @@ -0,0 +1,85 @@ +package com.bsd.pjcloud.wallet.service; + +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.wallet.api.dto.WalletActiveDto; +import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto; +import com.bsd.pjcloud.wallet.api.dto.WalletManageDto; +import com.bsd.pjcloud.wallet.api.dto.WalletTransactionDto; +import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeImportDto; +import jakarta.servlet.http.HttpServletResponse; + +/** + * 钱包用户数据统一导出服务。 + * + *

负责冻结页面筛选与数据权限、按页读取钱包业务数据、批量补齐 UPMS 用户及身份资料、流式写入 + * EasyExcel 二级表头文件,并通过通用异步任务协议实时上报进度。

+ */ +public interface WalletUserExportService { + + /** + * 提交钱包开卡记录异步分片导出任务。 + * + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitWalletActiveExport(WalletActiveDto dto); + + /** + * 使用中文二级表头同步分片导出钱包开卡记录,供旧调用方兼容使用。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、用户身份、部门和勾选记录筛选条件 + */ + void exportWalletActive(HttpServletResponse response, WalletActiveDto dto); + + /** + * 提交钱包流水异步分片导出任务。 + * + * @param dto 页面当前流水筛选条件 + * @param scene 固定流水导出场景 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitTransactionExport(WalletTransactionDto dto, WalletTransactionExportScene scene); + + /** + * 使用自定义二级表头执行兼容旧调用方的钱包流水同步导出。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前流水筛选条件 + * @param scene 固定流水导出场景 + */ + void exportTransaction(HttpServletResponse response, WalletTransactionDto dto, + WalletTransactionExportScene scene); + + /** + * 提交账户当前余额异步分片导出任务。 + * + * @param dto 账户余额筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitWalletBalanceExport(WalletManageDto dto); + + /** + * 提交指定月份钱包余额异步分片导出任务。 + * + * @param dto 月度钱包余额筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitWalletRecordExport(WalletManageBalanceDto dto); + + /** + * 提交批量充值扣款水单异步分片导出任务。 + * + * @param dto 充值水单筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitRechargeImportExport(WalletRechargeImportDto dto); + + /** + * 使用自定义二级表头执行兼容旧调用方的充值水单同步导出。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 充值水单筛选条件 + */ + void exportRechargeImport(HttpServletResponse response, WalletRechargeImportDto dto); +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java index 0b36c178..1a27bdda 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletActiveServiceImpl.java @@ -31,6 +31,7 @@ import com.bsd.pjcloud.wallet.mapper.WalletActiveMapper; import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper; import com.bsd.pjcloud.wallet.service.MchManageService; import com.bsd.pjcloud.wallet.service.WalletActiveService; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletTypeService; import lombok.RequiredArgsConstructor; @@ -60,6 +61,9 @@ public class WalletActiveServiceImpl extends ServiceImpl pageJoin(Page page, WalletActiveDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletActiveVo::getUserId).distinct().toList(); - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(WalletActiveVo::getDeptId).distinct().toList(); R> deptList = deptService.getDeptByDeptIds(deptIds); @@ -336,16 +336,10 @@ public class WalletActiveServiceImpl extends ServiceImpl scopePageList(Page page, WalletActiveDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletActiveVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(WalletActiveVo::getDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -366,6 +360,53 @@ public class WalletActiveServiceImpl extends ServiceImpl导出用户和身份资料由 {@code WalletUserExportServiceImpl} 使用完整用户接口统一补齐, + * 此处不再调用普通列表用户接口,避免每个大数据分片重复远程查询同一批用户。

+ * + * @param page 导出页码、分片大小和是否统计总数的分页参数 + * @param dto 已冻结身份及勾选记录范围的开卡筛选条件 + * @return 已补齐商户名称的开卡导出分页 + */ + @Override + public Page scopeExportPageList(Page page, WalletActiveDto dto) { + prepareIdentityQuery(dto); + Page list = baseMapper.scopePageList(page, dto); + if (ObjectUtil.isEmpty(list.getRecords())) { + return list; + } + List merchantIds = list.getRecords().stream().map(WalletActiveVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (ObjectUtil.isNotEmpty(merchantIds)) { + List merchants = mchManageService.list(MchManageDto.builder().ids(merchantIds).build()); + if (ObjectUtil.isNotEmpty(merchants)) { + WalletDataUtil.convertMchList(list.getRecords(), + CollectionUtils.convertMap(merchants, MchManageVo::getId)); + } + } + return list; + } + + /** + * 将身份筛选转换为开卡记录可直接使用的用户主键条件。 + * + * @param dto 开卡记录查询条件;处理后保留最终用户主键集合 + */ + @Override + public void prepareIdentityQuery(WalletActiveDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } + /** * 远程调用开卡并且绑定用户的列表 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java index 4edfac2e..6cba878d 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletCodeUseServiceImpl.java @@ -9,13 +9,10 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; -import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; -import com.bsd.pjcloud.admin.api.utils.DataUtil; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.BeanUtils; -import com.bsd.pjcloud.common.core.util.CollectionUtils; import com.bsd.pjcloud.common.core.util.QRCodeImageGenerator; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.hard.api.dto.code.QRCodeExtraDto; @@ -29,6 +26,7 @@ import com.bsd.pjcloud.wallet.api.vo.WalletCodeUseVo; import com.bsd.pjcloud.wallet.api.vo.WalletSettingVo; import com.bsd.pjcloud.wallet.mapper.WalletCodeUseMapper; import com.bsd.pjcloud.wallet.service.*; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum; import com.bsd.pjcloud.hard.api.dto.code.QRCodeDto; @@ -47,7 +45,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -75,6 +72,8 @@ public class WalletCodeUseServiceImpl extends ServiceImpl pageJoin(Page page, WalletCodeUseDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); - if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletCodeUseVo::getUserId).distinct().toList(); - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); return list; } + /** + * 将身份筛选条件转换为最终用户主键集合,复用 Mapper 已有的 {@code userIds} 过滤能力。 + * + * @param dto 二维码使用记录查询条件;处理后身份字段被最终用户主键集合替代 + */ + private void prepareIdentityQuery(WalletCodeUseDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } + @Override // @GlobalTransactional @Transactional(rollbackFor = Exception.class) diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletForeignServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletForeignServiceImpl.java index 19ca7c99..3c18ead0 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletForeignServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletForeignServiceImpl.java @@ -25,6 +25,7 @@ import com.bsd.pjcloud.wallet.api.entity.WalletForeignEntity; import com.bsd.pjcloud.wallet.api.vo.WalletForeignVo; import com.bsd.pjcloud.wallet.mapper.WalletForeignMapper; import com.bsd.pjcloud.wallet.service.WalletForeignService; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; import com.bsd.pjcloud.wallet.utils.flow.FlowUtil; import com.bsd.pjcloud.wallet.utils.flow.dto.WalletForeignApplyFlowDto; import lombok.RequiredArgsConstructor; @@ -55,6 +56,9 @@ public class WalletForeignServiceImpl extends ServiceImpl pageJoin(Page page, WalletForeignDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); disposeList(list.getRecords(), dto); return list; @@ -97,6 +102,7 @@ public class WalletForeignServiceImpl extends ServiceImpl scopePageList(Page page, WalletForeignDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto); disposeList(list.getRecords(), dto); return list; @@ -148,14 +154,7 @@ public class WalletForeignServiceImpl extends ServiceImpl list, WalletForeignDto dto) { if (ObjectUtil.isEmpty(list)) return; - // 用户 - List userIds = list.stream().map(WalletForeignVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); // 部门 List deptIds = list.stream().map(WalletForeignVo::getDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -182,4 +181,21 @@ public class WalletForeignServiceImpl extends ServiceImpl pageJoin(Page page, WalletFreezeDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); disposeList(list.getRecords(), dto); return list; @@ -78,6 +83,7 @@ public class WalletFreezeServiceImpl extends ServiceImpl scopePageList(Page page, WalletFreezeDto dto) { + prepareIdentityQuery(dto); DataScope dataScope = new DataScope(); dataScope.put("scopeDeptName", "a.dept_id"); Page list = baseMapper.scopePageList(page, dto, dataScope); @@ -185,14 +191,7 @@ public class WalletFreezeServiceImpl extends ServiceImpl list, WalletFreezeDto dto) { if (ObjectUtil.isEmpty(list)) return; - // 用户 - List userIds = list.stream().map(WalletFreezeVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); // 部门 List deptIds = list.stream().map(WalletFreezeVo::getDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -220,8 +219,25 @@ public class WalletFreezeServiceImpl extends ServiceImpl scopeList(WalletManageDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List list = baseMapper.scopeList(dto, dataScope); disposeList(list); return list; @@ -258,11 +263,27 @@ public class WalletManageServiceImpl extends ServiceImpl scopePageList(Page page, WalletManageDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePage(page, dto, dataScope); disposeList(list.getRecords()); return list; } + /** + * 分页查询账户余额导出业务资料,并跳过普通列表的 UPMS 用户身份回填。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份的账户余额查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 已装配部门和钱包类型的钱包分页 + */ + @Override + public Page scopeExportPageList(Page page, WalletManageDto dto, DataScope dataScope) { + Page list = baseMapper.scopePage(page, dto, dataScope); + disposeExportList(list.getRecords()); + return list; + } + @Override public Page appPageList(PjcloudUser user, Page page, WalletManageDto dto, DataScope dataScope) { Page list = baseMapper.scopePage(page, dto, dataScope); @@ -290,6 +311,22 @@ public class WalletManageServiceImpl extends ServiceImpl result = appPageList(user, new Page<>(1, 1, false), dto, null); + return ObjectUtil.isEmpty(result.getRecords()) ? null : result.getRecords().get(0); + } + @Override public WalletManageVo join(Long walletId) { WalletManageVo detail = baseMapper.join(walletId); @@ -326,9 +363,38 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) { + return queryWalletRecordPage(page, dto, dataScope, true); + } + + /** + * 分页计算月末钱包余额导出数据,并把完整用户资料留给统一导出服务装配。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份且包含统计月份的钱包记录查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 已计算月末余额并装配部门、钱包类型的分页 + */ + @Override + public Page scopeQbExportPage(Page page, WalletManageBalanceDto dto, DataScope dataScope) { + return queryWalletRecordPage(page, dto, dataScope, false); + } + + /** + * 执行普通列表和统一导出共用的月末钱包余额分页计算。 + * + * @param page 分页参数 + * @param dto 钱包记录月份及用户、部门、钱包类型筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @param fillUserInfo 是否按普通列表协议回填 UPMS 用户和身份资料 + * @return 已计算指定月份末余额的钱包分页 + */ + private Page queryWalletRecordPage(Page page, WalletManageBalanceDto dto, + DataScope dataScope, boolean fillUserInfo) { WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); + prepareIdentityQuery(walletDto); Page walletPage = baseMapper.scopePage(page, walletDto, dataScope); - if (walletPage.getTotal() == 0) return walletPage; + // 导出第二批起会关闭总数查询,此时 total 固定为零,只能按当前批记录判断是否为空。 + if (ObjectUtil.isEmpty(walletPage.getRecords())) return walletPage; walletPage.getRecords().forEach(v -> { v.setBalance(0); v.setTotalRecharge(0); @@ -356,12 +422,19 @@ public class WalletManageServiceImpl extends ServiceImpl pageJoinActive(Page page, WalletTopupActionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); if (ObjectUtil.isEmpty(dto.getUnactiveMonthsNumber())) { dto.setUnactiveMonthsNumber(3L); } @@ -409,6 +483,7 @@ public class WalletManageServiceImpl extends ServiceImpl pageListActive(WalletTopupActionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); if (ObjectUtil.isEmpty(dto.getUnactiveMonthsNumber())) { dto.setUnactiveMonthsNumber(3L); } @@ -421,6 +496,8 @@ public class WalletManageServiceImpl extends ServiceImpl scopeQbList(WalletManageBalanceDto dto, DataScope dataScope) { WalletManageDto walletDto = BeanUtils.toBean(dto, WalletManageDto.class); + prepareIdentityQuery(walletDto); + BeanUtil.copyProperties(walletDto, dto); List walletList = baseMapper.scopeList(walletDto, dataScope); if (ObjectUtil.isEmpty(walletList)) return walletList; walletList.forEach(v -> { @@ -567,14 +644,27 @@ public class WalletManageServiceImpl extends ServiceImpl list) { + if (ObjectUtil.isEmpty(list)) { + return; + } + // 普通列表保持既有用户资料契约,导出分页由专用导出服务获取岗位等完整资料。 + userIdentitySupport.fillUserInfo(list, true); + disposeExportList(list); + } + + /** + * 装配账户余额与月度钱包导出共用的部门和钱包类型资料。 + * + * @param list 当前钱包分页记录;为空时不执行远程或数据库查询 + */ + private void disposeExportList(List list) { if (ObjectUtil.isNotEmpty(list)) { - // 用户 - List userIds = list.stream().map(WalletManageVo::getUserId).distinct().toList(); - R> userList = userService.getInnerUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } // 部门 List deptIds = list.stream().map(WalletManageVo::getDeptId).distinct().toList(); R> deptList = deptService.getDeptByDeptIds(deptIds); @@ -593,6 +683,41 @@ public class WalletManageServiceImpl extends ServiceImpl pageJoin(Page page, WalletStrideDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); disposeList(list.getRecords(), dto); return list; @@ -105,6 +110,7 @@ public class WalletStrideServiceImpl extends ServiceImpl scopePageList(Page page, WalletStrideDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto); disposeList(list.getRecords(), dto); return list; @@ -148,17 +154,10 @@ public class WalletStrideServiceImpl extends ServiceImpl list, WalletStrideDto dto) { if (ObjectUtil.isEmpty(list)) return; - // 用户 - List userIds = list.stream().map(WalletStrideVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); // 部门 List deptIds = list.stream().map(WalletStrideVo::getDeptId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { + if (ObjectUtil.isNotEmpty(deptIds)) { R> deptList = deptService.getDeptByDeptIds(deptIds); if (ObjectUtil.isNotEmpty(deptList.getData())) { DataUtil.convertDeptList(list, deptList.getData()); @@ -183,6 +182,23 @@ public class WalletStrideServiceImpl extends ServiceImpl() .eq(WalletStrideEntity::getUserId, walletId) @@ -190,4 +206,4 @@ public class WalletStrideServiceImpl extends ServiceImpl 0; } -} \ No newline at end of file +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java index cabe4937..1e21a00a 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletTransactionServiceImpl.java @@ -58,6 +58,7 @@ import com.bsd.pjcloud.wallet.service.WalletManageService; import com.bsd.pjcloud.wallet.service.WalletThirdZyService; import com.bsd.pjcloud.wallet.service.WalletTransactionService; import com.bsd.pjcloud.wallet.support.WalletMessageContextHolder; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -140,6 +141,9 @@ public class WalletTransactionServiceImpl extends ServiceImpl scopePageList(Page page, WalletTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; } + /** + * 分页查询统一导出所需的流水业务资料,并跳过普通列表的 UPMS 用户回填。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份和业务场景的流水查询条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 已装配部门、钱包、钱包类型及订单号的流水分页 + */ + @Override + public Page scopeExportPageList(Page page, WalletTransactionDto dto, + DataScope dataScope) { + Page list = baseMapper.scopePageList(page, dto, dataScope); + disposeExportList(list.getRecords(), dto); + return list; + } + + /** + * 分页查询个人充值统计导出流水,并复用导出分页的部门、钱包和钱包类型装配逻辑。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份和充值统计筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @param excludedDeptIds 统计场景固定排除的部门主键 + * @return 不重复查询 UPMS 用户资料的个人充值统计导出分页 + */ + @Override + public Page scopeRechargePersonExportPage(Page page, WalletTransactionDto dto, + DataScope dataScope, List excludedDeptIds) { + Page list = baseMapper.scopeRechargePersonExportPage(page, dto, dataScope, + excludedDeptIds); + disposeExportList(list.getRecords(), dto); + return list; + } + /** * 分页查询本人或有效被监护人的 EAT 饭卡流水。目标用户与钱包均由服务端重新解析, * 避免客户端通过篡改用户 ID、用户集合或钱包 ID 越权读取其他账户流水。 @@ -940,6 +979,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl pageCzTotalList(WalletTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List resultList = new ArrayList<>(); // 查询食堂 MchManageDto mchQueryDto = MchManageDto.builder().typesList(List.of(MerchantTypeEnum.CANTEEN.getType())).build(); @@ -982,6 +1022,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl listDeptCzTotal(WalletTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List resultList = new ArrayList<>(); // 查询食堂的充值情况 WalletMchDeptViewDto query = new WalletMchDeptViewDto(); @@ -1019,6 +1060,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl scopeJzPageList(Page page, WalletTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page list = baseMapper.scopeJzPageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; @@ -1026,6 +1068,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl scopeList(WalletTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List list = baseMapper.scopeList(dto, dataScope); disposeList(list, dto); return list; @@ -1033,6 +1076,7 @@ public class WalletTransactionServiceImpl extends ServiceImpl list, WalletTransactionDto dto) { + if (ObjectUtil.isEmpty(list)) { + return; + } + // 普通列表仍由统一身份支持器回填;导出分片改由专用导出服务一次性获取更完整的用户资料。 + userIdentitySupport.fillUserInfo(list, true); + disposeExportList(list, dto); + } + + /** + * 装配钱包流水导出与普通列表共用的部门、钱包、钱包类型及订单号资料。 + * + * @param list 当前分页记录;为空时不执行远程或数据库查询 + * @param dto 当前流水查询条件,用于判断是否解析订单号 + */ + private void disposeExportList(List list, WalletTransactionDto dto) { if (ObjectUtil.isNotEmpty(list)) { - // 用户 - List userIds = list.stream().map(WalletTransactionVo::getUserId).distinct().toList(); - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } // 部门 List deptIds = list.stream().map(WalletTransactionVo::getDeptId).distinct().toList(); R> deptList = deptService.getDeptByDeptIds(deptIds); @@ -1235,4 +1294,22 @@ public class WalletTransactionServiceImpl extends ServiceImpl服务在提交任务时冻结身份筛选结果,在后台任务中按稳定分页顺序读取钱包业务数据;每个分片通过 + * UPMS 完整用户导出接口补齐岗位和身份资料,再使用 EasyExcel 二级表头流式写入。完整文件关闭后才会 + * 上传文件中心或写入同步响应,避免大数据导出占用大量堆内存,也避免客户端下载到半成品文件。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WalletUserExportServiceImpl implements WalletUserExportService { + + /** 钱包用户导出日志标签,用于定位查询、写入和上传异常。 */ + private static final String LOG_TAG = "钱包用户导出"; + + /** 查询真实数据量前创建异步任务使用的占位总数。 */ + private static final int INITIAL_PROGRESS_TOTAL = 1; + + /** 单次钱包业务查询和 EasyExcel 写入的记录数。 */ + private static final int EXPORT_BATCH_SIZE = 200; + + /** 单次查询 UPMS 完整用户导出资料的最大用户数。 */ + private static final long USER_EXPORT_BATCH_SIZE = 500L; + + /** 文件上传和异步任务成功收尾占用的额外进度步数。 */ + private static final int EXTRA_PROGRESS_STEPS = 2; + + /** EasyExcel 二级表头占用的固定行数。 */ + private static final int HEADER_ROW_COUNT = 2; + + /** 当前导出不追加模板合计行,因此不需要为尾部预留 Excel 行。 */ + private static final int RESERVED_TAIL_ROW_COUNT = 0; + + /** 单个 Excel 2007 工作表可容纳的钱包导出明细上限。 */ + private static final int MAX_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount( + HEADER_ROW_COUNT, RESERVED_TAIL_ROW_COUNT); + + /** 账户当前余额导出任务的 Redis Key 业务命名空间。 */ + private static final String BALANCE_EXPORT_REDIS_KEY = "wallet:manage:export:balance"; + + /** 钱包开卡记录导出任务的 Redis Key 业务命名空间。 */ + private static final String ACTIVE_EXPORT_REDIS_KEY = "wallet:user:export:active"; + + /** 月度钱包记录导出任务的 Redis Key 业务命名空间。 */ + private static final String RECORD_EXPORT_REDIS_KEY = "wallet:manage:export:record"; + + /** 批量充值扣款水单导出任务的 Redis Key 业务命名空间。 */ + private static final String RECHARGE_IMPORT_EXPORT_REDIS_KEY = "wallet:user:export:recharge-water"; + + /** 账户当前余额导出任务展示名称。 */ + private static final String BALANCE_EXPORT_TASK_NAME = "账户余额导出"; + + /** 钱包开卡记录导出任务展示名称。 */ + private static final String ACTIVE_EXPORT_TASK_NAME = "钱包开卡导出"; + + /** 月度钱包记录导出任务展示名称。 */ + private static final String RECORD_EXPORT_TASK_NAME = "钱包记录导出"; + + /** 批量充值扣款水单导出任务展示名称。 */ + private static final String RECHARGE_IMPORT_EXPORT_TASK_NAME = "充值扣款水单导出"; + + /** + * 个人充值统计在页面未指定金额时使用的历史默认金额,单位为分。 + * + *

该集合延续原固定模板只统计 800、1600、2400 元充值的业务口径。

+ */ + private static final List PERSON_RECHARGE_DEFAULT_PRICES = List.of(80000, 160000, 240000); + + /** + * 个人充值统计历史口径固定排除的部门主键。 + * + *

这些主键属于既有统计规则,不是数据库结构;调整前必须与财务确认统计范围。

+ */ + private static final List PERSON_RECHARGE_EXCLUDED_DEPT_IDS = List.of( + 1875013738914156546L, + 1910218102049226753L, + 1910218128624336898L, + 1942046316044144642L + ); + + /** 文件中心保存钱包用户导出文件的业务目录。 */ + private static final String EXPORT_FILE_DIR = "wallet-export"; + + /** 文件中心区分钱包用户导出文件的业务类型编码。 */ + private static final String EXPORT_FILE_TYPE = "WALLET_USER_EXPORT"; + + /** XLSX 文件上传和同步响应使用的标准 MIME 类型。 */ + private static final String EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 异步导出文件名使用的时间格式,避免连续任务生成相同名称。 */ + private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** Excel 日期时间字段统一展示格式。 */ + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 钱包流水服务,负责分页数据权限、钱包类型和历史用户展示资料装配。 */ + private final WalletTransactionService walletTransactionService; + + /** 钱包开卡服务,负责按数据权限分页读取开卡、商户和历史用户展示资料。 */ + private final WalletActiveService walletActiveService; + + /** 钱包管理服务,负责当前余额和月度历史余额分页计算。 */ + private final WalletManageService walletManageService; + + /** 充值水单服务,负责水单分页、钱包类型和历史用户展示资料装配。 */ + private final WalletRechargeImportService walletRechargeImportService; + + /** 商户部门视图 Mapper,负责按流水部门批量解析个人充值统计所属食堂。 */ + private final MchDeptViewMapper mchDeptViewMapper; + + /** UPMS 用户远程服务,负责按当前操作者权限返回完整用户、岗位和身份资料。 */ + private final RemoteUserService remoteUserService; + + /** 文件中心远程服务,负责保存完整关闭后的 XLSX 文件。 */ + private final RemoteFileService remoteFileService; + + /** 通用异步任务执行器,负责恢复租户、安全和 Feign 上下文并持久化进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** + * 提交钱包开卡记录异步分片导出任务。 + * + * @param dto 页面当前开卡、身份、部门和勾选记录筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitWalletActiveExport(WalletActiveDto dto) { + WalletActiveDto querySnapshot = copyWalletActiveQuery(dto); + walletActiveService.prepareIdentityQuery(querySnapshot); + return asyncTaskExecutorService.submitWithResult(ACTIVE_EXPORT_REDIS_KEY, ACTIVE_EXPORT_TASK_NAME, + INITIAL_PROGRESS_TOTAL, reporter -> exportWalletActiveRecords(querySnapshot, reporter)); + } + + /** + * 使用中文二级表头同步分片导出钱包开卡记录,保留旧 GET 接口兼容性。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前开卡、身份、部门和勾选记录筛选条件 + */ + @Override + public void exportWalletActive(HttpServletResponse response, WalletActiveDto dto) { + WalletActiveDto querySnapshot = copyWalletActiveQuery(dto); + walletActiveService.prepareIdentityQuery(querySnapshot); + Page firstPage = fetchWalletActivePage(1L, querySnapshot, true); + long total = validateTotal(firstPage.getTotal(), ACTIVE_EXPORT_TASK_NAME); + response.setContentType(EXCEL_CONTENT_TYPE); + writePagedWorkbook(firstPage, total, null, WalletActiveExportExcelVo.class, + "钱包开卡", "wallet-active-export-", + current -> fetchWalletActivePage(current, querySnapshot, false), this::convertWalletActiveRows, + file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 提交钱包流水异步分片导出任务。 + * + * @param dto 页面当前流水筛选条件 + * @param scene 固定流水导出场景 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitTransactionExport(WalletTransactionDto dto, + WalletTransactionExportScene scene) { + WalletTransactionDto querySnapshot = prepareTransactionQuery(copyTransactionQuery(dto), scene); + return asyncTaskExecutorService.submitWithResult(scene.getRedisKey(), scene.getTaskName(), + INITIAL_PROGRESS_TOTAL, reporter -> exportTransactions(querySnapshot, scene, reporter)); + } + + /** + * 执行兼容旧调用方的钱包流水同步分片导出。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 页面当前流水筛选条件 + * @param scene 固定流水导出场景 + */ + @Override + public void exportTransaction(HttpServletResponse response, WalletTransactionDto dto, + WalletTransactionExportScene scene) { + WalletTransactionDto querySnapshot = prepareTransactionQuery(copyTransactionQuery(dto), scene); + if (scene == WalletTransactionExportScene.RECHARGE_PERSON_TOTAL) { + exportRechargePersonTransaction(response, querySnapshot, scene); + return; + } + Page firstPage = fetchTransactionPage(1L, querySnapshot, true); + long total = validateTotal(firstPage.getTotal(), scene.getTaskName()); + response.setContentType(EXCEL_CONTENT_TYPE); + writePagedWorkbook(firstPage, total, null, WalletTransactionExportExcelVo.class, + scene.getFileNamePrefix(), "wallet-transaction-export-", + current -> fetchTransactionPage(current, querySnapshot, false), this::convertTransactionRows, + file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 提交账户当前余额异步分片导出任务。 + * + * @param dto 账户余额筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitWalletBalanceExport(WalletManageDto dto) { + WalletManageDto querySnapshot = copyWalletManageQuery(dto); + walletManageService.prepareIdentityQuery(querySnapshot); + return asyncTaskExecutorService.submitWithResult(BALANCE_EXPORT_REDIS_KEY, BALANCE_EXPORT_TASK_NAME, + INITIAL_PROGRESS_TOTAL, reporter -> exportWalletBalance(querySnapshot, reporter)); + } + + /** + * 提交指定月份钱包余额异步分片导出任务。 + * + * @param dto 月度钱包余额筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitWalletRecordExport(WalletManageBalanceDto dto) { + WalletManageBalanceDto querySnapshot = prepareWalletRecordQuery(copyWalletRecordQuery(dto)); + return asyncTaskExecutorService.submitWithResult(RECORD_EXPORT_REDIS_KEY, RECORD_EXPORT_TASK_NAME, + INITIAL_PROGRESS_TOTAL, reporter -> exportWalletRecords(querySnapshot, reporter)); + } + + /** + * 提交批量充值扣款水单异步分片导出任务。 + * + * @param dto 充值水单筛选条件 + * @return 供前端轮询的初始任务进度 + */ + @Override + public AsyncTaskProgressVO submitRechargeImportExport(WalletRechargeImportDto dto) { + WalletRechargeImportDto querySnapshot = copyRechargeImportQuery(dto); + walletRechargeImportService.prepareIdentityQuery(querySnapshot); + return asyncTaskExecutorService.submitWithResult(RECHARGE_IMPORT_EXPORT_REDIS_KEY, + RECHARGE_IMPORT_EXPORT_TASK_NAME, INITIAL_PROGRESS_TOTAL, + reporter -> exportRechargeImports(querySnapshot, reporter)); + } + + /** + * 执行兼容旧调用方的充值水单同步分片导出。 + * + * @param response 当前 HTTP 下载响应 + * @param dto 充值水单筛选条件 + */ + @Override + public void exportRechargeImport(HttpServletResponse response, WalletRechargeImportDto dto) { + WalletRechargeImportDto querySnapshot = copyRechargeImportQuery(dto); + walletRechargeImportService.prepareIdentityQuery(querySnapshot); + Page firstPage = fetchRechargeImportPage(1L, querySnapshot, true); + long total = validateTotal(firstPage.getTotal(), RECHARGE_IMPORT_EXPORT_TASK_NAME); + response.setContentType(EXCEL_CONTENT_TYPE); + writePagedWorkbook(firstPage, total, null, WalletRechargeImportExportExcelVo.class, + "充值扣款水单", "wallet-recharge-import-export-", + current -> fetchRechargeImportPage(current, querySnapshot, false), this::convertRechargeImportRows, + file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 在异步线程中生成并上传钱包流水工作簿。 + * + * @param query 已冻结身份筛选的流水查询 + * @param scene 固定流水导出场景 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportTransactions(WalletTransactionDto query, WalletTransactionExportScene scene, + AsyncTaskProgressReporter reporter) { + if (scene == WalletTransactionExportScene.RECHARGE_PERSON_TOTAL) { + return exportRechargePersonTransactions(query, scene, reporter); + } + Page firstPage = fetchTransactionPage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), scene.getTaskName()); + updateProgressTotal(reporter, total, scene.getTaskName()); + String fileName = buildFileName(scene.getFileNamePrefix()); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletTransactionExportExcelVo.class, + scene.getFileNamePrefix(), "wallet-transaction-export-", + current -> fetchTransactionPage(current, query, false), this::convertTransactionRows, + file -> uploadExportFile(file, fileName)); + reporter.advance(scene.getTaskName() + "文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 执行兼容旧调用方的个人充值统计同步分片导出。 + * + * @param response 当前 HTTP 下载响应 + * @param query 已冻结身份、金额和部门规则的查询条件 + * @param scene 个人充值统计导出场景 + */ + private void exportRechargePersonTransaction(HttpServletResponse response, WalletTransactionDto query, + WalletTransactionExportScene scene) { + Page firstPage = fetchRechargePersonPage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), scene.getTaskName()); + response.setContentType(EXCEL_CONTENT_TYPE); + writePagedWorkbook(firstPage, total, null, WalletRechargePersonExportExcelVo.class, + scene.getFileNamePrefix(), "wallet-recharge-person-export-", + current -> fetchRechargePersonPage(current, query, false), this::convertRechargePersonRows, + file -> { + Files.copy(file, response.getOutputStream()); + response.flushBuffer(); + return null; + }); + } + + /** + * 在异步线程中生成并上传个人充值统计工作簿。 + * + * @param query 已冻结身份、金额和部门规则的查询条件 + * @param scene 个人充值统计导出场景 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportRechargePersonTransactions(WalletTransactionDto query, + WalletTransactionExportScene scene, + AsyncTaskProgressReporter reporter) { + Page firstPage = fetchRechargePersonPage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), scene.getTaskName()); + updateProgressTotal(reporter, total, scene.getTaskName()); + String fileName = buildFileName(scene.getFileNamePrefix()); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletRechargePersonExportExcelVo.class, + scene.getFileNamePrefix(), "wallet-recharge-person-export-", + current -> fetchRechargePersonPage(current, query, false), this::convertRechargePersonRows, + file -> uploadExportFile(file, fileName)); + reporter.advance(scene.getTaskName() + "文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 在异步线程中生成并上传账户当前余额工作簿。 + * + * @param query 已冻结身份筛选的账户余额查询 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportWalletBalance(WalletManageDto query, AsyncTaskProgressReporter reporter) { + Page firstPage = fetchWalletBalancePage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), BALANCE_EXPORT_TASK_NAME); + updateProgressTotal(reporter, total, BALANCE_EXPORT_TASK_NAME); + String fileName = buildFileName("账户余额"); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletManageExportExcelVo.class, + "账户余额", "wallet-balance-export-", current -> fetchWalletBalancePage(current, query, false), + (records, startIndex) -> convertWalletRows(records, startIndex, null), + file -> uploadExportFile(file, fileName)); + reporter.advance("账户余额导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 在异步线程中生成并上传指定月份钱包余额工作簿。 + * + * @param query 已冻结身份筛选的月度余额查询 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportWalletRecords(WalletManageBalanceDto query, + AsyncTaskProgressReporter reporter) { + Page firstPage = fetchWalletRecordPage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), RECORD_EXPORT_TASK_NAME); + updateProgressTotal(reporter, total, RECORD_EXPORT_TASK_NAME); + String fileName = buildFileName("钱包记录"); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletManageExportExcelVo.class, + "钱包记录", "wallet-record-export-", current -> fetchWalletRecordPage(current, query, false), + (records, startIndex) -> convertWalletRows(records, startIndex, query.getWalletRecordMonth()), + file -> uploadExportFile(file, fileName)); + reporter.advance("钱包记录导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 在异步线程中生成并上传批量充值扣款水单工作簿。 + * + * @param query 已冻结身份筛选的水单查询 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportRechargeImports(WalletRechargeImportDto query, + AsyncTaskProgressReporter reporter) { + Page firstPage = fetchRechargeImportPage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), RECHARGE_IMPORT_EXPORT_TASK_NAME); + updateProgressTotal(reporter, total, RECHARGE_IMPORT_EXPORT_TASK_NAME); + String fileName = buildFileName("充值扣款水单"); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, + WalletRechargeImportExportExcelVo.class, "充值扣款水单", "wallet-recharge-import-export-", + current -> fetchRechargeImportPage(current, query, false), this::convertRechargeImportRows, + file -> uploadExportFile(file, fileName)); + reporter.advance("充值扣款水单导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 在异步线程中生成并上传钱包开卡记录工作簿。 + * + * @param query 已冻结身份和勾选记录范围的开卡查询 + * @param reporter 当前任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportWalletActiveRecords(WalletActiveDto query, + AsyncTaskProgressReporter reporter) { + Page firstPage = fetchWalletActivePage(1L, query, true); + long total = validateTotal(firstPage.getTotal(), ACTIVE_EXPORT_TASK_NAME); + updateProgressTotal(reporter, total, ACTIVE_EXPORT_TASK_NAME); + String fileName = buildFileName("钱包开卡"); + String fileUrl = writePagedWorkbook(firstPage, total, reporter, WalletActiveExportExcelVo.class, + "钱包开卡", "wallet-active-export-", + current -> fetchWalletActivePage(current, query, false), this::convertWalletActiveRows, + file -> uploadExportFile(file, fileName)); + reporter.advance("钱包开卡导出文件已上传,共 " + total + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } + + /** + * 使用 EasyExcel 将分页数据流式写入二级表头工作簿,并把完整临时文件交给调用方消费。 + * + * @param firstPage 已取得总数和首批数据的分页结果 + * @param total 导出开始时统计的总记录数 + * @param reporter 异步进度报告器;同步兼容导出时为空 + * @param headClass EasyExcel 二级表头行类型 + * @param sheetName 工作表名称 + * @param temporaryFilePrefix 临时文件名前缀 + * @param pageFetcher 后续分页查询器 + * @param batchConverter 当前业务分片转换器 + * @param fileConsumer 完整临时文件消费器 + * @param 业务分页行类型 + * @param Excel 导出行类型 + * @param 文件消费结果类型 + * @return 文件消费器返回的结果 + */ + private R writePagedWorkbook(Page firstPage, long total, AsyncTaskProgressReporter reporter, + Class headClass, String sheetName, String temporaryFilePrefix, + ExportPageFetcher pageFetcher, + ExportBatchConverter batchConverter, + BatchExcelExportTemplate.FileConsumer fileConsumer) { + Path outputPath = null; + try { + outputPath = Files.createTempFile(temporaryFilePrefix, ".xlsx"); + long processedTotal = 0L; + long current = 1L; + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), headClass).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(sheetName).build(); + Page currentPage = firstPage; + while (processedTotal < total) { + List records = safeRecords(currentPage); + if (records.isEmpty()) { + break; + } + int recordCount = Math.toIntExact(Math.min(records.size(), total - processedTotal)); + List batch = records.subList(0, recordCount); + excelWriter.write(batchConverter.convert(batch, processedTotal), writeSheet); + processedTotal += batch.size(); + if (reporter != null) { + reporter.advance("已写入 " + processedTotal + " / " + total + " 条数据"); + } + if (processedTotal >= total) { + break; + } + currentPage = pageFetcher.fetch(++current); + } + if (processedTotal != total) { + throw new IllegalStateException("钱包数据在导出期间发生变化,预期 " + total + + " 条,实际处理 " + processedTotal + " 条"); + } + } + return fileConsumer.consume(outputPath); + } catch (IllegalArgumentException | IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|{}|生成或消费工作簿失败,sheetName:{}", LOG_TAG, sheetName, exception); + throw new IllegalStateException("生成钱包用户导出文件失败", exception); + } finally { + deleteTemporaryFile(outputPath); + } + } + + /** + * 分页查询钱包流水,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结的流水查询条件 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐页面展示资料的钱包流水分页 + */ + private Page fetchTransactionPage(long current, WalletTransactionDto query, + boolean searchCount) { + return walletTransactionService.scopeExportPageList( + new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of()); + } + + /** + * 分页查询钱包开卡记录,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结身份和勾选记录范围的开卡查询 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐用户、身份和商户资料的开卡记录分页 + */ + private Page fetchWalletActivePage(long current, WalletActiveDto query, + boolean searchCount) { + return walletActiveService.scopeExportPageList(new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query); + } + + /** + * 分页查询个人充值统计流水,并在数据库查询阶段应用固定排除部门规则。 + * + * @param current 当前一基页码 + * @param query 已冻结的个人充值统计筛选条件 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐用户、部门和钱包资料的个人充值统计分页 + */ + private Page fetchRechargePersonPage(long current, WalletTransactionDto query, + boolean searchCount) { + return walletTransactionService.scopeRechargePersonExportPage( + new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of(), + PERSON_RECHARGE_EXCLUDED_DEPT_IDS); + } + + /** + * 分页查询账户当前余额,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结的账户余额查询条件 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐页面展示资料的钱包分页 + */ + private Page fetchWalletBalancePage(long current, WalletManageDto query, boolean searchCount) { + return walletManageService.scopeExportPageList( + new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of()); + } + + /** + * 分页查询指定月份末钱包余额,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结的月度余额查询条件 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已计算历史余额并补齐展示资料的钱包分页 + */ + private Page fetchWalletRecordPage(long current, WalletManageBalanceDto query, + boolean searchCount) { + return walletManageService.scopeQbExportPage( + new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of()); + } + + /** + * 分页查询批量充值扣款水单,并控制只有第一页执行总数统计。 + * + * @param current 当前一基页码 + * @param query 已冻结的水单查询条件 + * @param searchCount 是否查询符合条件的总记录数 + * @return 已补齐页面展示资料的水单分页 + */ + private Page fetchRechargeImportPage(long current, WalletRechargeImportDto query, + boolean searchCount) { + return walletRechargeImportService.scopeExportPageList( + new Page<>(current, EXPORT_BATCH_SIZE, searchCount), query, DataScope.of()); + } + + /** + * 将一个钱包流水分片转换为二级表头导出行。 + * + * @param records 当前流水分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的流水导出行 + */ + private List convertTransactionRows(List records, + long startIndex) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletTransactionVo::getUserId).toList())); + List rows = new ArrayList<>(records.size()); + for (WalletTransactionVo source : records) { + WalletTransactionExportExcelVo row = new WalletTransactionExportExcelVo(); + fillTransactionColumns(row, source, userMap.get(source.getUserId()), + startIndex + rows.size() + 1L); + rows.add(row); + } + return rows; + } + + /** + * 将一个钱包开卡记录分片转换为中文二级表头导出行。 + * + * @param records 当前开卡记录分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的开卡导出行 + */ + private List convertWalletActiveRows(List records, + long startIndex) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletActiveVo::getUserId).toList())); + List rows = new ArrayList<>(records.size()); + for (WalletActiveVo source : records) { + WalletActiveExportExcelVo row = new WalletActiveExportExcelVo(); + fillUserColumns(row, userMap.get(source.getUserId()), source.getUsername(), source.getPhone(), + source.getDeptName(), source.getIdentityTypes(), source.getStudentInfo(), source.getTeacherInfo()); + row.setLineNumber(Long.toString(startIndex + rows.size() + 1L)); + row.setMerchantName(source.getMerchantName()); + row.setCardNumber(source.getCardNumber()); + row.setBindStatus(resolveYesNo(source.getUserId() != null && source.getWalletId() != null ? "1" : "0")); + row.setHardwareActiveStatus(resolveYesNo(source.getIsActiveHardware())); + row.setCreateTime(formatDateTime(source.getCreateTime())); + rows.add(row); + } + return rows; + } + + /** + * 将一个个人充值统计分片转换为带所属食堂的二级表头导出行。 + * + * @param records 当前个人充值统计流水分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的个人充值统计导出行 + */ + private List convertRechargePersonRows(List records, + long startIndex) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletTransactionVo::getUserId).toList())); + Map canteenDeptMap = fetchCanteenDeptMap(records.stream() + .map(WalletTransactionVo::getDeptId).filter(Objects::nonNull).distinct().toList()); + List rows = new ArrayList<>(records.size()); + for (WalletTransactionVo source : records) { + WalletRechargePersonExportExcelVo row = new WalletRechargePersonExportExcelVo(); + fillTransactionColumns(row, source, userMap.get(source.getUserId()), + startIndex + rows.size() + 1L); + WalletMchDeptViewVo canteenDept = canteenDeptMap.get(source.getDeptId()); + row.setCanteenName(canteenDept == null ? null : canteenDept.getMchName()); + rows.add(row); + } + return rows; + } + + /** + * 填充钱包流水及其派生导出行的公共用户、钱包和流水列。 + * + * @param row 目标流水导出行 + * @param source 当前钱包流水展示资料 + * @param user UPMS 完整用户及身份资料;历史用户已删除时为空 + * @param lineNumber 跨分页连续的一基序号 + */ + private void fillTransactionColumns(WalletTransactionExportExcelVo row, WalletTransactionVo source, + UserVO user, long lineNumber) { + fillUserColumns(row, user, source.getUsername(), source.getPhone(), source.getDeptName(), + source.getIdentityTypes(), source.getStudentInfo(), source.getTeacherInfo()); + row.setLineNumber(Long.toString(lineNumber)); + row.setWalletTypeName(source.getWalletTypeName()); + row.setCardNumber(source.getWalletCardNumber()); + row.setTitle(source.getTitle()); + row.setPrice(formatPrice(source.getPrice())); + row.setBalance(formatPrice(source.getBalance())); + row.setTags(source.getTags()); + row.setRemark(source.getRemark()); + row.setCreateTime(formatDateTime(source.getCreateTime())); + } + + /** + * 按当前分片的流水部门批量查询所属自营食堂。 + * + * @param deptIds 当前分片去重后的流水部门主键 + * @return 以流水部门主键为键的食堂部门视图;无法匹配的部门不进入结果 + */ + private Map fetchCanteenDeptMap(List deptIds) { + if (deptIds.isEmpty()) { + return Map.of(); + } + WalletMchDeptViewDto query = new WalletMchDeptViewDto(); + query.setDeptIdList(deptIds); + query.setMchTypeList(List.of(MerchantTypeEnum.CANTEEN.getType())); + List canteenDeptList = mchDeptViewMapper.selectList(query); + if (canteenDeptList == null || canteenDeptList.isEmpty()) { + return Map.of(); + } + return canteenDeptList.stream().filter(item -> item != null && item.getDeptId() != null) + .collect(Collectors.toMap(WalletMchDeptViewVo::getDeptId, item -> item, + (left, right) -> left, LinkedHashMap::new)); + } + + /** + * 将一个账户余额或月度余额分片转换为二级表头导出行。 + * + * @param records 当前钱包分片 + * @param startIndex 当前分片前已经写入的记录数 + * @param recordMonth 月度记录月份;账户当前余额场景为空 + * @return 与输入顺序一致的钱包导出行 + */ + private List convertWalletRows(List records, long startIndex, + String recordMonth) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletManageVo::getUserId).toList())); + List rows = new ArrayList<>(records.size()); + for (WalletManageVo source : records) { + WalletManageExportExcelVo row = new WalletManageExportExcelVo(); + fillUserColumns(row, userMap.get(source.getUserId()), source.getUsername(), source.getPhone(), + source.getDeptName(), source.getIdentityTypes(), source.getStudentInfo(), source.getTeacherInfo()); + row.setLineNumber(Long.toString(startIndex + rows.size() + 1L)); + row.setRecordMonth(recordMonth); + row.setWalletTypeName(source.getWalletTypeName()); + row.setCardNumber(source.getCardNumber()); + row.setBalance(formatPrice(source.getBalance())); + if (StrUtil.isBlank(recordMonth)) { + row.setTotalExpense(formatPrice(source.getTotalExpense())); + row.setTotalRecharge(formatPrice(source.getTotalRecharge())); + } + row.setFreezeStatus(resolveYesNo(source.getIsFreeze())); + row.setLastTopUpTime(formatDateTime(source.getLastTopUpTime())); + rows.add(row); + } + return rows; + } + + /** + * 将一个批量充值扣款水单分片转换为二级表头导出行。 + * + * @param records 当前水单分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的水单导出行 + */ + private List convertRechargeImportRows( + List records, long startIndex) { + Map userMap = fetchExportUserMap(extractUserIds(records.stream() + .map(WalletRechargeImportVo::getUserId).toList())); + List rows = new ArrayList<>(records.size()); + for (WalletRechargeImportVo source : records) { + WalletRechargeImportExportExcelVo row = new WalletRechargeImportExportExcelVo(); + fillUserColumns(row, userMap.get(source.getUserId()), source.getUsername(), source.getPhone(), + source.getUserDeptName(), source.getIdentityTypes(), source.getStudentInfo(), + source.getTeacherInfo()); + row.setLineNumber(Long.toString(startIndex + rows.size() + 1L)); + row.setImportMonth(source.getImportTime() == null ? null : source.getImportTime().toString().substring(0, 7)); + row.setWalletTypeName(source.getWalletTypeName()); + row.setBeforePrice(formatPrice(source.getBeforePrice())); + row.setPrice(formatPrice(normalizeRechargeImportPrice(source.getPrice(), source.getBizType()))); + row.setTags(source.getTags()); + row.setProcessStatus(source.getProcessStatus()); + row.setProcessTime(formatDateTime(source.getProcessTime())); + row.setCreateTime(formatDateTime(source.getCreateTime())); + rows.add(row); + } + return rows; + } + + /** + * 按一个业务分片中的用户主键查询 UPMS 完整导出资料。 + * + * @param userIds 当前分片去重后的用户主键 + * @return 以用户主键为键的完整用户、岗位和身份资料 + */ + private Map fetchExportUserMap(List userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + UserDTO query = new UserDTO(); + query.setUserIds(userIds); + R> response = remoteUserService.getScopeExportUserPage(1L, USER_EXPORT_BATCH_SIZE, query); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询钱包导出用户及身份资料失败")); + } + List users = response.getData().getRecords(); + if (users == null || users.isEmpty()) { + return Map.of(); + } + return users.stream().filter(user -> user != null && user.getUserId() != null) + .collect(Collectors.toMap(UserVO::getUserId, user -> user, (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 向任意钱包导出行填充完整用户和身份公共列。 + * + * @param row 目标导出行 + * @param user UPMS 完整用户资料;历史用户已经删除时为空 + * @param fallbackName 钱包业务记录中的历史姓名 + * @param fallbackPhone 钱包业务记录中的历史手机号 + * @param fallbackDeptName 钱包业务记录中的历史部门名称 + * @param fallbackIdentityTypes 钱包业务分页已补充的身份编码 + * @param fallbackStudentInfo 钱包业务分页已补充的学生资料 + * @param fallbackTeacherInfo 钱包业务分页已补充的老师资料 + */ + private void fillUserColumns(WalletUserExportExcelVo row, UserVO user, String fallbackName, + String fallbackPhone, String fallbackDeptName, + List fallbackIdentityTypes, SysUserStudentDto fallbackStudentInfo, + SysUserTeacherDto fallbackTeacherInfo) { + List identityTypes = user == null ? fallbackIdentityTypes : user.getIdentityTypes(); + SysUserStudentDto studentInfo = user == null ? fallbackStudentInfo : user.getStudentInfo(); + SysUserTeacherDto teacherInfo = user == null ? fallbackTeacherInfo : user.getTeacherInfo(); + row.setUserName(resolveUserName(user, fallbackName)); + row.setPhone(user == null ? fallbackPhone : StrUtil.blankToDefault(user.getPhone(), fallbackPhone)); + row.setDeptName(user == null ? fallbackDeptName + : StrUtil.blankToDefault(user.getDeptName(), fallbackDeptName)); + row.setPostNames(user == null ? "" : joinPostNames(user.getPostList())); + row.setIdentityTypeNames(joinIdentityNames(identityTypes)); + row.setStudentNo(studentInfo == null ? null : studentInfo.getStudentNo()); + row.setSchoolName(studentInfo == null ? null : studentInfo.getSchoolName()); + row.setClassName(studentInfo == null ? null : studentInfo.getClassName()); + row.setGuardianPhones(studentInfo == null ? null : studentInfo.getGuardianPhones()); + row.setTeacherNo(teacherInfo == null ? null : teacherInfo.getTeacherNo()); + } + + /** + * 依次按姓名、昵称、账号和钱包历史姓名确定导出姓名。 + * + * @param user UPMS 完整用户资料,历史用户不存在时为空 + * @param fallbackName 钱包业务记录中的历史姓名 + * @return 可用于财务核对的用户姓名 + */ + private String resolveUserName(UserVO user, String fallbackName) { + if (user == null) { + return StrUtil.blankToDefault(fallbackName, ""); + } + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.blankToDefault(user.getUsername(), StrUtil.blankToDefault(fallbackName, "")); + } + + /** + * 拼接用户岗位名称。 + * + * @param posts UPMS 完整用户资料中的岗位列表 + * @return 去空后的岗位名称,多个岗位使用中文逗号分隔 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName).filter(StrUtil::isNotBlank) + .collect(Collectors.joining(",")); + } + + /** + * 将用户身份协议编码转换并拼接为中文名称。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 身份中文名称,未知历史编码原样保留 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 保持旧水单导出的扣款负数语义。 + * + * @param price 数据库存储的操作金额,单位为分 + * @param bizType 充值申请业务编码 + * @return 扣款或食堂退还时为负数的分金额 + */ + private Integer normalizeRechargeImportPrice(Integer price, String bizType) { + if (price == null || price <= 0 || !WalletBizRechargeEnum.reductionCodes().contains(bizType)) { + return price; + } + return -price; + } + + /** + * 将数据库分金额转换为 Excel 使用的元金额。 + * + * @param amount 金额,单位为分;允许为空 + * @return 单位为元且保留两位小数的金额 + */ + private BigDecimal formatPrice(Number amount) { + return amount == null ? null : BigDecimal.valueOf(amount.longValue()) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + /** + * 将日期时间转换为 Excel 统一文本格式。 + * + * @param dateTime 业务日期时间,允许为空 + * @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空 + */ + private String formatDateTime(LocalDateTime dateTime) { + return dateTime == null ? null : dateTime.format(DATE_TIME_FORMATTER); + } + + /** + * 将项目常用的 1/0 是否编码转换为中文展示。 + * + * @param value 数据库是否编码,允许为空或历史未知值 + * @return 是、否、原始未知值或空值 + */ + private String resolveYesNo(String value) { + if (Objects.equals("1", value)) { + return "是"; + } + if (Objects.equals("0", value)) { + return "否"; + } + return value; + } + + /** + * 对流水查询应用固定场景并冻结身份用户范围。 + * + * @param query 当前任务独占的流水查询副本 + * @param scene 固定流水导出场景 + * @return 已冻结的同一查询对象 + */ + private WalletTransactionDto prepareTransactionQuery(WalletTransactionDto query, + WalletTransactionExportScene scene) { + // 全部流水场景必须保留页面传入的交易类型和收支方向,其余场景继续沿用原有服务端固定口径。 + if (!scene.getBizTypes().isEmpty()) { + query.setBizTypes(new ArrayList<>(scene.getBizTypes())); + } + if (scene != WalletTransactionExportScene.ALL) { + query.setType(scene.getTransactionType()); + } + query.setIsFindOrder(scene.isFindOrder()); + if (scene == WalletTransactionExportScene.RECHARGE_PERSON_TOTAL) { + prepareRechargePersonPriceQuery(query); + } + walletTransactionService.prepareIdentityQuery(query); + return query; + } + + /** + * 将个人充值统计页面提交的元金额转换为数据库分金额,并补充历史默认金额范围。 + * + * @param query 当前任务独占的个人充值统计查询副本 + */ + private void prepareRechargePersonPriceQuery(WalletTransactionDto query) { + if (query.getPrice() != null) { + query.setPrice(convertYuanIntegerToCent(query.getPrice())); + } + if (query.getPriceList() != null && !query.getPriceList().isEmpty()) { + query.setPriceList(query.getPriceList().stream().map(this::convertYuanIntegerToCent).toList()); + } + if (query.getPrice() == null && (query.getPriceList() == null || query.getPriceList().isEmpty())) { + query.setPriceList(new ArrayList<>(PERSON_RECHARGE_DEFAULT_PRICES)); + } + } + + /** + * 将页面按整数元提交的充值金额转换为数据库使用的分金额。 + * + * @param amountYuan 充值金额,单位为元 + * @return 充值金额,单位为分 + */ + private Integer convertYuanIntegerToCent(Integer amountYuan) { + try { + return Math.multiplyExact(amountYuan, 100); + } catch (ArithmeticException exception) { + throw new IllegalArgumentException("充值金额超出支持范围", exception); + } + } + + /** + * 将月度钱包记录身份筛选冻结为用户主键集合。 + * + * @param query 当前任务独占的月度余额查询副本 + * @return 已冻结身份筛选的同一查询对象 + */ + private WalletManageBalanceDto prepareWalletRecordQuery(WalletManageBalanceDto query) { + WalletManageDto walletQuery = new WalletManageDto(); + BeanUtils.copyProperties(query, walletQuery); + walletManageService.prepareIdentityQuery(walletQuery); + query.setUserId(walletQuery.getUserId()); + query.setUserIds(copyList(walletQuery.getUserIds())); + query.setIdentityTypes(null); + query.setStudentInfo(null); + query.setTeacherInfo(null); + query.setHasFace(null); + return query; + } + + /** + * 校验导出总数和单工作表容量。 + * + * @param total 第一页分页查询得到的总记录数 + * @param taskName 当前业务导出任务名称 + * @return 已通过校验的总记录数 + */ + private long validateTotal(long total, String taskName) { + if (total <= 0L) { + throw new IllegalStateException("没有符合当前条件的" + taskName.replace("导出", "") + "数据"); + } + if (total > MAX_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_EXPORT_ROWS + + " 条,请缩小查询范围后分批导出"); + } + return total; + } + + /** + * 根据总记录数更新异步任务总步数。 + * + * @param reporter 当前任务进度报告器 + * @param total 导出总记录数 + * @param taskName 当前业务导出任务名称 + */ + private void updateProgressTotal(AsyncTaskProgressReporter reporter, long total, String taskName) { + int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, EXPORT_BATCH_SIZE, + EXTRA_PROGRESS_STEPS); + reporter.updateTotal(progressTotal, "已查询 " + total + " 条数据,将分批生成" + taskName + "文件"); + } + + /** + * 获取分页记录并统一空值语义。 + * + * @param page 当前分页结果 + * @param 分页记录类型 + * @return 当前页记录;分页或记录为空时返回不可变空列表 + */ + private List safeRecords(Page page) { + return page == null || page.getRecords() == null ? List.of() : page.getRecords(); + } + + /** + * 去除当前分片用户主键中的空值和重复值。 + * + * @param userIds 业务分页行中的用户主键 + * @return 保持首次出现顺序的有效用户主键 + */ + private List extractUserIds(List userIds) { + return userIds.stream().filter(Objects::nonNull).distinct().toList(); + } + + /** + * 生成带时间戳的 XLSX 文件名。 + * + * @param prefix 当前业务文件名前缀 + * @return 可供用户下载的文件名 + */ + private String buildFileName(String prefix) { + return prefix + "_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx"; + } + + /** + * 上传完整工作簿并校验文件中心响应。 + * + * @param file 已关闭并可读取的完整临时 XLSX 文件 + * @param fileName 用户下载时使用的文件名 + * @return 文件中心下载地址 + * @throws Exception 文件读取或远程上传失败时抛出 + */ + private String uploadExportFile(Path file, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + EXCEL_CONTENT_TYPE, Files.readAllBytes(file)); + R uploadResult = remoteFileService.innerUpload(exportFile, EXPORT_FILE_DIR, null, EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的钱包导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回钱包导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 按文件中心现有约定转换中文原始文件名编码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理临时工作簿;失败只记录告警,不覆盖原始导出结果。 + * + * @param outputPath 待清理的临时文件路径,允许为空 + */ + private void deleteTemporaryFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception); + } + } + + /** + * 复制钱包流水查询及其可变筛选字段。 + * + * @param source Controller 接收的原始查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletTransactionDto copyTransactionQuery(WalletTransactionDto source) { + WalletTransactionDto target = new WalletTransactionDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setUserIds(copyList(source.getUserIds())); + target.setBizTypes(copyList(source.getBizTypes())); + target.setTagList(copyList(source.getTagList())); + target.setTotalDpetIds(copyList(source.getTotalDpetIds())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setScopeDeptIds(copyList(source.getScopeDeptIds())); + target.setPriceList(copyList(source.getPriceList())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(source.getCreateTimeList() == null ? null : source.getCreateTimeList().clone()); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制钱包开卡查询及其可变筛选字段。 + * + * @param source Controller 接收的原始开卡查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletActiveDto copyWalletActiveQuery(WalletActiveDto source) { + WalletActiveDto target = new WalletActiveDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setIds(copyList(source.getIds())); + target.setUserIds(copyList(source.getUserIds())); + target.setMerchantIdList(copyList(source.getMerchantIdList())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制账户余额查询及其可变筛选字段。 + * + * @param source Controller 接收的原始查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletManageDto copyWalletManageQuery(WalletManageDto source) { + WalletManageDto target = new WalletManageDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setUserIds(copyList(source.getUserIds())); + target.setWalletIds(copyList(source.getWalletIds())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(source.getCreateTimeList() == null ? null : source.getCreateTimeList().clone()); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制月度钱包余额查询及其可变筛选字段。 + * + * @param source Controller 接收的原始查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletManageBalanceDto copyWalletRecordQuery(WalletManageBalanceDto source) { + WalletManageBalanceDto target = new WalletManageBalanceDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setUserIds(copyList(source.getUserIds())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setScopeDeptIds(copyList(source.getScopeDeptIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制充值水单查询及其可变筛选字段。 + * + * @param source Controller 接收的原始查询对象 + * @return 后台任务独占的查询快照 + */ + private WalletRechargeImportDto copyRechargeImportQuery(WalletRechargeImportDto source) { + WalletRechargeImportDto target = new WalletRechargeImportDto(); + if (source == null) { + return target; + } + BeanUtils.copyProperties(source, target); + target.setUserIds(copyList(source.getUserIds())); + target.setUserUniqueIds(copyList(source.getUserUniqueIds())); + target.setTagList(copyList(source.getTagList())); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIdentityTypes(copyList(source.getIdentityTypes())); + target.setCreateTimeList(source.getCreateTimeList() == null ? null : source.getCreateTimeList().clone()); + target.setProcessTimeList(source.getProcessTimeList() == null ? null : source.getProcessTimeList().clone()); + target.setStudentInfo(copyStudentInfo(source.getStudentInfo())); + target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo())); + return target; + } + + /** + * 复制学生身份资料,避免后台任务与请求对象共享可变状态。 + * + * @param source 原学生身份资料 + * @return 独立副本;源对象为空时返回空 + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份资料,避免后台任务与请求对象共享可变状态。 + * + * @param source 原老师身份资料 + * @return 独立副本;源对象为空时返回空 + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 复制可变列表并保持空值语义。 + * + * @param source 原列表 + * @param 列表元素类型 + * @return 独立列表;源列表为空值时返回空值 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** 按指定页码读取一个钱包业务导出分片。 */ + @FunctionalInterface + private interface ExportPageFetcher { + + /** + * 读取指定一基页码的数据。 + * + * @param current 当前一基页码 + * @return 当前页业务记录 + */ + Page fetch(long current); + } + + /** 将一个钱包业务分片转换为对应的 Excel 导出行。 */ + @FunctionalInterface + private interface ExportBatchConverter { + + /** + * 转换当前分片并生成跨分页连续序号。 + * + * @param records 当前业务分片 + * @param startIndex 当前分片前已经写入的记录数 + * @return 与输入顺序一致的 Excel 导出行 + */ + List convert(List records, long startIndex); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java index 27fe654a..a2196212 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchManageServiceImpl.java @@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.admin.api.dto.UserInfo; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.constant.user.UserRelationTypeEnum; import com.bsd.pjcloud.admin.api.entity.SysDept; import com.bsd.pjcloud.admin.api.feign.RemoteDeptService; @@ -110,6 +111,7 @@ public class MchManageServiceImpl extends ServiceImpl UserIdentityTypeEnum.valueOfByType(code) != null) + .distinct() + .toList()); + } + } + + /** + * 规范化钱包充值配置中的身份协议编码,避免无效值写入商户 JSON 配置。 + * 充值配置为空时保持原样,以兼容历史商户及“空列表表示不限制身份”的约定。 + * + * @param dto 本次创建或编辑提交的商户数据 + */ + private void normalizeTopUpConfig(MchManageEntity dto) { + MchManageEntity.TopUpConfig conf = dto.getTopUpConfig(); + if (conf == null || ObjectUtil.isEmpty(conf.getUserIdentityTypeList())) { + return; + } + conf.setUserIdentityTypeList(conf.getUserIdentityTypeList().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(code -> UserIdentityTypeEnum.valueOfByType(code) != null) + .distinct() + .toList()); } @CacheEvict( @@ -471,6 +503,36 @@ public class MchManageServiceImpl extends ServiceImpl identityTypes) { + if (merchant == null || merchant.getTopUpConfig() == null) { + return true; + } + List allowedIdentityTypes = merchant.getTopUpConfig().getUserIdentityTypeList() == null + ? List.of() + : merchant.getTopUpConfig().getUserIdentityTypeList().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(code -> !code.isEmpty()) + .distinct() + .toList(); + if (allowedIdentityTypes.isEmpty()) return true; + if (ObjectUtil.isEmpty(identityTypes)) { + return false; + } + return identityTypes.stream() + .filter(Objects::nonNull) + .map(String::trim) + .anyMatch(allowedIdentityTypes::contains); + } + /** * 刷新商户二维码 */ @@ -560,6 +622,33 @@ public class MchManageServiceImpl extends ServiceImpl先通过用户所属部门和允许的商户类型定位最近的匹配商户,再复用按商户查询覆盖部门的逻辑, + * 将原本由调用方完成的两段查询收敛到钱包服务内部。

+ * + * @param userId 系统用户主键 + * @param mchTypeList 允许匹配的商户类型集合 + * @return 目标商户覆盖的用户部门主键集合;用户、所属部门、匹配商户或关联部门不存在时返回空集合 + */ + @Override + public List getSelfMchUserDeptIdListByUserId(Long userId, List mchTypeList) { + if (userId == null || ObjectUtil.isEmpty(mchTypeList)) { + return List.of(); + } + R userResponse = remoteUserService.getInnerByUserId(userId); + if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null + || userResponse.getData().getSysUser() == null || userResponse.getData().getSysUser().getDeptId() == null) { + return List.of(); + } + MchManageVo merchant = getViewsCanteenMch(userResponse.getData().getSysUser().getDeptId(), mchTypeList); + if (merchant == null || merchant.getId() == null) { + return List.of(); + } + return getSelfCanteenUserDeptIdList(merchant.getId()); + } + /** * 判断用户是否能查看到所有食堂 */ diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java index 1d70f7ac..dcdbf679 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImpl.java @@ -11,9 +11,9 @@ import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; -import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchManageEntity; @@ -28,11 +28,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; /** * 商户管理 @@ -59,7 +59,7 @@ public class MchPaySettingServiceImpl extends ServiceImpl pageList(MchPaySettingDto dto, DataScope dataScope) { - return baseMapper.pageList(dto, dataScope); + return baseMapper.pageList(dto, dataScope).stream().map(this::sanitizeForClient).toList(); } /** @@ -75,44 +75,70 @@ public class MchPaySettingServiceImpl extends ServiceImpl { - try { - MchManageVo mchManage = mchManageService.join(dto.getMchId()); - if (ObjectUtil.isNotEmpty(mchManage)) { - thirdAppPlusPayService.remoterChangeMch(mchManage); - } - } catch (Exception e) { - log.error("编辑商户的回调异常:{}", e.getMessage()); - } - }); + syncMerchantRelations(dto.getMchId()); return dto.getId(); } @Override + @Transactional(rollbackFor = Exception.class) public MchPaySettingEntity edit(MchPaySettingDto dto) { if (existPlatformType(dto)) { - throw new RuntimeException("改支付类型已经设置,请直接修改"); + throw new RuntimeException("该支付类型已经设置,请直接修改"); } - preserveAliPayAuthToken(dto); + removeRelationOwnedFields(dto); baseMapper.updateById(dto); - CompletableFuture.runAsync(() -> { - try { - MchManageVo mchManage = mchManageService.join(dto.getMchId()); - if (ObjectUtil.isNotEmpty(mchManage)) { - thirdAppPlusPayService.remoterChangeMch(mchManage); - } - } catch (Exception e) { - log.error("编辑商户的回调异常:{}", e.getMessage()); - } - }); + syncMerchantRelations(dto.getMchId()); return sanitizeForClient(dto); } + /** + * 删除配置后同步商户剩余启用配置,thirdparty 会把不再使用的关系标记为历史关系。 + * + * @param ids 当前租户内支付配置主键 + * @return 全部删除成功 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean delete(List ids) { + if (ids == null || ids.isEmpty()) { + return false; + } + List mchIds = listByIds(ids).stream().map(MchPaySettingEntity::getMchId) + .filter(ObjectUtil::isNotEmpty).distinct().toList(); + boolean removed = removeBatchByIds(ids); + if (!removed) { + return false; + } + for (Long mchId : mchIds) { + syncMerchantRelations(mchId); + } + return true; + } + + /** + * 同步确认 thirdparty 已接收关系事实变更;不再使用脱离租户上下文且可能丢事件的公共线程池。 + * + * @param mchId 发生支付配置变更的商户主键 + */ + private void syncMerchantRelations(Long mchId) { + MchManageVo merchant = mchManageService.join(mchId); + if (merchant == null) { + throw new IllegalStateException("支付配置所属商户不存在"); + } + R result = thirdAppPlusPayService.remoterChangeMch(merchant); + if (result == null || !result.isOk() || !Boolean.TRUE.equals(result.getData())) { + log.error("同步商户平台分佣关系失败,mchId:{},code:{}", mchId, result == null ? null : result.getCode()); + throw new IllegalStateException("同步支付平台分佣关系失败,请稍后重试"); + } + } + /** * 用户可以支付的方式(目前微信、支付宝) */ @@ -142,36 +168,33 @@ public class MchPaySettingServiceImpl extends ServiceImpl滚动发布期间旧管理端仍可能提交这些键,钱包服务必须在持久化边界统一剔除, + * 防止支付配置 JSON 与关系事实形成两个相互漂移的配置源。

+ * + * @param setting 待持久化或返回客户端的支付配置 */ - private void preserveAliPayAuthToken(MchPaySettingDto dto) { - if (dto == null || dto.getId() == null || !AppPlusPlatformPayType.ALI_PAY.getType().equals(dto.getPlatformType()) - || StrUtil.isBlank(dto.getSettingJson())) { + private void removeRelationOwnedFields(MchPaySettingEntity setting) { + if (setting == null || StrUtil.isBlank(setting.getSettingJson())) { return; } - MchPaySettingEntity existing = getById(dto.getId()); - if (existing == null || StrUtil.isBlank(existing.getSettingJson())) { - return; + if (!JSONUtil.isTypeJSON(setting.getSettingJson())) { + throw new RuntimeException("支付配置JSON无效"); } try { - MchPaySettingEntity.AliPaySetting incoming = JSONUtil.toBean( - dto.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - MchPaySettingEntity.AliPaySetting stored = JSONUtil.toBean( - existing.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - if (incoming != null && stored != null && StrUtil.isBlank(incoming.getAppAuthToken()) - && StrUtil.isNotBlank(stored.getAppAuthToken())) { - incoming.setAppAuthToken(stored.getAppAuthToken()); - dto.setSettingJson(JSONUtil.toJsonStr(incoming)); - } + var settingJson = JSONUtil.parseObj(setting.getSettingJson()); + settingJson.remove("subMchid"); + settingJson.remove("accountKey"); + settingJson.remove("appAuthToken"); + setting.setSettingJson(settingJson.isEmpty() ? null : settingJson.toString()); } catch (RuntimeException exception) { - throw new RuntimeException("支付宝支付配置无效"); + throw new RuntimeException("支付配置JSON无效"); } } /** - * 复制并脱敏支付配置,保证调用方不能通过返回对象修改缓存或内部商户配置。 + * 复制支付配置并剔除关系表负责的渠道字段,保证客户端不会读到旧配置或完整授权令牌。 * * @param source 内部完整支付配置 * @param 支付配置实体或视图类型 @@ -183,18 +206,10 @@ public class MchPaySettingServiceImpl extends ServiceImpl pageJoin(Page page, WalletRechargeApplyDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto, dataScope); if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletRechargeApplyVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserById(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list.getRecords(), true); // 部门 List deptIds = list.getRecords().stream().map(WalletRechargeApplyVo::getDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -229,6 +227,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl scopePageList(Page page, WalletRechargeApplyDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page list = baseMapper.scopePageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; @@ -473,14 +472,7 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl list, WalletRechargeApplyDto dto) { if (ObjectUtil.isEmpty(list)) return; - // 用户 - List userIds = list.stream().map(WalletRechargeApplyVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - List userList = userService.getUserById(new ArrayList<>(userIds)).getData(); - if (ObjectUtil.isNotEmpty(userList)) { - DataUtil.convertUserList(list, CollectionUtils.convertMap(userList, SysUser::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); // 部门 List deptIds = list.stream().map(WalletRechargeApplyVo::getDeptId).distinct().toList(); if (ObjectUtil.isNotEmpty(deptIds)) { @@ -518,6 +510,23 @@ public class WalletRechargeApplyServiceImpl extends ServiceImpl().eq(WalletRechargeApplyEntity::getUserId, walletId).eq(WalletRechargeApplyEntity::getProcessStatus, NodeStatusEnum.JXZ.getCode())); return count >= 10; diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeImportServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeImportServiceImpl.java index c1e00d80..a7987f74 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeImportServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeImportServiceImpl.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.wallet.api.vo.recharge.WalletRechargeImportVo; import com.bsd.pjcloud.wallet.mapper.WalletTypeMapper; import com.bsd.pjcloud.wallet.mapper.recharge.WalletRechargeImportMapper; import com.bsd.pjcloud.wallet.service.*; +import com.bsd.pjcloud.wallet.support.WalletUserIdentitySupport; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; @@ -60,47 +61,52 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl pageJoin(Page page, WalletRechargeImportDto dto) { + prepareIdentityQuery(dto); Page list = baseMapper.pageJoin(page, dto); - if (ObjectUtil.isNotEmpty(list.getRecords())) { - // 用户 - List userIds = list.getRecords().stream().map(WalletRechargeImportVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } - } - return list; - } - - @Override - public List listJoin(WalletRechargeImportDto dto) { - List list = baseMapper.listJoin(dto); - if (ObjectUtil.isNotEmpty(list)) { - // 用户 - List userIds = list.stream().map(WalletRechargeImportVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } - } - return list; - } - - @Override - public Page scopePageList(Page page, WalletRechargeImportDto dto, DataScope dataScope) { - Page list = baseMapper.scopePageList(page, dto, dataScope); disposeList(list.getRecords(), dto); return list; } + @Override + public List listJoin(WalletRechargeImportDto dto) { + prepareIdentityQuery(dto); + List list = baseMapper.listJoin(dto); + disposeList(list, dto); + return list; + } + + @Override + public Page scopePageList(Page page, WalletRechargeImportDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); + Page list = baseMapper.scopePageList(page, dto, dataScope); + disposeList(list.getRecords(), dto); + return list; + } + + /** + * 分页查询水单导出业务资料,并跳过普通列表的 UPMS 用户身份回填。 + * + * @param page 分页参数;仅第一页执行总数查询 + * @param dto 已冻结身份的水单筛选条件 + * @param dataScope 当前操作者的数据权限范围 + * @return 已装配审批关联字段和钱包类型的水单分页 + */ + @Override + public Page scopeExportPageList(Page page, WalletRechargeImportDto dto, + DataScope dataScope) { + Page list = baseMapper.scopePageList(page, dto, dataScope); + disposeExportList(list.getRecords()); + return list; + } + @Override public List scopeList(WalletRechargeImportDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List list = baseMapper.scopeList(dto, dataScope); disposeList(list, dto); return list; @@ -128,16 +134,28 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl list, WalletRechargeImportDto dto) { + if (ObjectUtil.isEmpty(list)) { + return; + } + // 普通列表保持既有用户资料契约,导出分页由专用导出服务获取岗位等完整资料。 + userIdentitySupport.fillUserInfo(list, true); + disposeExportList(list); + } + + /** + * 装配水单列表和统一导出共用的钱包类型资料。 + * + * @param list 当前水单分页记录;为空时不查询钱包类型 + */ + private void disposeExportList(List list) { if (ObjectUtil.isNotEmpty(list)) { - // 用户 - List userIds = list.stream().map(WalletRechargeImportVo::getUserId).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - R> userList = userService.getUserDeptByIds(new ArrayList<>(userIds)); - if (ObjectUtil.isNotEmpty(userList.getData())) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId)); - } - } // 饭卡类型 List walletTypeId = list.stream().map(WalletRechargeImportVo::getWalletTypeId).distinct().toList(); if (ObjectUtil.isNotEmpty(walletTypeId)) { @@ -149,4 +167,22 @@ public class WalletRechargeImportServiceImpl extends ServiceImpl scopePageList(Page page, WalletRechargeBindInvoiceDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageList(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -132,6 +137,7 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl scopeList(WalletRechargeBindInvoiceDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); List pageList = baseMapper.scopeList(dto, dataScope); disposeList(pageList, dto); return pageList; @@ -550,6 +556,7 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl list, WalletRechargeBindInvoiceDto dto) { if (ObjectUtil.isEmpty(list)) return; + userIdentitySupport.fillUserInfo(list, true); // 审批流程 if (dto.getIsFindUserCheck()) { List processInstanceIds = list.stream().map(WalletRechargeInvoiceVo::getProcessId).filter(ObjectUtil::isNotEmpty).distinct().toList(); @@ -568,4 +575,23 @@ public class WalletRechargeInvoiceServiceImpl extends ServiceImpl constrainedUserIds = dto.getUserIds() == null ? null + : dto.getUserIds().stream().filter(ObjectUtil::isNotEmpty).map(Long::valueOf).toList(); + List userIds = userIdentitySupport.findIdentityUserIds(dto, null, constrainedUserIds); + dto.setUserIds(userIds.stream().map(String::valueOf).toList()); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java index 5dfae29b..d4c66d52 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImpl.java @@ -6,11 +6,11 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; import com.bsd.pjcloud.admin.api.vo.SysUserFaceStatusCountVo; +import com.bsd.pjcloud.admin.api.vo.UserVO; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.util.BeanUtils; import com.bsd.pjcloud.common.core.util.R; @@ -25,6 +25,7 @@ import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.GuardianWardRechargeCreateDto; import com.bsd.pjcloud.wallet.api.dto.recharge.app.WalletAppRechargeDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.WalletTypeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargePackageEntity; @@ -73,6 +74,9 @@ import java.util.Set; @Service public class WalletRechargeServiceImpl extends ServiceImpl implements WalletRechargeService { + /** 人工退款冲正流水业务号前缀,售后单 ID 保证财务流水可以反查原退款。 */ + private static final String REFUND_ROLLBACK_BIZ_PREFIX = "refund_rollback:"; + @Lazy private final WalletRechargeService walletRechargeService; private final WalletRechargePackageService walletRechargePackageService; @@ -128,6 +132,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl listUserBusinessItems(List userIds, Set wardUserIds, Long currentUserId) { - R> userResult; + R> userResult; try { - userResult = remoteUserService.getInnerUserById(userIds); + // user-dept 接口会一次补齐身份协议编码,充值资格不能依赖不含身份的 SysUser 接口。 + userResult = remoteUserService.getUserDeptByIds(userIds); } catch (Exception exception) { throw new RuntimeException("查询关系人信息失败", exception); } if (userResult == null || !userResult.isOk() || userResult.getData() == null) { throw new RuntimeException("查询关系人信息失败"); } - Map userMap = new LinkedHashMap<>(); + Map userMap = new LinkedHashMap<>(); userResult.getData().forEach(user -> userMap.put(user.getUserId(), user)); Map faceCountMap = listUserFaceStatusCounts(userIds); Map reservationCountMap = listUserPendingReservationCounts(userIds); @@ -197,7 +203,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl() + .eq(WalletTransactionEntity::getWalletId, walletId) + .eq(WalletTransactionEntity::getBizId, REFUND_ROLLBACK_BIZ_PREFIX + afterSaleId) + .eq(WalletTransactionEntity::getBizType, WalletBizEnum.REFUND.getCode()) + .orderByDesc(WalletTransactionEntity::getId) + .last("limit 1")); + } + /** * 校验幂等退款记录的资金目标和金额,防止错误复用售后单 ID 静默返回另一笔退款。 * @@ -422,13 +539,14 @@ public class WalletRechargeServiceImpl extends ServiceImpl> result; + R> result; try { - result = remoteUserService.getInnerUserById(List.of(wardUserId)); + // 返回身份协议编码,创建待支付记录前仍需按最新身份配置终审。 + result = remoteUserService.getUserDeptByIds(List.of(wardUserId)); } catch (Exception exception) { throw new RuntimeException("查询被监护人信息失败", exception); } @@ -441,23 +559,51 @@ public class WalletRechargeServiceImpl extends ServiceImpl> userResult; + try { + userResult = remoteUserService.getUserDeptByIds(List.of(wallet.getUserId())); + } catch (Exception exception) { + throw new RuntimeException("查询充值用户身份失败", exception); + } + if (userResult == null || !userResult.isOk() || CollUtil.isEmpty(userResult.getData())) { + throw new RuntimeException("查询充值用户身份失败"); + } + MchManageVo merchant = mchManageService.getViewsCanteenMchByDeptId(wallet.getDeptId()); + if (merchant == null) { + throw new RuntimeException("钱包所属食堂不存在"); + } + if (!mchManageService.isTopUpIdentityAllowed(merchant, userResult.getData().get(0).getIdentityTypes())) { + throw new RuntimeException("当前人员身份不支持钱包充值"); + } + } + /** * 构造移动端可展示的脱敏饭卡摘要,不返回钱包 ID 或完整卡号。 */ - private GuardianWardRechargeItemVo buildWardItem(SysUser ward, WalletManageEntity wallet) { + private GuardianWardRechargeItemVo buildWardItem(UserVO ward, WalletManageEntity wallet) { GuardianWardRechargeItemVo item = new GuardianWardRechargeItemVo(); item.setWardUserId(ward == null ? wallet == null ? null : wallet.getUserId() : ward.getUserId()); item.setWardName(displayName(ward)); item.setWardAvatar(ward == null ? null : ward.getAvatar()); + item.setIdentityTypes(ward == null || ward.getIdentityTypes() == null ? List.of() : List.copyOf(ward.getIdentityTypes())); if (wallet == null) { item.setWalletOpened(false); item.setBalance(0); @@ -473,25 +619,39 @@ public class WalletRechargeServiceImpl extends ServiceImpl identityTypes) { if (ObjectUtil.equals(wallet.getIsFreeze(), YesNoEnum.YES.getCode())) { return "饭卡已冻结"; } + // 新建但尚未绑定实体卡的钱包可能没有写入绑定状态,空状态与“未激活”均表示可接收余额充值。 + boolean cardStatusSupportsRecharge = StrUtil.isBlank(wallet.getStatus()) + || ObjectUtil.equals(wallet.getStatus(), WalleCardStatusEnum.NONE_ACTIVE.getCode()) + || ObjectUtil.equals(wallet.getStatus(), WalleCardStatusEnum.ACTIVE.getCode()); + if (!cardStatusSupportsRecharge) { + return cardStatusName(wallet.getStatus()) + ",暂不可充值"; + } + MchManageVo merchant = mchManageService.getViewsCanteenMchByDeptId(wallet.getDeptId()); + if (merchant == null) { + return "饭卡所属食堂不存在"; + } + // 商户未配置身份限制时保持历史行为;配置非空时要求目标用户至少命中一种身份。 + if (!mchManageService.isTopUpIdentityAllowed(merchant, identityTypes)) { + return "当前人员身份不支持钱包充值"; + } return ""; } @@ -575,7 +735,7 @@ public class WalletRechargeServiceImpl extends ServiceImpl scopePageList(Page page, WalletRuleTransactionDto dto, DataScope dataScope) { + prepareIdentityQuery(dto); Page pageList = baseMapper.scopePageList(page, dto, dataScope); disposeList(pageList.getRecords(), dto); return pageList; @@ -188,13 +193,23 @@ public class WalletRuleTransactionServiceImpl extends ServiceImpl list, WalletRuleTransactionDto dto) { - // 用户 - List userIds = list.stream().map(WalletRuleTransactionVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList(); - if (ObjectUtil.isNotEmpty(userIds)) { - List userList = userService.getUserDeptByIds(new ArrayList<>(userIds)).getData(); - if (ObjectUtil.isNotEmpty(userList)) { - DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList, UserVO::getUserId)); - } - } + userIdentitySupport.fillUserInfo(list, true); } -} \ No newline at end of file + + /** + * 将身份条件转换为钱包转账流水关联用户的主键集合。 + * + * @param dto 钱包转账流水查询条件;处理后保留最终用户主键集合 + */ + private void prepareIdentityQuery(WalletRuleTransactionDto dto) { + if (!userIdentitySupport.hasIdentityQuery(dto)) { + return; + } + dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds())); + dto.setUserId(null); + dto.setIdentityTypes(null); + dto.setStudentInfo(null); + dto.setTeacherInfo(null); + dto.setHasFace(null); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java new file mode 100644 index 00000000..ac959177 --- /dev/null +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/support/WalletUserIdentitySupport.java @@ -0,0 +1,257 @@ +package com.bsd.pjcloud.wallet.support; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +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.BaseUserIdentityQueryInterface; +import com.bsd.pjcloud.common.core.util.CollectionUtils; +import com.bsd.pjcloud.common.core.util.R; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 钱包后台列表的用户身份查询与展示资料装配器。 + * + *

身份资料由 UPMS 统一维护。本组件先通过带数据权限的用户接口筛选用户主键,再把主键交给钱包表查询; + * 列表返回后仍通过同一接口批量补齐用户主资料和身份资料,避免钱包模块复制身份查询 SQL。

+ */ +@Component +@RequiredArgsConstructor +public class WalletUserIdentitySupport { + + /** 单次调用 UPMS 查询的最大用户数量,控制跨服务响应体和内存占用。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 身份条件无匹配用户时使用的无效用户主键,确保下游忽略空集合的 SQL 不会放宽查询。 */ + private static final Long NO_MATCH_USER_ID = -1L; + + /** UPMS 用户远程服务,负责数据权限、身份筛选及身份资料返回。 */ + private final RemoteUserService remoteUserService; + + /** + * 判断查询对象是否携带实际生效的身份或人脸条件。 + * + * @param query 钱包业务查询对象;为空表示没有身份条件 + * @return 至少存在一个有效身份、人脸或身份扩展条件时返回 {@code true} + */ + public boolean hasIdentityQuery(BaseUserIdentityQueryInterface query) { + if (query == null) { + return false; + } + return query.getIdentityTypes() != null && !query.getIdentityTypes().isEmpty() + || hasStudentQuery(query.getStudentInfo()) + || hasTeacherQuery(query.getTeacherInfo()) + || StrUtil.isNotBlank(query.getHasFace()); + } + + /** + * 在现有用户条件范围内查询符合身份条件的用户主键。 + * + *

UPMS 调用失败时抛出异常并终止钱包查询;没有匹配用户时返回无效主键,防止下游 SQL 因空集合而查询全部数据。

+ * + * @param query 身份及人脸查询条件 + * @param userId 页面已有的单用户筛选条件 + * @param userIds 页面已有的多用户筛选条件 + * @return 同时符合已有用户范围、当前操作者数据权限和身份条件的用户主键 + */ + public List findIdentityUserIds(BaseUserIdentityQueryInterface query, Long userId, List userIds) { + List constrainedUserIds = mergeUserConstraints(userId, userIds); + if (constrainedUserIds != null && constrainedUserIds.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + UserDTO userQuery = buildUserQuery(query, constrainedUserIds); + List users = fetchAllUsers(userQuery); + if (users.isEmpty()) { + return List.of(NO_MATCH_USER_ID); + } + return users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + } + + /** + * 批量补齐钱包列表的用户主资料、部门及身份资料。 + * + * @param rows 当前页或当前导出批次的钱包业务记录 + * @param updateDepartment 是否使用 UPMS 当前部门覆盖业务记录中的部门展示字段 + * @param 同时实现用户部门和用户身份展示契约的业务 VO 类型 + */ + public void fillUserInfo(List rows, + boolean updateDepartment) { + if (rows == null || rows.isEmpty()) { + return; + } + List userIds = rows.stream().map(BaseUserDeptInterface::getUserId).filter(Objects::nonNull).distinct().toList(); + if (userIds.isEmpty()) { + return; + } + UserDTO userQuery = new UserDTO(); + userQuery.setUserIds(userIds); + Map userMap = CollectionUtils.convertMap(fetchAllUsers(userQuery), UserVO::getUserId); + for (T row : rows) { + UserVO user = userMap.get(row.getUserId()); + if (user == null) { + continue; + } + // 部分历史 VO 显式声明了标准字段,需同时回填旧字段以兼容既有接口与导出。 + row.setUsername(user.getName()); + row.setUserMobile(user.getPhone()); + row.setUserAvatar(user.getAvatar()); + row.setName(user.getName()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + if (updateDepartment) { + row.setUserDeptId(user.getDeptId()); + row.setUserDeptName(user.getDeptName()); + row.setDeptName(user.getDeptName()); + } + row.setIdentityTypes(copyList(user.getIdentityTypes())); + row.setStudentInfo(copyStudentInfo(user.getStudentInfo())); + row.setTeacherInfo(copyTeacherInfo(user.getTeacherInfo())); + } + } + + /** + * 合并单用户和多用户条件;两者同时存在时取交集,保持原查询的“同时满足”语义。 + * + * @param userId 单用户主键 + * @param userIds 多用户主键集合 + * @return 去重后的用户限制;返回 {@code null} 表示未限制用户,空集合表示条件互斥 + */ + private List mergeUserConstraints(Long userId, List userIds) { + LinkedHashSet constraints = new LinkedHashSet<>(); + if (userIds != null && !userIds.isEmpty()) { + userIds.stream().filter(Objects::nonNull).forEach(constraints::add); + } + if (userId == null) { + return constraints.isEmpty() ? null : new ArrayList<>(constraints); + } + if (constraints.isEmpty()) { + return List.of(userId); + } + return constraints.contains(userId) ? List.of(userId) : List.of(); + } + + /** + * 把钱包查询契约转换为 UPMS 用户查询对象,并复制可变身份条件。 + * + * @param query 钱包侧身份查询条件 + * @param constrainedUserIds 已有用户筛选范围 + * @return 可独立交给 UPMS 使用的用户查询对象 + */ + private UserDTO buildUserQuery(BaseUserIdentityQueryInterface query, List constrainedUserIds) { + UserDTO userQuery = new UserDTO(); + userQuery.setIdentityTypes(copyList(query.getIdentityTypes())); + userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo())); + userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo())); + userQuery.setHasFace(query.getHasFace()); + userQuery.setUserIds(copyList(constrainedUserIds)); + return userQuery; + } + + /** + * 分页读取当前操作者权限范围内的全部匹配用户。 + * + * @param query UPMS 用户查询条件 + * @return 按 UPMS 分页顺序合并的用户列表 + */ + private List fetchAllUsers(UserDTO query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + R> response = remoteUserService.getScopeBasicUserPage(current, USER_BATCH_SIZE, query); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.blankToDefault(message, "查询权限用户及身份信息失败")); + } + Page page = response.getData(); + List records = page.getRecords() == null ? List.of() : page.getRecords(); + users.addAll(records); + total = page.getTotal(); + if (records.isEmpty()) { + break; + } + current++; + } while (users.size() < total); + return users; + } + + /** + * 判断学生扩展对象是否包含有效筛选字段。 + * + * @param studentInfo 学生身份扩展条件 + * @return 学号、学校、班级或监护人手机号任一非空时返回 {@code true} + */ + private boolean hasStudentQuery(SysUserStudentDto studentInfo) { + return studentInfo != null && (StrUtil.isNotBlank(studentInfo.getStudentNo()) + || StrUtil.isNotBlank(studentInfo.getSchoolName()) + || StrUtil.isNotBlank(studentInfo.getClassName()) + || StrUtil.isNotBlank(studentInfo.getGuardianPhones())); + } + + /** + * 判断老师扩展对象是否包含有效筛选字段。 + * + * @param teacherInfo 老师身份扩展条件 + * @return 老师学号非空时返回 {@code true} + */ + private boolean hasTeacherQuery(SysUserTeacherDto teacherInfo) { + return teacherInfo != null && StrUtil.isNotBlank(teacherInfo.getTeacherNo()); + } + + /** + * 复制学生身份资料,避免跨服务响应对象被业务列表意外修改。 + * + * @param source 原学生身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) { + if (source == null) { + return null; + } + SysUserStudentDto target = new SysUserStudentDto(); + target.setStudentNo(source.getStudentNo()); + target.setSchoolName(source.getSchoolName()); + target.setClassName(source.getClassName()); + target.setGuardianPhones(source.getGuardianPhones()); + target.setExtJson(source.getExtJson()); + return target; + } + + /** + * 复制老师身份资料,保持查询与展示对象相互独立。 + * + * @param source 原老师身份资料 + * @return 独立副本;源对象为空时返回 {@code null} + */ + private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) { + if (source == null) { + return null; + } + SysUserTeacherDto target = new SysUserTeacherDto(); + target.setTeacherNo(source.getTeacherNo()); + return target; + } + + /** + * 复制可变列表并保持空值语义。 + * + * @param source 原列表 + * @param 列表元素类型 + * @return 独立列表;源列表为空值时返回 {@code null} + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } +} diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml index 352748ec..659f0407 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/WalletManageMapper.xml @@ -137,6 +137,12 @@ AND sub.user_id = #{dto.userId} + + AND sub.user_id IN + + #{v} + + @@ -157,6 +163,12 @@ AND a.user_id = #{dto.userId} + + AND a.user_id IN + + #{v} + + @@ -190,6 +202,12 @@ AND a.user_id = #{dto.userId} + + AND a.user_id IN + + #{v} + + @@ -318,4 +336,4 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml index 3fcbcbfd..fd8b66af 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchManageMapper.xml @@ -4,7 +4,7 @@ @@ -14,6 +14,9 @@ + @@ -43,6 +46,7 @@ a.mch_count, a.function_scope, a.table_reservation_conf, + a.top_up_config, a.wallet_alias_list, a.create_by, a.create_time, diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchPaySettingMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchPaySettingMapper.xml index 90d90814..413a5b52 100755 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchPaySettingMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/mch/MchPaySettingMapper.xml @@ -11,7 +11,6 @@ a.enable, a.use_scope, a.setting_json, - a.fz_ratio, a.create_by, a.create_time, a.update_by, @@ -50,4 +49,4 @@ - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml index 086f0d75..1cf8e584 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/recharge/WalletRechargeImportMapper.xml @@ -110,7 +110,7 @@ and p.process_time between #{dto.processTimeList[0]} and #{dto.processTimeList[1]} - order by a.create_time desc + order by a.create_time desc, a.id desc - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml index 3ca473bd..bfa30201 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/resources/mapper/rule/WalletRuleTransactionMapper.xml @@ -83,6 +83,12 @@ and t.user_id = #{dto.userId} + + and t.user_id in + + #{v} + + @@ -103,4 +109,4 @@ group by a.id order by a.create_time desc,a.id desc - \ No newline at end of file + diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java index 11e14279..ee0513a7 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/mch/MchPaySettingServiceImplTest.java @@ -2,6 +2,7 @@ package com.bsd.pjcloud.wallet.service.impl.mch; import cn.hutool.json.JSONUtil; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService; import com.bsd.pjcloud.wallet.api.dto.mch.MchPaySettingDto; import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity; @@ -22,7 +23,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 商户支付配置支付宝授权令牌脱敏和更新保留测试。 + * 商户支付配置关系字段清理测试。 */ @ExtendWith(MockitoExtension.class) class MchPaySettingServiceImplTest { @@ -31,7 +32,7 @@ class MchPaySettingServiceImplTest { @Mock private MchPaySettingMapper mchPaySettingMapper; - /** 商户服务 Mock,仅补齐异步刷新依赖。 */ + /** 商户服务 Mock,用于同步刷新默认平台分佣关系。 */ @Mock private MchManageService mchManageService; @@ -39,7 +40,7 @@ class MchPaySettingServiceImplTest { @Mock private RemoteUserService remoteUserService; - /** 第三方支付远程服务 Mock,仅补齐异步刷新依赖。 */ + /** 第三方支付远程服务 Mock,用于同步确认关系事实已接收。 */ @Mock private RemoterThirdAppPlusPayService thirdAppPlusPayService; @@ -56,31 +57,28 @@ class MchPaySettingServiceImplTest { } /** - * 验证管理端详情保留 smid 和账号键,但不返回完整授权令牌。 + * 验证管理端详情不再返回已经迁入关系表的任何渠道字段。 */ @Test - void getSafeByIdShouldRemoveAliPayAuthToken() { + void getSafeByIdShouldRemoveRelationOwnedFields() { MchPaySettingEntity stored = storedAliPaySetting(); when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); MchPaySettingEntity result = service.getSafeById(stored.getId()); - MchPaySettingEntity.AliPaySetting safe = JSONUtil.toBean( - result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertEquals("smid-1001", safe.getSubMchid()); - Assertions.assertEquals("secondary", safe.getAccountKey()); - Assertions.assertNull(safe.getAppAuthToken()); + Assertions.assertNull(result.getSettingJson()); Assertions.assertTrue(stored.getSettingJson().contains("sensitive-auth-token")); } /** - * 验证前端提交空 token 时保存旧值,但接口返回值仍保持脱敏。 + * 验证旧管理端继续提交关系字段时,钱包持久化边界仍会全部剔除。 */ @Test - void editShouldPreserveStoredAliPayAuthTokenWithoutReturningIt() { + void editShouldDiscardLegacyRelationOwnedFields() { MchPaySettingEntity stored = storedAliPaySetting(); when(mchPaySettingMapper.selectCount(any())).thenReturn(1L); - when(mchPaySettingMapper.selectById(stored.getId())).thenReturn(stored); + when(mchManageService.join(stored.getMchId())).thenReturn(new com.bsd.pjcloud.wallet.api.vo.MchManageVo()); + when(thirdAppPlusPayService.remoterChangeMch(any())).thenReturn(R.ok(true)); MchPaySettingEntity.AliPaySetting incomingSetting = new MchPaySettingEntity.AliPaySetting(); incomingSetting.setSubMchid("smid-1001"); incomingSetting.setAccountKey("secondary"); @@ -95,12 +93,8 @@ class MchPaySettingServiceImplTest { ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(MchPaySettingEntity.class); verify(mchPaySettingMapper).updateById(updateCaptor.capture()); - MchPaySettingEntity.AliPaySetting persisted = JSONUtil.toBean( - updateCaptor.getValue().getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertEquals("sensitive-auth-token", persisted.getAppAuthToken()); - MchPaySettingEntity.AliPaySetting safeResult = JSONUtil.toBean( - result.getSettingJson(), MchPaySettingEntity.AliPaySetting.class); - Assertions.assertNull(safeResult.getAppAuthToken()); + Assertions.assertNull(updateCaptor.getValue().getSettingJson()); + Assertions.assertNull(result.getSettingJson()); } /** diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java index f455fafe..58f8c890 100644 --- a/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java +++ b/pjcloud-wallet/pjcloud-wallet-biz/src/test/java/com/bsd/pjcloud/wallet/service/impl/recharge/WalletRechargeServiceImplTest.java @@ -4,10 +4,12 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService; import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; +import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum; import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum; import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto; import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity; +import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity; import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity; import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity; import com.bsd.pjcloud.wallet.mapper.WalletManageMapper; @@ -24,10 +26,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InOrder; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -211,6 +216,83 @@ class WalletRechargeServiceImplTest { verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), any(WalletBizEnum.class)); } + /** + * 已完成退款首次冲正时必须强制扣回原金额,并把退款记录切换为未退款状态作为重试幂等依据。 + */ + @Test + void completedRefundRollbackShouldDeductBalanceAndMarkNotRefunded() { + WalletRechargeEntity existing = existingRefund(20L, 100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true)).thenReturn(new WalletTransactionEntity()); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService).reduceWalletBalance(20L, 100, "refund_rollback:50", + WalletBizEnum.REFUND, true); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.NONE.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 已冲正退款的重复请求应直接成功,不能再次形成负向钱包流水。 + */ + @Test + void duplicateRefundRollbackShouldNotDeductAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + + assertEquals(Boolean.TRUE, service.rollbackRefund(50L)); + + verify(walletTransactionService, never()).reduceWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class), any(Boolean.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + + /** + * 冲正后再次同意退款时应重新入账一次并恢复成功状态,后续重复同意仍由成功状态拦截。 + */ + @Test + void refundedAgainAfterRollbackShouldCreditBalanceAndRestoreSuccessState() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + WalletTransactionEntity rollbackTransaction = new WalletTransactionEntity(); + rollbackTransaction.setPrice(-100); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(rollbackTransaction); + when(rechargeMapper.updateById(any(WalletRechargeEntity.class))).thenReturn(1); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService).addWalletBalance(20L, 100, "50", WalletBizEnum.REFUND); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(WalletRechargeEntity.class); + verify(rechargeMapper).updateById(updateCaptor.capture()); + assertEquals(HardRefundStatusEnum.SUCCESS.getStatus(), updateCaptor.getValue().getRefundStatus()); + } + + /** + * 历史退款记录即使状态仍为默认值,只要没有负向冲正流水就必须按已到账处理,不能重复增加余额。 + */ + @Test + void historicalDefaultRefundStatusWithoutRollbackTransactionShouldNotCreditAgain() { + WalletRechargeEntity existing = existingRefund(20L, 100); + existing.setRefundStatus(HardRefundStatusEnum.NONE.getStatus()); + when(rechargeMapper.selectOne(any())).thenReturn(existing); + when(walletTransactionService.getOne(any())).thenReturn(null); + + service.refund(refundRequest(100, null)); + + verify(walletTransactionService, never()).addWalletBalance(anyLong(), anyInt(), anyString(), + any(WalletBizEnum.class)); + verify(rechargeMapper, never()).updateById(any(WalletRechargeEntity.class)); + } + /** * 构造固定售后单的退款请求。 * @@ -249,6 +331,8 @@ class WalletRechargeServiceImplTest { entity.setWalletId(walletId); entity.setPayRefundOrderId(50L); entity.setRefundTotalPrice(price); + entity.setRefundStatus(HardRefundStatusEnum.SUCCESS.getStatus()); + entity.setRefundTime(LocalDateTime.of(2026, 8, 27, 10, 0)); return entity; } }