Merge branch 'master' into gjh

This commit is contained in:
zjf committed 2026-08-31 11:54:14 +08:00
commit 2a167fdf84
267 files changed
+19337 -3235

No files matched your search

-37
View File
@@ -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 = '支付宝与微信聚合订单码支付会话';
@@ -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;
-153
View File
@@ -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。
-79
View File
@@ -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;
+362
View File
@@ -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;
@@ -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`;
@@ -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`;
-3
View File
@@ -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 '修改人',
@@ -6,7 +6,10 @@ import lombok.Data;
import java.math.BigDecimal;
/**
* 每日菜谱规范导入模板行
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、菜品SPU ID、填写说明
* <p>配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU
* <p>菜谱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;
}
@@ -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<Long> ids;
@NotBlank
@Schema(description = "封面图地址(上传或图库)")
private String picUrl;
}
@@ -109,4 +109,9 @@ public class CanteenDailyMenuEntity extends Model<CanteenDailyMenuEntity> {
@TableField(exist = false)
private List<Long> spuIds;
/** 多餐段排菜:优先于 hourType;为空则回退单餐段 hourType */
@Schema(description = "归属时间列表 1早餐 2午餐 3晚餐")
@TableField(exist = false)
private List<Integer> hourTypes;
}
@@ -210,6 +210,13 @@ public class CanteenSpuEntity extends Model<CanteenSpuEntity> {
@Schema(description="是否显示")
private String isShow;
/**
* 列表筛选:餐段(按分类 categoryType 过滤,非表字段)
*/
@TableField(exist = false)
@Schema(description = "餐段筛选 1早餐 2午餐 3晚餐")
private Integer hourType;
/**
* 套餐组成(仅 spuType=2 时使用)
*/
@@ -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<OnlineSpuComboItemVO> comboItems;
}
@@ -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<CanteenDailyMenuImportExcel> 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<CanteenDailyMenuImportExcel> 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<List<com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO>> 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------------
/**
@@ -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.<CanteenSpuEntity>lambdaUpdate()
.set(CanteenSpuEntity::getPicUrl, picUrl)
.in(CanteenSpuEntity::getId, dto.getIds()));
return ok ? R.ok(null, "已更新 " + dto.getIds().size() + " 道菜封面") : R.failed("更新失败");
}
/**
* 修改菜品spu
*
@@ -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;
/**
* 每日菜谱导入模板:套餐多行合并日期/餐段/套餐名/套餐价/说明;菜品单价不合并。
* <p>列:0日期 1餐段 2套餐名 3菜品名 4配料 5菜品单价 6套餐价 7填写说明
* <p>数据行(表头第 0 行):1-3 A套餐,4-5 B套餐,6 午餐单品,7-8 早餐单品
* 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、说明。
* <p>列: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);
}
}
@@ -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<CanteenDailyMe
* @return 可展示的每日菜谱详情;记录不存在、已删除或菜品已隐藏时返回 null
*/
DailyMenuAppVO selectAppDetail(@Param("id") Long id);
/**
* PC 管理端:指定日期商户的每日菜谱明细(含排序、库存、上下架/展示状态)
*/
List<DailyMenuAdminVO> selectAdminList(@Param("time") String time, @Param("merchantId") Long merchantId,
@Param("categoryId") Long categoryId, DataScope dataScope);
}
@@ -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<CanteenDailyMenuEntity
* @return
*/
R cv(CanteenDailyMenuCVDTO creenDailyMenuCVDTO);
/**
* PC 管理端每日菜谱列表(含库存与订单统计)
*/
List<DailyMenuAdminVO> selectAdminList(String time, Long merchantId, Long categoryId);
}
@@ -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<CanteenDailyMenuMapper, CanteenDailyMenuEntity> implements CanteenDailyMenuService {
private final CanteenSpuService canteenSpuService;
@@ -101,29 +104,50 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
@Transactional
public void saveToday(CanteenDailyMenuEntity canteenDailyMenu) {
List<Long> spuIds = Optional.ofNullable(canteenDailyMenu.getSpuIds()).orElse(Collections.emptyList());
if (spuIds.isEmpty()) return;
LambdaQueryWrapper<CanteenDailyMenuEntity> queryWrapper = new LambdaQueryWrapper<CanteenDailyMenuEntity>()
.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId())
.eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime())
.eq(CanteenDailyMenuEntity::getHourType, canteenDailyMenu.getHourType())
.select(CanteenDailyMenuEntity::getSpuId); // 仅查询必要字段
List<Long> existSpuIds = baseMapper.selectList(queryWrapper)
if (spuIds.isEmpty()) {
return;
}
List<Integer> 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<CanteenDailyMenuEntity> 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<CanteenDailyMenuEntity> insertList = new ArrayList<>();
for (Integer hourType : hourTypes) {
LambdaQueryWrapper<CanteenDailyMenuEntity> queryWrapper = new LambdaQueryWrapper<CanteenDailyMenuEntity>()
.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailyMenu.getMerchantId())
.eq(CanteenDailyMenuEntity::getStartTime, canteenDailyMenu.getStartTime())
.eq(CanteenDailyMenuEntity::getHourType, hourType)
.select(CanteenDailyMenuEntity::getSpuId);
List<Long> 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<CanteenDailyMenuMap
return detail;
}
@Override
public List<DailyMenuAdminVO> selectAdminList(String time, Long merchantId, Long categoryId) {
List<DailyMenuAdminVO> 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<DailyMenuAdminVO> list) {
if (CollectionUtils.isEmpty(list)) {
return;
}
List<Long> comboIds = list.stream()
.filter(v -> Objects.equals(v.getSpuType(), 2) && v.getSpuId() != null)
.map(DailyMenuAdminVO::getSpuId)
.distinct()
.collect(Collectors.toList());
Map<Long, List<com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO>> 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<DailyMenuVO> list) {
if (CollectionUtils.isEmpty(list)) {
return;
@@ -202,7 +202,10 @@ public class CanteenOnlineSpuServiceImpl extends ServiceImpl<CanteenOnlineSpuMap
continue;
}
if (!childIds.add(item.getChildSpuId())) {
throw new IllegalArgumentException("套餐组成菜品不能重复");
CanteenOnlineSpuEntity dup = baseMapper.selectById(item.getChildSpuId());
String dish = dup != null && StrUtil.isNotBlank(dup.getName()) ? dup.getName()
: String.valueOf(item.getChildSpuId());
throw new IllegalArgumentException("套餐组成菜品不能重复:「" + dish + "」");
}
if (selfId != null && Objects.equals(item.getChildSpuId(), selfId)) {
throw new IllegalArgumentException("套餐不能包含自身");
@@ -304,25 +304,25 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
wrapper.like(StrUtil.isNotBlank(canteenDailySpuDTO.getName()), CanteenSpuEntity::getName, canteenDailySpuDTO.getName());
wrapper.eq(Objects.nonNull(canteenDailySpuDTO.getCategoryId()), CanteenSpuEntity::getCategoryId, canteenDailySpuDTO.getCategoryId());
//查询今时今日已经选择的菜品
LambdaQueryWrapper<CanteenDailyMenuEntity> DyWrapper = Wrappers.lambdaQuery();
DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime());
DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType());
DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId());
List<CanteenDailyMenuEntity> list = canteenDailyMenuMapper.selectList(DyWrapper);
// 单餐段:排除当日该餐段已选菜品,并按分类餐段过滤;多餐段/未指定则不过滤(保存时按餐段去重)
if (canteenDailySpuDTO.getHourType() != null) {
LambdaQueryWrapper<CanteenDailyMenuEntity> DyWrapper = Wrappers.lambdaQuery();
DyWrapper.eq(CanteenDailyMenuEntity::getStartTime, canteenDailySpuDTO.getStartTime());
DyWrapper.eq(CanteenDailyMenuEntity::getHourType, canteenDailySpuDTO.getHourType());
DyWrapper.eq(CanteenDailyMenuEntity::getMerchantId, canteenDailySpuDTO.getMerchantId());
List<CanteenDailyMenuEntity> list = canteenDailyMenuMapper.selectList(DyWrapper);
List<Long> spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList());
wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds);
List<Long> spuIds = list.stream().map(CanteenDailyMenuEntity::getSpuId).distinct().collect(Collectors.toList());
wrapper.notIn(!CollectionUtils.isEmpty(list), CanteenSpuEntity::getId, spuIds);
//再次分类餐段过滤
LambdaQueryWrapper<CanteenCategoryEntity> canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType());
List<CanteenCategoryEntity> canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper);
List<Long> caIds = canteenCategoryEntities.stream().map(CanteenCategoryEntity::getId).collect(Collectors.toList());
LambdaQueryWrapper<CanteenCategoryEntity> canteenCategoryEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
canteenCategoryEntityLambdaQueryWrapper.like(CanteenCategoryEntity::getCategoryType, canteenDailySpuDTO.getHourType());
List<CanteenCategoryEntity> canteenCategoryEntities = canteenCategoryMapper.selectList(canteenCategoryEntityLambdaQueryWrapper);
List<Long> 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<CanteenSpuMapper, Canteen
LambdaQueryWrapper<CanteenSpuEntity> 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<CanteenSpuMapper, Canteen
return pageData;
}
/** 按分类餐段类型过滤菜品(category_type like 餐段编码) */
private void applyHourTypeCategoryFilter(LambdaQueryWrapper<CanteenSpuEntity> wrapper, Integer hourType) {
if (hourType == null) {
return;
}
List<Long> caIds = canteenCategoryMapper.selectList(Wrappers.<CanteenCategoryEntity>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 ServiceImpl<CanteenSpuMapper, Canteen
if (entity.getPicUrl() == null) {
entity.setPicUrl("");
}
merchantScopeHelper.fillDeptIdByMerchantId(entity.getMerchantId(), entity::setDeptId);
// 调用方已带 deptId 时不再 Feign 查商户(导入场景会反复调用,极慢)
if (entity.getDeptId() == null) {
merchantScopeHelper.fillDeptIdByMerchantId(entity.getMerchantId(), entity::setDeptId);
}
baseMapper.insert(entity);
saveComboItems(entity.getId(), entity.getSpuType(), entity.getComboItems());
return Boolean.TRUE;
@@ -478,7 +499,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
public Boolean updateDeep(CanteenSpuEntity entity) {
normalizeSpuType(entity);
validateCombo(entity, entity.getId());
merchantScopeHelper.fillDeptIdByMerchantId(entity.getMerchantId(), entity::setDeptId);
if (entity.getDeptId() == null) {
merchantScopeHelper.fillDeptIdByMerchantId(entity.getMerchantId(), entity::setDeptId);
}
baseMapper.updateById(entity);
comboItemMapper.delete(Wrappers.<CanteenSpuComboItemEntity>lambdaQuery()
.eq(CanteenSpuComboItemEntity::getComboSpuId, entity.getId()));
@@ -531,7 +554,10 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
if (!childIds.add(item.getChildSpuId())) {
throw new IllegalArgumentException("套餐组成菜品不能重复");
CanteenSpuEntity dup = baseMapper.selectById(item.getChildSpuId());
String dish = dup != null && StrUtil.isNotBlank(dup.getName()) ? dup.getName()
: String.valueOf(item.getChildSpuId());
throw new IllegalArgumentException("套餐组成菜品不能重复:「" + dish + "」");
}
if (selfId != null && Objects.equals(item.getChildSpuId(), selfId)) {
throw new IllegalArgumentException("套餐不能包含自身");
@@ -173,4 +173,38 @@
AND s.is_show = 0
LIMIT 1
</select>
<select id="selectAdminList" resultType="com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO">
SELECT t.id,
t.start_time,
t.merchant_id,
sp.id AS spu_id,
sp.name,
sp.introduction,
sp.price,
sp.market_price,
sp.pic_url,
sp.stock,
sp.sort,
sp.description AS ingredients,
c.id AS category_id,
c.name AS category_name,
IFNULL(sp.spu_type, 1) AS spu_type,
t.hour_type,
t.dept_id,
t.create_by
FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp ON t.spu_id = sp.id
LEFT JOIN canteen_category c ON sp.category_id = c.id AND c.del_flag = 0
<where>
t.del_flag = 0
AND t.merchant_id = #{merchantId}
AND t.start_time = #{time}
AND (sp.id IS NULL OR sp.del_flag = 0)
<if test="categoryId != null">
AND sp.category_id = #{categoryId}
</if>
</where>
ORDER BY t.hour_type ASC, sp.sort ASC, t.id ASC
</select>
</mapper>
@@ -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<Long> 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());
}
@@ -18,6 +18,9 @@ public enum HardAfterSaleOperateTypeEnum {
/** 商户审核拒绝退款申请。 */
ADMIN_DISAGREE_APPLY("12", "商家拒绝退款"),
/** 后台将人工审核完成的退款冲正并退回审核中。 */
ADMIN_RETURN_TO_REVIEW("13", "商家将退款退回审核中"),
/** 会员补充退货物流信息。 */
MEMBER_DELIVERY("20", "会员填写退货物流信息,快递公司:{deliveryName},快递单号:{logisticsNo}"),
@@ -26,7 +26,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
EXPIRED_MANUAL("40", "后台逐单过期退款", false),
/**
* 管理端按商户当前配置立即重扫历史订单后触发的自动退款,失败后继续进入既有自动重试状态机。
*/
EXPIRED_RESCAN("50", "后台立即重新扫描", true);
/**
* 数据库存储的稳定触发编码。
@@ -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<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -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<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -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<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -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<Long> deptIds;
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> merchantIds;
private String hardType;
@@ -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<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
/** 售后申请日期闭区间;为空时不限制申请日期。 */
private LocalDate[] createTimeList;
/** 当前账号数据范围内允许查询的订单商户主键集合;为空时不限制商户。 */
private List<Long> mchIds;
/** 关联订单的状态字典编码集合;为空时不限制订单状态。 */
private List<String> orderStatusCodes;
/** 关联订单的类型字典编码;为空时不限制订单类型。 */
private String orderType;
/** 关联订单的用餐餐段字典编码集合;为空时不限制餐段。 */
private List<String> hourTypeCodes;
/** 关联订单预计使用日期闭区间,页面按开始日零点至结束日最后一秒传入。 */
private LocalDateTime[] apptUseDateTimeList;
}
@@ -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;
}
@@ -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;
}
@@ -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;
/** 用户姓名、登录账号或手机号的模糊查询关键字;为空时不限制。 */
@@ -52,6 +52,9 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
private List<Long> userDeptIds;
/** 用户主键筛选集合;为空表示不限制用户范围,供菜品统计及明细查询复用。 */
private List<Long> userIds;
private Boolean isInclude;
}
@@ -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<String> mchTypeCodes;
/** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */
private List<String> merchantTypeCodes;
private List<String> orderStatusCodes;
private LocalDateTime[] createTimeList;
@@ -78,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private List<String> hardTypeCodes;
/** 用餐时间段字典编码集合;为空表示不限制餐段。 */
private List<String> hourTypeCodes;
private List<String> refundStatusCodes;
/**
@@ -131,4 +141,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
Boolean apptIdOrApptRuleIdNotNull = false;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -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<Long> userIds;
/** 目标柜门主键;为空时不限制柜门。 */
private Long cabinetId;
/** UPMS 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
}
@@ -163,11 +163,11 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@Schema(description = "过期预约退款配置ID快照")
private Long expiredRefundConfigId;
/** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
@Schema(description = "过期预约餐段实际结束时间快照")
/** 预约下单校验通过时固化的用餐日核销有效期结束时间,固定为 apptUseDate 当天 23:59:59;不受退款开关影响。 */
@Schema(description = "过期预约用餐日核销结束时间快照")
private LocalDateTime expiredRefundUsageEndTime;
/** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
/** 用餐日核销有效期结束时间加配置延迟后的过期退款触发时间。 */
@Schema(description = "过期预约退款触发时间")
private LocalDateTime expiredRefundDeadline;
@@ -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<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -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<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 订单相关
*/
@@ -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<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
private HardAuthEntity hardAuth;
@@ -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<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -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;
}
@@ -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;
/**
* 硬件订单售后详情。
*
* <p>同时保留历史用户、用户部门和用户身份协议,兼容售后详情的旧用户装配逻辑及管理端身份展示。</p>
*/
@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<String> 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<HardAfterSaleCarEntity> carList;
}
@@ -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<OrderItem> 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<ProductItem> 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;
}
}
@@ -11,6 +11,11 @@ import lombok.Data;
@Data
public class HardCanteenProductApptExportRecord {
/**
* 导出聚合层级,固定为 SUMMARY、MERCHANT 或 PRODUCT;仅用于一次查询后拆分三级结果,不持久化。
*/
private String recordLevel;
/** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
private String statisticsDate;
@@ -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 和下单菜品名称快照聚合的统计数据。
*
* <p>商品 ID 相同但名称快照不同的历史订单会拆分展示,避免商品 ID 被菜谱复用后串入错误名称。</p>
*/
@Data
@Schema(description = "食堂用户预约菜品统计")
@@ -18,6 +23,14 @@ public class HardCanteenUserApptDishStatisticsVo {
@Schema(description = "菜品名称")
private String productName;
/** 当前名称快照统计项实际涉及的商品 ID;正常数据仅有一个,历史商品 ID 为空时不写入集合。 */
@Schema(description = "商品ID集合")
private List<Long> 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<HardCanteenUserApptDishUserStatisticsVo> users = new ArrayList<>();
}
@@ -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;
}
@@ -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;
@@ -47,7 +47,7 @@ public class HardCanteenUserApptStatisticsSummaryVo {
@Schema(description = "餐段统计")
private List<HardCanteenUserApptMealStatisticsVo> meals = new ArrayList<>();
/** 按餐段和菜品名称聚合的菜品份数。 */
/** 按餐段、商品 ID 和下单名称快照聚合的菜品份数;历史商品 ID 缺失时按菜品名称兜底。 */
@Schema(description = "菜品统计")
private List<HardCanteenUserApptDishStatisticsVo> dishes = new ArrayList<>();
}
@@ -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 表示不存在。 */
@@ -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;
}
@@ -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<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -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;
@@ -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;
/**
* 硬件订单明细导出行,按订单业务域组织二级表头。
*
* <p>仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段;
* 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。</p>
*/
@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;
}
@@ -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<String> 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;
}
@@ -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<List<HardAfterSaleLogEntity>> appHistory(@RequestParam Long id) {
public R<List<HardAfterSaleLogVo>> 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<HardBatchRefundPreviewVo> 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<AsyncTaskProgressVO> submitBatchRefund(@Valid @RequestBody HardBatchRefundSubmitDto dto) {
return R.ok(hardBatchRefundService.submit(dto, SecurityUtils.getUser()), "批量退款任务已提交");
}
@GetMapping(value = "/history")
@Operation(summary = "查询售后进度")
public R<List<HardAfterSaleLogEntity>> 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<Boolean> returnToReview(@RequestParam Long id) {
return R.ok(hardAfterSaleService.returnToReview(SecurityUtils.getUser().getId(), id));
}
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
@@ -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<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page,
@ParameterObject HardAutoRefundTaskPageDto dto) {
List<Long> scopedMchIds = listScopedMerchantIds();
if (scopedMchIds.isEmpty()) {
Page<HardAutoRefundTaskVo> empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
empty.setRecords(List.of());
return R.ok(empty);
}
dto.setMchIds(scopedMchIds);
public R<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> 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<HardExpiredRefundRescanResultVo> 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<List<MchManageVo>> 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;远程异常拒绝请求,不能降级为全量任务。
*
@@ -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 按食堂聚合并包含部门、用餐时间段明细的预约统计结果
* <p>食堂主键不信任客户端输入,而是根据当前商户管理员重新计算;服务层只统计有效预约订单,
* 并按已完成退款扣减后的净菜品份数聚合。</p>
*
* @param query 预约日期、餐段和核销状态筛选条件
* @return 当前账号可管理食堂内与 PC 核销状态口径一致的菜品和点餐人员汇总
*/
@Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门")
@Operation(summary = "App预约菜品人员统计", description = "按当前账号可管理食堂统计预约菜品和点餐人员净份数")
@ApiCryptoAes
@PostMapping("/list-by-dept")
public R<List<HardCanteenProductTotalVo>> 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<HardCanteenUserApptStatisticsSummaryVo> 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<Page<HardCanteenProductTotalVo>> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
// 查询商户
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> 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<HardCanteenUserApptStatisticsVo> appUserReservationDailyStatistics(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
return R.ok(canteenApptTotalService.appUserReservationDailyStatistics(query, resolveAppCanteenMerchantIds()));
}
/**
* 解析当前 App 商户管理员可管理的食堂主键,客户端提交的食堂范围不参与授权判定。
*
* @return 当前账号可管理的食堂主键;没有可管理食堂时返回空集合
*/
private List<Long> resolveAppCanteenMerchantIds() {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getId() == null) {
throw new IllegalStateException("未获取到当前登录用户");
}
R<List<Long>> 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<Long> 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<AsyncTaskProgressVO> 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<HardCanteenProductApptExcelVo> productReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return canteenApptTotalExportService.productReservationExport(query);
}
/**
* 为后台菜品预约导出补充当前操作者可见的食堂范围。
*
* <p>分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
* 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。</p>
*
* @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
*/
private void applyCanteenMerchantScope(HardOrderDto query) {
if (query == null) {
return;
}
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
R<List<MchManageVo>> scopeResponse = remoteMchService.scopeList(mchManageDto);
List<MchManageVo> mchList = scopeResponse == null ? null : scopeResponse.getData();
List<Long> 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<Long> allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
@@ -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<HardCanteenDeptTotalExcelVo> exportDept(@RequestBody HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
return canteenTotalService.exportDept(dto, user, null);
public List<HardCanteenProductApptExcelVo> 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<AsyncTaskProgressVO> submitDepartmentSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitDepartmentSalesExport(dto), "食堂菜品部门统计导出任务已提交");
}
}
@@ -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 食堂综合销售统计。
*
* <p>线下购买按支付时间统计,预约订单按核销时间统计;返回结构与 PC 菜品销售统计一致,
* 并且只允许查询当前商户管理员可见的食堂。</p>
*
* @param page 按自然日分页的分页参数
* @param dto 统计日期、来源和餐段筛选条件
* @return 日期、餐段和来源多维销售统计分页
*/
@ApiCryptoAes
@Operation(summary = "App食堂综合销售统计", description = "线下按支付时间、预约按核销时间查询日期餐段来源统计")
@PostMapping("/app/proudct-total")
public R<Page<HardCanteenProductTotalVo>> appProductTotal(@ParameterObject Page page,
@RequestBody HardOrderDto dto) {
if (!applyAppCanteenMerchantScope(dto)) {
return R.ok(new Page<HardCanteenProductTotalVo>());
}
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<List<HardCanteenProductTotalVo>> 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<Long> allowedMchIds = remoteMchService
.getSelfMchIdList(user.getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(allowedMchIds)) {
dto.setMchIds(List.of());
return false;
}
List<Long> requestedMchIds = dto.getMchIds();
List<Long> 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<HardCanteenProductTotalExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> 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<HardCanteenProductApptExcelVo> 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<AsyncTaskProgressVO> submitProductSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitProductSalesExport(dto), "食堂菜品销售统计导出任务已提交");
}
/**
@@ -136,6 +136,13 @@ public class HardCarController {
}
/* ************************************* end app end ************************************* */
/**
* 分页查询订单原购物车商品,并补充已完成退款、申请中退款和当前剩余份数。
*
* @param page 分页参数
* @param orderId 原订单主键
* @return 原购物车商品分页;订单不存在或未关联购物车时返回空数据
*/
@GetMapping("/order-id")
@Operation(summary = "根据订单id查询购物车")
public R<Page<HardCarVo>> 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<HardCarVo> carPage = hardCarService.pageJoin(page, dto);
hardAfterSaleCarService.fillRefundQuantityStats(order.getParentCarId(), carPage.getRecords());
return R.ok(carPage);
}
@GetMapping("/refund-car")
@@ -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<MchManageVo> mchList = mchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isEmpty(mchList)) {
List<Long> mchIdList = mchService.getSelfMchIdList(SecurityUtils.getUser().getId(), List.of(MerchantTypeEnum.CANTEEN)).getData();
if (ObjectUtil.isEmpty(mchIdList)) {
return R.ok(new Page<HardOrderVo>());
}
List<Long> 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<HardOrderVo>());
}
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<AsyncTaskProgressVO> 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<Long> mchDtoIds = dto.getMchIds();
if (ObjectUtil.isNotEmpty(mchDtoIds) && mchDtoIds.size() == 1) {
Long mchId = mchDtoIds.get(0);
List<MchManageVo> mchList = mchService.getByParentId(mchId).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
List<Long> mchIds = mchList.stream().map(MchManageVo::getId).toList();
mchDtoIds.addAll(mchIds);
dto.setMchIds(mchDtoIds);
}
}
List<HardOrderVo> list = hardwareOrderService.scopeList(dto, DataScope.of());
List<HardOrderExcelVo> 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<String, Object> map = new HashMap<String, Object>();
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);
}
}
@@ -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<List<HardDeptTotalVo>> pageListDept(@RequestBody HardDeptTotalDto dto) {
if (!applyCanteenDepartmentScope(dto)) {
return R.ok(List.of());
}
return R.ok(hardOrderTotalService.pageListDept(dto));
}
/**
* 将部门统计条件限制到当前用户可管理的食堂商户所属部门。
*
* <p>钱包服务根据当前用户主键和食堂商户类型一次性返回其所属食堂覆盖的用户部门。页面传入的
* 包含或排除条件只在该权限部门集合内生效,最终统一改写为明确的包含集合,避免空集合或排除
* 条件导致底层统计退化为全量查询。</p>
*
* @param dto 待收敛的部门统计条件,方法会写入最终允许查询的部门 ID 集合
* @return 是否仍存在可查询部门;false 表示调用方必须直接返回空数据
*/
private boolean applyCanteenDepartmentScope(HardDeptTotalDto dto) {
PjcloudUser user = SecurityUtils.getUser();
if (user == null || user.getId() == null) {
return false;
}
R<List<Long>> departmentResponse = mchService.getSelfMchUserDeptIdListByUserId(
user.getId(), List.of(MerchantTypeEnum.CANTEEN));
if (departmentResponse == null || !departmentResponse.isOk() || ObjectUtil.isEmpty(departmentResponse.getData())) {
return false;
}
List<Long> allowedDeptIds = departmentResponse.getData().stream()
.filter(id -> id != null && id > 0)
.distinct()
.toList();
if (ObjectUtil.isEmpty(allowedDeptIds)) {
return false;
}
List<Long> requestedDeptIds = ObjectUtil.isNotEmpty(dto.getDeptIds())
? dto.getDeptIds()
: ObjectUtil.isNotEmpty(dto.getDeptId()) ? List.of(dto.getDeptId()) : List.of();
List<Long> scopedDeptIds = allowedDeptIds;
if (ObjectUtil.isNotEmpty(requestedDeptIds)) {
Set<Long> 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<HardDeptTotalVo> list = hardOrderTotalService.pageListDept(dto);
List<HardDeptTotalVo> 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();
@@ -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
}
/**
* 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
* 为当前统计日期设置整组外框:当天首个来源块首行加顶部边框,当天所有行加左右边框,末行加底部边框。
*
* <p>流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
* 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。</p>
*
* @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) {
@@ -20,6 +20,7 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper<HardDoorFlowEntity
MPJLambdaQueryWrapper<HardDoorFlowEntity> wrapper = new MPJLambdaQueryWrapper<HardDoorFlowEntity>()
.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<HardDoorFlowEntity
.orderByDesc(HardDoorFlowEntity::getId);
return selectJoinPage(page, HardDoorFlowVo.class, wrapper);
}
}
}
@@ -25,4 +25,4 @@ public interface HardInvoiceMapper extends PjcloudBaseMapper<HardInvoiceEntity>
Page<HardInvoiceVo> scopePageList(Page page, @Param("dto") HardInvoiceDto dto, @Param("dataScope") DataScope dataScope);
}
}
@@ -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<HardAutoRefu
Page<HardAutoRefundTaskEntity> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
/**
* 查询指定商户仍处于已支付未核销状态且没有冲突售后的每日菜谱钱包订单,供立即重扫补建任务。
*
* @param mchId 精确商户 ID
* @return 按订单主键升序排列的可重扫订单
*/
List<HardOrderEntity> listExpiredRescanOrders(@Param("mchId") Long mchId);
}
@@ -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<HardCarEntity> {
/**
* 查询菜品多维统计行且不应用数据库分页。
*
* <p>同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
* 服务层按完整自然日分页,导出仅选择餐段来源层。</p>
* <p>同一次查询通过统一来源 CTE 返回当日合计、来源合计、餐段合计和餐段来源四层数据,
* 保证交易人数在每个层级独立去重;服务层按完整自然日分页。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
@@ -67,31 +68,26 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
*/
List<HardCanteenProductTotalVo> productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询预约核销商品事实,复用菜品销售统计的核销时间和完成退款净额口径。
*
* @param dto 核销日期、食堂、餐段和用户范围;调用方必须把来源限制为预约核销
* @return 按核销时间、订单和商品稳定排序的有效商品事实,全额退款订单不返回记录
*/
List<HardCanteenReservationSaleFact> listReservationVerifiedSaleFacts(@Param("dto") HardOrderDto dto);
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询菜品销售三级导出的日期餐段汇总及来源拆分。
* 一次查询菜品销售三级导出的汇总、商户部门和商品聚合行。
*
* <p>SQL 只构造一次来源 CTE,再按记录层级分别聚合,避免三个查询重复扫描订单、购物车和退款数据。</p>
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 按层级、日期、餐段、来源、商户部门和商品稳定排序的三级聚合行
*/
List<HardCanteenProductApptExportRecord> listSalesExportSummary(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商户部门汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段下商户部门的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportMerchant(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商品汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个商户部门块下商品的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportProduct(@Param("dto") HardOrderDto dto);
List<HardCanteenProductApptExportRecord> listSalesExportRows(@Param("dto") HardOrderDto dto, DataScope dataScope);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
@@ -100,7 +96,7 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
*
* @param page 分页参数
* @param dto 单日、来源、食堂、餐段、部门和商品名称筛选
* @return 按商品聚合的来源明细页
* @return 按商品名称、商品 ID、统计日期和来源升序排列的商品聚合明细页
*/
Page<HardCarVo> proudctQueryStats(Page page, @Param("dto") HardCarDto dto);
@@ -146,6 +142,17 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
.orderByAsc(HardCarEntity::getId));
}
/**
* 按父购物车批量查询完成退款扣减后的预约商品快照。
*
* <p>部分退款仅保留剩余份数和金额,全额退款商品不返回;该结果用于待核销、部分核销及预约日统计,
* 避免人员页面继续累计原始购物车快照。</p>
*
* @param parentCarIds 预约订单关联的父购物车主键集合
* @return 退款后仍有有效数量的商品快照;传入空集合时调用方应直接返回空列表
*/
List<HardCarEntity> listEffectiveReservationCars(@Param("parentCarIds") List<Long> parentCarIds);
/**
* 菜品销量按照分类查询
*/
@@ -0,0 +1,42 @@
package com.bsd.pjcloud.hard.mapper.order.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 食堂预约核销商品事实。
*
* <p>数据直接来自菜品销售统计的预约核销分支,日期取订单核销时间,数量和金额均已扣除状态为 50 的
* 完成退款;仅用于 Hardware 服务内部统一人员统计与商品统计口径,不作为外部接口模型。</p>
*/
@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;
}
@@ -47,6 +47,15 @@ public interface HardAfterSaleCarService extends IService<HardAfterSaleCarEntity
*/
List<HardAfterSaleCarEntity> getOccupiedByCarParentId(Long carParentId, List<String> statuses);
/**
* 批量查询多个父购物车在指定售后状态下的商品占用明细,避免退款预览逐订单访问数据库。
*
* @param carParentIds 父购物车主键集合,空集合不发起数据库查询
* @param statuses 需要计入占用的售后状态编码集合
* @return 父购物车主键到商品聚合明细的映射;无占用的购物车不出现在映射中
*/
Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds, List<String> statuses);
/**
* 按售后状态为订单购物车展示对象补充原购、已退、申请中和剩余份数。
*
@@ -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<HardAfterSaleEntity> {
*/
List<HardAfterSaleLogEntity> getHistory(Long id);
/**
* 获取 App 售后进度,并仅在申请、拒绝或直接退款节点补充对应原因。
*
* @param id 售后单 ID
* @return App 可见的售后进度记录
*/
List<HardAfterSaleLogVo> getAppHistory(Long id);
/**
* 退款
*/
@@ -109,6 +118,15 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
Boolean disagreeRefund(Long userId, HardDisagreeDto dto);
/**
* 将人工审核完成的钱包退款冲正并退回申请中,同时恢复退款前的库存占用和订单聚合状态。
*
* @param userId 当前后台操作用户 ID
* @param id 售后单 ID
* @return 资金、库存和售后状态均已恢复时返回 {@code true}
*/
Boolean returnToReview(Long userId, Long id);
/**
* 校验订单是否可以退款
*/
@@ -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) {
@@ -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<HardAutoRefundTaskEn
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 按商户当前过期预约配置重扫历史已支付未核销订单,补建或刷新任务并立即执行已到期任务。
*
* @param mchId 需要重扫的商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于尝试日志审计
* @return 本轮扫描、任务同步和退款执行数量
*/
HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId);
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -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);
}
@@ -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<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
/**
* 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门、商品三级结构排列的导出行
*/
List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query);
}
@@ -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<Long> merchantIds);
/**
* 按 App 当前账号可管理的食堂范围统计每日预约及查询范围总计。
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端根据当前账号解析出的可管理食堂主键集合
* @return 与 PC 端核销状态口径一致的逐日预约统计
*/
HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds);
}
@@ -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<HardCanteenProductApptExcelVo> exportSales(HardOrderDto dto, DataScope dataScope);
/**
* 提交食堂菜品销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto);
/**
* 提交食堂部门销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto);
}
@@ -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;
/**
* 硬件订单导出服务。
*
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、自定义表头写入、进度上报和文件中心上传,
* 订单查询服务只保留订单业务查询职责。</p>
*/
public interface HardOrderExportService {
/**
* 提交硬件订单异步分批导出任务。
*
* @param dto 订单页面当前筛选条件;orderIds 非空时仅导出勾选订单
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitExport(HardOrderDto dto);
/**
* 兼容旧页面的同步导出入口,同样按页查询并通过临时文件流式生成工作簿。
*
* @param response 当前 HTTP 下载响应,方法会写入完整 XLSX 文件
* @param dto 订单页面当前筛选条件
*/
void export(HttpServletResponse response, HardOrderDto dto);
}
@@ -294,6 +294,19 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
void fastenerInventory(HardOrderEntity order, List<HardCarEntity> 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<HardCarEntity> refundCarList, YesNoEnum isReduce,
Long afterSaleId, String inventoryBizId);
/**
* 核销硬件二维码
*/
@@ -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<HardApptEntity> candidates = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.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,
@@ -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<HardAuthMapper, HardAuthEnt
private final RemoteUserService remoteUserService;
private final RemoteAppUserService remoteAppUserService;
/** 授权用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAuthVo> scopePageJoin(Page page, HardAuthDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardAuthVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -237,14 +242,18 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardAuthVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> 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<HardAuthMapper, HardAuthEnt
.append("使用本智能柜设备即视为您已阅读并同意以上所有条款。请谨慎使用,确保遵守使用规定。");
return strBuilder.toString();
}
}
}
@@ -29,6 +29,7 @@ import com.bsd.pjcloud.hard.api.vo.HardBroadcastVo;
import com.bsd.pjcloud.hard.mapper.HardBroadcastMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.constant.MqttActionEnum;
import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg;
@@ -55,10 +56,14 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
private final RemoteUserService remoteUserService;
/** 广播预定用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteMqttEventService remoteMqttEventService;
@Override
public Page<HardBroadcastVo> scopePageJoin(Page page, HardBroadcastDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardBroadcastVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -310,14 +315,18 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardBroadcastVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> 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);
}
}
@@ -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<HardDoorFlowMapper, Har
private final RemoteUserService remoteUserService;
private final RemoteWalletService remoteWalletService;
/** 开门流程用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
/**
* 复杂查询
*/
@Override
public Page<HardDoorFlowVo> scopePageJoin(Page page, HardDoorFlowDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardDoorFlowVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -427,14 +432,18 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户相关
List<Long> userIds = list.stream().map(HardDoorFlowVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> 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<HardDoorFlowMapper, Har
private HardDoorFlowServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -37,6 +37,7 @@ import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardInvoiceService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
@@ -90,8 +91,12 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private final RemoteFileService fileService;
private final RemoteMqttEventService mqttEventService;
/** 开票记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardInvoiceVo> scopeJoinPageList(Page page, HardInvoiceDto dto) {
prepareIdentityQuery(dto);
Page<HardInvoiceVo> list = baseMapper.scopePageList(page, dto, DataScope.of());
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 部门
@@ -102,14 +107,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
DataUtil.convertDeptList(list.getRecords(), deptList.getData());
}
}
// 用户
List<Long> userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> 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<Long> mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
@@ -122,6 +120,20 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return list;
}
/**
* 将开票用户身份条件预筛选为订单用户主键集合。
*
* @param dto 开票记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardInvoiceDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, null, dto.getUserIds()));
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
@Override
public String generateInvoice(HardOrderEntity order) {
return generateInvoice(order, null);
@@ -303,4 +315,4 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private HardInvoiceServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -72,6 +72,26 @@ public class HardAfterSaleCarServiceImpl extends ServiceImpl<HardAfterSaleCarMap
return baseMapper.getOccupiedByCarParentId(carParentId, statuses);
}
/**
* 一次查询全部父购物车的商品占用,再按父购物车主键分组供批量预览复用。
*
* @param carParentIds 父购物车主键集合
* @param statuses 需要计入占用的售后状态编码集合
* @return 父购物车主键到商品占用明细的有序映射
*/
@Override
public Map<Long, List<HardAfterSaleCarEntity>> getOccupiedByCarParentIds(List<Long> carParentIds,
List<String> statuses) {
if (ObjectUtil.isEmpty(carParentIds) || ObjectUtil.isEmpty(statuses)) {
return Map.of();
}
Map<Long, List<HardAfterSaleCarEntity>> 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;
}
/**
* 已完成退款才减少可履约数量,申请中的份数单独返回供页面提示。
*
@@ -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<HardAfterSaleMapper, H
*/
private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU";
/** 人工退款退回审核时重新占用食堂基础 SPU 库存的幂等业务号前缀。 */
private static final String RETURN_REVIEW_REOCCUPY_INVENTORY_BIZ_PREFIX = "HARD_AFTER_SALE_REVIEW_REOCCUPY:";
/** 退回审核后再次同意退款时恢复食堂基础 SPU 库存的幂等业务号前缀。 */
private static final String RETURN_REVIEW_RESTORE_INVENTORY_BIZ_PREFIX = "HARD_AFTER_SALE_REVIEW_RESTORE:";
private final HardAfterSaleLogMapper hardAfterSaleLogMapper;
private final HardOrderService hardOrderService;
@@ -115,19 +123,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
/** 退款订单级分布式锁模板,与数据库订单行锁共同保护微服务和单体部署。 */
private final LockTemplate lockTemplate;
/** 售后申请用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAfterSaleVo> pageJoin(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.pageJoin(page, dto);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> 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<Long> 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<HardAfterSaleMapper, H
@Override
public Page<HardAfterSaleVo> scopePageList(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.scopePageJoinList(page, dto, null);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> 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<Long> 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<HardAfterSaleMapper, H
return list;
}
/**
* 将售后申请用户身份条件预筛选为售后记录可直接使用的用户主键集合。
*
* @param dto 售后记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardAfterSaleDto 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
public Long create(HardAfterSaleDto dto) {
HardOrderVo order = validateOrderCanRefund(dto.getOrderId());
@@ -453,6 +467,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
return true;
}
/**
* 查询订单最新一笔售后详情,并装配该售后单申请时保存的商品快照。
*
* @param dto 售后查询条件,当前详情入口使用订单主键
* @return 最新售后详情;订单没有售后记录时返回 {@code null}
*/
@Override
public HardAfterSaleVo join(HardAfterSaleDto dto) {
List<HardAfterSaleEntity> list = baseMapper.selectList(new LambdaQueryWrapper<HardAfterSaleEntity>()
@@ -462,6 +482,8 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
if (ObjectUtil.isEmpty(list)) return null;
HardAfterSaleVo detail = BeanUtils.toBean(list.stream().findFirst().get(), HardAfterSaleVo.class);
if (ObjectUtil.isNotEmpty(detail)) {
// 审批必须查看当前售后单自己的商品快照,不能用订单原购物车数量代替本次退款数量。
detail.setCarList(hardAfterSaleCarService.getByRefundId(detail.getId()));
// 用户
if (ObjectUtil.isNotEmpty(detail.getUserId())) {
R<UserInfo> user = userService.getInnerByUserId(detail.getUserId());
@@ -488,6 +510,61 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
);
}
/**
* 将售后单上的原因投影到 App 时间线的业务节点,避免所有日志重复展示同一退款原因。
* 会员申请节点展示申请原因,拒绝节点展示审核原因;后台或系统直接退款没有申请节点,
* 因此在退款完成节点展示申请原因,并兼容回退到历史审核原因。
*
* @param id 售后单 ID
* @return 带节点退款原因的 App 售后进度
*/
@Override
public List<HardAfterSaleLogVo> getAppHistory(Long id) {
List<HardAfterSaleLogEntity> 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<HardAfterSaleMapper, H
HardAfterSaleEntity afterSale = validateAfterSaleAuditable(id);
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
if (order == null) throw new RuntimeException("设备订单不存在");
long returnReviewCount = countReturnToReviewOperations(id);
// 更新售后单的状态
HardAfterSaleEntity updateRefundObj = HardAfterSaleEntity.builder()
.id(id)
@@ -841,7 +919,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
// 食堂基础 SPU 库存恢复失败时禁止发起退款;渠道退款失败由库存幂等回调撤销本次恢复。
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
restoreRefundInventory(order, id);
String inventoryBizId = returnReviewCount > 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<HardAfterSaleMapper, H
return true;
}
@Override
public Boolean returnToReview(Long userId, Long id) {
HardAfterSaleEntity current = baseMapper.selectById(id);
if (current == null) {
throw new RuntimeException("售后单不存在");
}
return withOrderRefundLock(current.getOrderId(), () -> 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<HardAfterSaleEntity> 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<Boolean> 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<HardAfterSaleLogEntity>()
.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<HardAfterSaleMapper, H
* @param afterSaleId 本次售后单 ID,不能为空
*/
private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) {
restoreRefundInventory(order, afterSaleId, null);
}
/**
* 使用指定幂等业务号恢复食堂基础 SPU 的本次退款库存;空业务号兼容首次退款的历史售后单业务号。
*
* @param order 原每日菜谱预约订单或机器收银订单
* @param afterSaleId 本次售后单 ID,不能为空
* @param inventoryBizId 本轮库存恢复业务号;首次退款允许为空
*/
private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId, String inventoryBizId) {
if (afterSaleId == null) {
throw new RuntimeException("食堂菜品退款库存缺少售后单 ID");
}
List<HardAfterSaleCarEntity> 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<HardAfterSaleCarEntity> refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
List<HardCarEntity> inventoryCars = ObjectUtil.isEmpty(refundCars) ? null : toInventoryCars(refundCars);
hardOrderService.fastenerInventory(order, inventoryCars, YesNoEnum.YES, afterSaleId, inventoryBizId);
}
/**
@@ -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<HardAutoRefundTas
*/
private static final String SUPPORTED_ORDER_TYPE = "2";
/** 过期预约退款延迟允许的最小小时数。 */
private static final int MIN_EXPIRED_RESERVE_DELAY_HOURS = 1;
/** 过期预约退款延迟允许的最大小时数。 */
private static final int MAX_EXPIRED_RESERVE_DELAY_HOURS = 168;
/** 重扫单个订单时未同步任务,结果计数保持为零。 */
private static final int RESCAN_SYNC_SKIPPED = 0;
/** 重扫单个订单时新建任务,结果计数为一。 */
private static final int RESCAN_SYNC_CREATED = 1;
/** 重扫单个订单时刷新未尝试任务,结果计数为二。 */
private static final int RESCAN_SYNC_REFRESHED = 2;
/**
* 尝试日志 Mapper,用于保存每次自动、人工和开关跳过记录。
*/
@@ -118,7 +135,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final HardAfterSaleMapper afterSaleMapper;
/** 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。 */
/**
* 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。
*/
private final com.bsd.pjcloud.hard.mapper.order.HardOrderMapper hardOrderMapper;
/**
@@ -154,7 +173,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在售后申请事务内创建任务。配置服务异常、开关关闭或快照不完整时返回空并保留人工审核流程。
*
* @param order 已锁定的原订单
* @param order 已锁定的原订单
* @param afterSale 新建售后单
* @return 已创建任务;不符合免审条件时返回 {@code null}
*/
@@ -267,8 +286,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 使用订单 Redis 锁包裹尝试和失败状态落库,避免事务回滚释放行锁后被另一实例插入同一尝试序号。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Override
@@ -311,14 +330,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
@@ -363,8 +382,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -420,9 +439,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 记录一次成功完成的 Wallet 调用并原子推进售后和任务状态。恢复已到账记录时不再消耗自动尝试额度,
* 因为真正的资金调用已在此前回滚的 Hardware 事务中发生。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param countAutoAttempt 是否把本次调用计入八次自动资金尝试额度
*/
private void completeAttempt(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -478,10 +497,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 实际尝试失败后再次读取当前开关;若失败由关闭竞态导致则转为暂停且不消耗自动额度。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param exception 本次执行异常
* @param exception 本次执行异常
*/
private void handleAttemptFailure(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
Exception exception) {
@@ -517,13 +536,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在当前订单 Redis 锁内探测 Wallet 已到账记录,并通过独立 Hardware 事务恢复本地状态。
* 此方法直接调用事务代理,不递归进入 {@link #execute(Long, HardAutoRefundTriggerTypeEnum, Long)},避免重复获取同一订单锁。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean tryRecoverPersistedWalletRefund(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
Long operatorUserId) {
HardAutoRefundTaskEntity task = getById(taskId);
if (!hasPersistedWalletRefund(task)) {
return false;
@@ -536,8 +555,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 在独立 Hardware 事务中锁定任务并恢复 Wallet 已到账结果,不重新获取订单 Redis 锁。
* Wallet 的退款接口仍会按售后单 ID 严格校验钱包和金额后返回原记录。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -553,13 +572,13 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 在当前 Hardware 事务中恢复已持久化的钱包退款。调用方已持有任务行锁,恢复过程不消耗自动额度。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @return Wallet 已存在退款记录并完成本地恢复时返回 {@code true}
*/
private boolean recoverPersistedWalletRefund(HardAutoRefundTaskEntity task,
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId) {
if (!hasPersistedWalletRefund(task)) {
return false;
}
@@ -594,10 +613,10 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 独立事务记录一次真实资金尝试失败,并根据自动次数计算固定重试时间或耗尽状态。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
* @param errorMessage 已截断的非敏感错误信息
* @param errorMessage 已截断的非敏感错误信息
*/
@Transactional(rollbackFor = Exception.class)
public void recordFailureInTransaction(Long taskId, HardAutoRefundTriggerTypeEnum triggerType, Long operatorUserId,
@@ -658,8 +677,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 已发起调用后发现开关关闭时写一条跳过日志并暂停,不增加自动尝试次数。
*
* @param taskId 任务主键
* @param triggerType 触发来源
* @param taskId 任务主键
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
@Transactional(rollbackFor = Exception.class)
@@ -675,7 +694,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 更新配置不可用暂停状态并设置下一轮复查时间。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param reason 暂停原因
*/
private void pauseConfigUnavailable(HardAutoRefundTaskEntity task, String reason) {
@@ -688,8 +707,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 首次遇到关闭开关时记录一次跳过审计,持续暂停扫描不会重复增加日志。
*
* @param task 已锁定任务
* @param triggerType 触发来源
* @param task 已锁定任务
* @param triggerType 触发来源
* @param operatorUserId 人工重试用户 ID
*/
private void pauseForDisabledSwitch(HardAutoRefundTaskEntity task, HardAutoRefundTriggerTypeEnum triggerType,
@@ -721,7 +740,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 判断当前任务状态和触发来源是否允许进入资金尝试。
*
* @param task 已锁定任务
* @param task 已锁定任务
* @param triggerType 触发来源
* @return 允许执行时返回 {@code true}
*/
@@ -755,7 +774,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 查询当前配置并统一校验 Feign 业务响应,异常由调用方区分受理降级或任务暂停。
*
* @param mchId 商户 ID
* @param mchId 商户 ID
* @param orderType 硬件订单类型编码
* @return 当前配置;未配置时返回 {@code null}
*/
@@ -779,7 +798,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*
* @param configId 快照配置主键
* @param ruleType 快照规则名称
* @param current 当前 Wallet 配置
* @param current 当前 Wallet 配置
* @return 配置身份和规则一致时返回 {@code true}
*/
private boolean matchesSnapshot(Long configId, String ruleType, MchAutoRefundConfigVo current) {
@@ -788,7 +807,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/** 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。 */
/**
* 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。
*/
private boolean matchesExpiredSnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
@@ -812,7 +833,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 分页查询任务并尽力补充关联展示数据;远程展示信息异常不会影响技术人员查看任务本体。
*
* @param page 分页参数
* @param dto 服务端已注入商户数据范围的筛选条件
* @param dto 服务端已注入商户数据范围的筛选条件
* @return 任务分页
*/
@Override
@@ -922,7 +943,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
/**
* 仅允许重试耗尽任务进入人工额外尝试,重复点击仍由任务行锁复查状态。
*
* @param taskId 任务主键
* @param taskId 任务主键
* @param operatorUserId 当前管理用户 ID
*/
@Override
@@ -941,7 +962,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
* 管理端逐单执行过期预约业务退款,候选任务不受技术重试状态机权限限制。
* 状态机为保留失败日志会吸收资金异常,因此执行后必须复查最终状态,避免管理端把失败提示成成功。
*
* @param taskId 过期预约任务主键
* @param taskId 过期预约任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
@@ -965,10 +986,11 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
}
/** 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。 */
/**
* 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。
*/
@Override
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
HardAutoRefundTaskPageDto dto) {
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
@@ -981,7 +1003,193 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return result;
}
/** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
/**
* 按商户当前过期预约配置重扫历史订单,补建或刷新未尝试任务,并立即执行已经到期的任务。
* 资金动作仍统一经过现有订单锁、售后校验、Wallet 幂等和自动重试状态机;开关关闭时只同步暂停任务,绝不调用退款。
*
* @param mchId 精确商户 ID
* @param operatorUserId 发起重扫的后台用户 ID,用于退款尝试审计
* @return 本轮扫描、任务同步和退款执行数量
*/
@Override
public HardExpiredRefundRescanResultVo rescanExpiredReserveOrders(Long mchId, Long operatorUserId) {
if (mchId == null) {
throw new RuntimeException("商户不能为空");
}
MchAutoRefundConfigVo config = loadCurrentConfig(mchId, SUPPORTED_ORDER_TYPE);
if (config == null || config.getId() == null || !Objects.equals(config.getMchId(), mchId) || !Objects.equals(config.getOrderType(), SUPPORTED_ORDER_TYPE)) {
throw new RuntimeException("当前商户未配置过期预约退款规则");
}
int delayHours = config.getExpiredReserveRefundDelayHours() == null ? 0 : config.getExpiredReserveRefundDelayHours();
if (delayHours < MIN_EXPIRED_RESERVE_DELAY_HOURS || delayHours > 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<HardOrderEntity> 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<HardAutoRefundTaskEntity>()
.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<HardAfterSaleEntity>()
.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<HardAutoRefundTaskEntity>()
.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<HardAutoRefundTaskVo> records) {
List<Long> orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return;
@@ -1092,7 +1300,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
.and(expired -> 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<HardAutoRefundTas
* 使用业务提示和原异常构造配置查询异常。
*
* @param message 不包含敏感信息的失败原因
* @param cause 原始远程异常
* @param cause 原始远程异常
*/
private CurrentConfigLookupException(String message, Throwable cause) {
super(message, cause);
@@ -0,0 +1,733 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
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.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
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.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
import com.bsd.pjcloud.hard.api.dto.order.HardAdminRefundDto;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundPreviewDto;
import com.bsd.pjcloud.hard.api.dto.order.HardBatchRefundSubmitDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
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.order.HardBatchRefundPreviewVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.service.HardAfterSaleCarService;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.hard.service.HardBatchRefundService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
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.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 后台批量退款服务实现,预览阶段批量聚合数据,执行阶段复用既有单笔整单退款事务。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardBatchRefundServiceImpl implements HardBatchRefundService {
/** 批量退款日志标签,用于定位权限校验、逐笔退款和结果文件异常。 */
private static final String LOG_TAG = "订单批量退款";
/** 前后端共同遵守的单批订单上限,防止超大 IN 查询和失控的支付渠道调用。 */
private static final int MAX_BATCH_ORDER_COUNT = 1000;
/** 批量退款异步进度使用的稳定 Redis 业务命名空间。 */
private static final String BATCH_REFUND_REDIS_KEY = "hard:order:batch-refund";
/** 通用异步进度弹窗展示的任务名称。 */
private static final String BATCH_REFUND_TASK_NAME = "订单批量退款";
/** 每笔后台整单退款固定使用的仅退款售后方式协议编码。 */
private static final String REFUND_ONLY_WAY = "10";
/** 结果工作簿名称,包含每笔订单成功、失败或跳过原因。 */
private static final String RESULT_SHEET_NAME = "批量退款结果";
/** 文件中心保存批量退款结果的业务目录。 */
private static final String RESULT_FILE_DIR = "hard-batch-refund";
/** 文件中心区分批量退款结果文件的业务类型编码。 */
private static final String RESULT_FILE_TYPE = "HARD_BATCH_REFUND_RESULT";
/** XLSX 结果文件的标准 MIME 类型。 */
private static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 结果文件名时间格式,保证同一操作人连续执行时文件名仍可区分。 */
private static final DateTimeFormatter RESULT_FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 当前支付退款实现支持的第三方原路退款渠道。 */
private static final Set<HardPayTypeEnum> 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<HardBatchRefundPreviewVo.OrderItem> 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<Long> normalizeOrderIds(List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) {
throw new IllegalArgumentException("请选择需要退款的订单");
}
List<Long> 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<Long> orderIds, PjcloudUser user) {
List<HardOrderVo> scopedOrders = loadScopedOrders(orderIds, user);
Map<Long, HardOrderVo> orderMap = scopedOrders.stream().collect(Collectors.toMap(HardOrderVo::getId,
item -> item, (first, second) -> first, LinkedHashMap::new));
if (orderMap.size() != orderIds.size()) {
throw new IllegalStateException("部分订单不存在或已超出当前数据权限,请刷新列表后重试");
}
Map<Long, List<HardAfterSaleEntity>> afterSalesByOrderId = loadEffectiveAfterSales(orderIds);
List<Long> parentCarIds = scopedOrders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
Map<Long, List<HardAfterSaleCarEntity>> occupiedCarsByParentId =
hardAfterSaleCarService.getOccupiedByCarParentIds(parentCarIds,
List.of(HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus()));
List<HardBatchRefundPreviewVo.OrderItem> items = new ArrayList<>(orderIds.size());
for (Long orderId : orderIds) {
HardOrderVo order = orderMap.get(orderId);
List<HardAfterSaleEntity> afterSales = afterSalesByOrderId.getOrDefault(orderId, List.of());
List<HardAfterSaleCarEntity> 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<HardOrderVo> loadScopedOrders(List<Long> 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<Long, List<HardAfterSaleEntity>> loadEffectiveAfterSales(List<Long> orderIds) {
return hardAfterSaleService.list(new LambdaQueryWrapper<HardAfterSaleEntity>()
.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<HardAfterSaleEntity> afterSales,
List<HardAfterSaleCarEntity> 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<HardBatchRefundPreviewVo.ProductItem> buildRemainingProducts(List<HardCarVo> originalCars,
List<HardAfterSaleCarEntity> occupiedCars) {
if (ObjectUtil.isEmpty(originalCars)) return List.of();
Map<Long, HardAfterSaleCarEntity> occupiedByProductId = occupiedCars.stream()
.filter(item -> item != null && item.getProductId() != null)
.collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId, item -> item,
(first, second) -> first, LinkedHashMap::new));
Map<String, ProductAccumulator> 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<HardBatchRefundPreviewVo.ProductItem> 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<HardAfterSaleEntity> afterSales,
List<HardBatchRefundPreviewVo.ProductItem> 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<HardBatchRefundPreviewVo.OrderItem> items) {
HardBatchRefundPreviewVo.Summary summary = new HardBatchRefundPreviewVo.Summary();
List<HardBatchRefundPreviewVo.OrderItem> refundableItems = items.stream()
.filter(item -> Boolean.TRUE.equals(item.getRefundable())).toList();
Set<Long> userIds = refundableItems.stream().map(HardBatchRefundPreviewVo.OrderItem::getUserId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Set<String> 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<HardBatchRefundPreviewVo.OrderItem> orders, String remark,
Long operatorId, AsyncTaskProgressReporter reporter) {
List<BatchRefundResultExcelRow> 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<BatchRefundResultExcelRow> 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<HardAfterSaleEntity> 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;
}
}
@@ -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;
/**
* 食堂预约统计导出服务实现。
* 用户食堂预约统计导出服务实现。
*
* <p>菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过
* Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。</p>
* <p>按权限用户分批查询每日预约并流式写入 Excel,避免长日期范围和大量用户一次性占用过多内存。</p>
*/
@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<Long, String> merchantNameMap = fetchMerchantNameMap(orders);
Map<Long, Map<LocalDate, List<HardCanteenUserApptOrderVo>>> 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<HardOrderVo> retainEffectiveReservationOrders(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (orders.isEmpty()) {
return List.of();
}
Map<Long, Long> effectiveTotalPriceMap;
if (isVerifiedFilter(query)) {
Set<Long> 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<Long, List<HardCarEntity>> 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<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders) {
private Map<Long, List<HardCarEntity>> fetchEffectiveReservationCars(List<HardOrderVo> orders) {
List<Long> parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
if (parentCarIds.isEmpty()) {
@@ -810,12 +812,41 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
Map<Long, List<HardCarEntity>> 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<HardCanteenReservationSaleFact> fetchReservationVerifiedSaleFacts(
List<Long> userIds, HardCanteenUserApptQueryDto query) {
if (userIds.isEmpty()) {
return List.of();
}
List<HardCanteenReservationSaleFact> 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<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query) {
HardOrderDto querySnapshot = copyProductQuery(query);
List<HardCanteenProductApptExcelVo> 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<LocalDate> 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<HardCanteenProductApptExcelVo> 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<HardCanteenProductApptExcelVo> buildProductRows(HardOrderDto query, LocalDate fixedDate) {
List<HardCanteenProductTotalVo> summaries = hardCarMapper.listApptExportSummary(query);
List<HardCanteenProductApptExportRecord> merchants = hardCarMapper.listApptExportMerchant(query);
List<HardCanteenProductApptExportRecord> products = hardCarMapper.listApptExportProduct(query);
if (summaries == null || summaries.isEmpty()) {
return List.of();
}
Map<Long, String> merchantNames = fetchMerchantNames(merchants);
Map<Long, String> departmentNames = fetchDepartmentNames(merchants);
Map<String, String> hourNames = fetchHourNames();
Map<String, DailyMenuAppVO> menuMap = fetchDailyMenus(products, fixedDate);
Map<String, List<HardCanteenProductApptExportRecord>> merchantGroups = merchants.stream()
.collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList()));
Map<String, List<HardCanteenProductApptExportRecord>> productGroups = products.stream()
.collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList()));
List<HardCanteenProductApptExcelVo> 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<HardCanteenProductApptExportRecord> merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
Map<String, List<HardCanteenProductApptExcelVo>> detailRowsByMerchant = new LinkedHashMap<>();
Map<String, Long> detailCountsByMerchant = new HashMap<>();
Map<String, Long> detailPricesByMerchant = new HashMap<>();
long summaryProductCount = 0L;
long summaryTotalPrice = 0L;
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
String merchantKey = groupKey(merchant);
List<HardCanteenProductApptExcelVo> detailRows = productGroups
.getOrDefault(merchantKey, List.of()).stream()
.flatMap(product -> toProductRows(product,
menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
.toList();
List<HardCanteenProductApptExcelVo> 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<HardCanteenProductApptExcelVo> 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;
}
/**
* 将商品聚合行按预约当天每日菜谱中的套餐组成展开。
*
* <p>组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊,
* 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。</p>
*
* @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径
* @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行
* @return 单品原始行,或按套餐组成顺序生成的商品明细行
*/
private List<HardCanteenProductApptExcelVo> toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) {
List<OnlineSpuComboItemVO> 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<Integer> remainderIndexes = new ArrayList<>();
List<HardCanteenProductApptExcelVo> 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;
}
/**
* 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。
*
* <p>套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键,
* 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。</p>
*
* @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细
* @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致
*/
private List<HardCanteenProductApptExcelVo> buildProductStatistics(
List<HardCanteenProductApptExcelVo> detailRows) {
Map<String, HardCanteenProductApptExcelVo> 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<String, DailyMenuAppVO> fetchDailyMenus(List<HardCanteenProductApptExportRecord> products,
LocalDate fixedDate) {
Map<String, DailyMenuAppVO> result = new HashMap<>();
LocalDate date = fixedDate;
if (date == null) return result;
List<Long> 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<List<DailyMenuAppVO>> 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<Long, String> fetchMerchantNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<List<MchManageVo>> 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<Long, String> fetchDepartmentNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<Map<Long, SysDept>> 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<String, String> fetchHourNames() {
try {
R<List<SysDictItem>> 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<LocalDate> 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<LocalDate> 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);
}
/**
* 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。
*
* <p>商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。</p>
*
* @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<String, String> 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);
}
}
@@ -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
/**
* 按当前用户预约查询条件生成每日和查询范围总计。
*
* <p>统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数,
* 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。</p>
* <p>统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取完成退款扣减后的有效商品。
* 已核销筛选直接复用菜品销售统计的核销事实,保证人数、订单、份数和金额不会因整行状态或分页产生偏差。</p>
*
* @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
* @return 按日期升序排列并包含范围总计的统计结果
@@ -276,7 +280,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
List<HardCanteenUserApptVo> rows = filterRows(buildRows(users, query, queryDates, orders), query);
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders);
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders, query);
Map<LocalDate, StatisticsBucket> dailyBuckets = new LinkedHashMap<>();
queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket()));
@@ -296,6 +300,114 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
return result;
}
/**
* 按 App 当前账号可管理食堂统计预约菜品及点餐人员。
*
* <p>与 PC 人员预约统计不同,App 商户视角以可管理食堂中的有效订单为主数据源,
* 再按订单用户主键批量补齐姓名和部门,避免商户账号的组织数据权限漏掉实际点餐人员。</p>
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
* @return 与 PC 端核销状态口径一致且完成退款扣减后的菜品及点餐人员汇总
*/
@Override
public HardCanteenUserApptStatisticsSummaryVo appUserReservationStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds) {
return appUserReservationDailyStatistics(query, merchantIds).getTotal();
}
/**
* 按 App 当前账号可管理食堂统计每日预约及查询范围总计。
*
* <p>订单先按商户范围、日期和餐段读取,再复用 PC 人员预约页的核销状态判定筛选用户日期行,
* 保证待核销、部分核销和已核销的协议值及统计日期口径一致。</p>
*
* @param query 预约日期、餐段和核销状态筛选条件
* @param merchantIds 服务端解析出的可管理食堂主键集合,禁止使用客户端自行声明的范围
* @return 按统计日期升序排列的每日预约统计及范围总计
*/
@Override
public HardCanteenUserApptStatisticsVo appUserReservationDailyStatistics(
HardCanteenUserApptQueryDto query, List<Long> merchantIds) {
validateQuery(query);
List<Long> 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<HardOrderVo> orders = retainEffectiveReservationOrders(
hardOrderMapper.scopeList(orderQuery, null), query);
if (orders.isEmpty()) {
return new HardCanteenUserApptStatisticsVo();
}
List<Long> userIds = orders.stream().map(HardOrderVo::getUserId)
.filter(Objects::nonNull).distinct().toList();
List<UserVO> users = fetchUsersByIds(userIds);
List<LocalDate> queryDates = getQueryDates(query);
List<HardCanteenUserApptVo> rows = filterRows(buildRows(users, query, queryDates, orders), query).stream()
.filter(row -> row.getOrders() != null && !row.getOrders().isEmpty()).toList();
Map<Long, HardOrderVo> orderMap = orders.stream().filter(order -> order.getId() != null)
.collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left));
Map<Long, List<HardCarEntity>> carMap = fetchReservationCars(orders, query);
Map<LocalDate, StatisticsBucket> 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;
}
/**
* 按订单用户主键批量读取不受商户账号组织数据权限裁剪的用户展示摘要。
*
* <p>订单范围已先由可管理食堂限定,因此这里只补齐这些订单关联人员的姓名、账号和部门;
* 若历史用户已删除,则保留一个未命名占位,确保对应订单和菜品份数不会从统计中消失。</p>
*
* @param userIds 可管理食堂有效预约订单中的去重用户主键
* @return 与输入主键顺序一致的用户摘要列表
*/
private List<UserVO> fetchUsersByIds(List<Long> userIds) {
if (userIds.isEmpty()) {
return List.of();
}
Map<Long, UserVO> 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<Long> batchUserIds = new ArrayList<>(userIds.subList(start, end));
R<List<UserVO>> 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<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query,
List<LocalDate> queryDates) {
private List<HardCanteenUserApptVo> listAllFilteredRows(HardCanteenUserApptQueryDto query, List<LocalDate> queryDates) {
List<UserVO> users = fetchAllUsers(query);
return filterRows(buildRows(users, query, queryDates), query);
}
@@ -536,8 +647,7 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* @return 含准确总数的用户分页
*/
private Page<UserVO> fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) {
R<Page<UserVO>> response = remoteUserService.getScopeBasicUserPage(current, size,
buildUserQuery(query));
R<Page<UserVO>> 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<Long, String> merchantNameMap = fetchMerchantNameMap(orders);
Map<Long, Map<LocalDate, List<HardCanteenUserApptOrderVo>>> 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 条件。
* 只保留完成退款后仍有有效商品的预约订单,并把订单展示金额改为退款后的净商品金额。
*
* <p>已核销筛选直接使用菜品销售统计事实判定有效订单;其他预约状态读取退款后有效商品快照。
* 这样全额退款订单不会继续计入人员、订单和金额,部分退款只保留实际剩余值。</p>
*
* @param orders 已按订单状态和业务日期查询出的预约订单
* @param query 当前人员页面筛选条件
* @return 完成退款后仍有商品的订单,订单总价已更新为净额
*/
private List<HardOrderVo> retainEffectiveReservationOrders(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (orders.isEmpty()) {
return List.of();
}
Map<Long, Long> effectiveTotalPriceMap;
if (isVerifiedFilter(query)) {
Set<Long> 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<Long, List<HardCarEntity>> 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<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders) {
private Map<Long, List<HardCarEntity>> fetchReservationCars(List<HardOrderVo> orders,
HardCanteenUserApptQueryDto query) {
if (isVerifiedFilter(query)) {
Map<Long, HardOrderVo> 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<Long, List<HardCarEntity>> fetchEffectiveReservationCars(List<HardOrderVo> orders) {
List<Long> parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
.filter(Objects::nonNull).distinct().toList();
if (parentCarIds.isEmpty()) {
@@ -698,12 +870,59 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
Map<Long, List<HardCarEntity>> 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<HardCanteenReservationSaleFact> fetchReservationVerifiedSaleFacts(
List<Long> userIds, HardCanteenUserApptQueryDto query) {
if (userIds.isEmpty()) {
return List.of();
}
List<HardCanteenReservationSaleFact> 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<String, MealStatisticsAccumulator> meals = new LinkedHashMap<>();
/** 餐段和菜品名称到菜品聚合结果的有序映射。 */
/** 餐段、商品 ID 和名称快照到菜品聚合结果的有序映射;商品 ID 缺失时仅使用名称兼容历史数据。 */
private final Map<String, DishStatisticsAccumulator> 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<Long> productIds = new LinkedHashSet<>();
/** 下单时固化的菜品图片地址;同名菜品聚合时保留首个非空值。 */
private String productPic;
/** 菜品快照份数。 */
private long productCount;
@@ -975,15 +1265,20 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
/** 包含该菜品的去重订单主键。 */
private final Set<Long> orderIds = new HashSet<>();
/** 点过该菜品的用户主键到个人净份数的有序映射。 */
private final Map<Long, DishUserStatisticsAccumulator> 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;
}
}
@@ -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;
/**
* 硬件订单导出服务实现。
*
* <p>导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单,
* 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入,
* 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。</p>
*/
@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));
}
/**
* 执行兼容旧页面的同步分批导出。
*
* <p>旧接口仍保持原 URL 和响应方式,但内部不再一次性读取全部订单;完整临时文件生成后才写入响应,
* 避免部分失败文件被浏览器当作成功结果下载。</p>
*
* @param response 当前 HTTP 下载响应
* @param dto 订单页面当前筛选条件
*/
@Override
public void export(HttpServletResponse response, HardOrderDto dto) {
HardOrderDto querySnapshot = prepareExportQuery(copyQuery(dto));
Page<OrderExportRow> 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<OrderExportRow> 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 流式写入自定义二级表头工作簿。
*
* <p>文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度,
* 避免大批量订单长期占用请求线程和堆内存。</p>
*
* @param query 已冻结的订单查询条件
* @param firstPage 已用于取得总数的第一页,避免重复查询首批订单
* @param total 导出开始时按相同条件统计的订单总数
* @param reporter 异步任务进度报告器;同步兼容导出时为空
* @param fileConsumer 完整临时文件消费回调
* @param <R> 文件消费结果类型
* @return 文件消费回调返回的结果
*/
private <R> R writeExportWorkbook(HardOrderDto query, Page<OrderExportRow> firstPage, long total,
AsyncTaskProgressReporter reporter,
BatchExcelExportTemplate.FileConsumer<R> 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<OrderExportRow> currentPage = firstPage;
while (processedTotal < total) {
List<OrderExportRow> records = safeRecords(currentPage);
if (records.isEmpty()) {
break;
}
long remaining = total - processedTotal;
int recordCount = Math.toIntExact(Math.min(records.size(), remaining));
List<OrderExportRow> batch = records.subList(0, recordCount);
List<HardOrderExportExcelVo> 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<OrderExportRow> fetchExportPage(long current, long size, HardOrderDto query, boolean searchCount) {
Page<HardOrderVo> page = new Page<>(current, size, searchCount);
Page<HardOrderVo> orderPage = hardOrderMapper.scopePageList(page, query, createOrderDataScope());
Page<OrderExportRow> exportPage = new Page<>(current, size, orderPage.getTotal(), searchCount);
exportPage.setRecords(buildExportRows(orderPage.getRecords()));
return exportPage;
}
/**
* 在导出任务线程中同步装配一个订单分片,避免列表服务的公共线程池丢失 Feign 登录凭证。
*
* @param orders 当前订单分片,最多为订单导出批大小
* @return 保持订单原始顺序并携带完整用户资料、商户名称的导出行
*/
private List<OrderExportRow> buildExportRows(List<HardOrderVo> orders) {
if (orders == null || orders.isEmpty()) {
return List.of();
}
List<Long> userIds = orders.stream().map(HardOrderVo::getUserId).filter(Objects::nonNull).distinct().toList();
List<Long> merchantIds = orders.stream().map(HardOrderVo::getMerchantId).filter(Objects::nonNull).distinct().toList();
Map<Long, UserVO> userMap = fetchExportUserMap(userIds);
Map<Long, String> 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<Long, UserVO> fetchExportUserMap(List<Long> userIds) {
if (userIds.isEmpty()) {
return Map.of();
}
UserDTO userQuery = new UserDTO();
userQuery.setUserIds(userIds);
R<Page<UserVO>> 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<UserVO> 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<Long, String> fetchMerchantNameMap(List<Long> merchantIds) {
if (merchantIds.isEmpty()) {
return Map.of();
}
R<List<MchManageVo>> 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<OrderExportRow> safeRecords(Page<OrderExportRow> 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<SysPost> 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<String> 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();
}
/**
* 将订单状态协议编码安全转换为中文描述。
*
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。</p>
*
* @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;
}
/**
* 将设备类型协议编码安全转换为中文描述。
*
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。</p>
*
* @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<Long> mchIds = query.getMchIds();
if (mchIds != null && mchIds.size() == 1) {
Long parentMchId = mchIds.get(0);
R<List<MchManageVo>> response = remoteMchService.getByParentId(parentMchId);
if (response == null || !response.isOk()) {
throw new IllegalStateException("查询导出子商户范围失败");
}
List<Long> 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 <T> 列表元素类型
* @return 独立列表副本;输入为空时返回空
*/
private <T> List<T> copyList(List<T> 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);
}
}
}
@@ -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<HardOrderMapper, HardOrder
private final RemoteWalletTransactionService walletTransactionService;
private final RemoteWalletSettingService walletSettingService;
private final RemoteUserService userService;
/** 硬件订单用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteUserRelationService userRelationService;
private final RemoteDeptService deptService;
private final RemoteMchRuleJoinService mchRuleJoinService;
@@ -244,6 +248,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public Page<HardOrderVo> pagejoin(Page page, HardOrderDto dto) {
applyIdentityQuery(dto);
Page<HardOrderVo> list = baseMapper.pageJoin(page, dto);
disposeList(list.getRecords(), dto);
return list;
@@ -251,6 +256,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public List<HardOrderVo> scopeList(HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
List<HardOrderVo> list = baseMapper.scopeList(dto, dataScope);
disposeList(list, dto);
return list;
@@ -305,6 +311,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> scopePageList(Page page, HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
if (dto.getAppViewerUserId() != null) {
Page<HardOrderVo> list = appVisibilityPageList(page, dto, dataScope);
disposeList(list.getRecords(), dto);
@@ -351,6 +358,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> 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<HardOrderVo> list = baseMapper.scopePageList(page, dto, dataScope);
@@ -371,10 +379,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CompletableFuture<Void> findUserFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
if (ObjectUtil.isNotEmpty(detail.getUserId())) {
List<UserVO> 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<HardOrderMapper, HardOrder
throw new RuntimeException("购物车不存在");
}
Long reserveCount = isHaveCanteenReserve(dto.getUserId());
if (reserveCount > 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<HardOrderMapper, HardOrder
}
for (Long dinerUserId : dinerUserIds) {
Long reserveCount = isHaveCanteenReserve(dinerUserId);
if (reserveCount >= 20) {
if (reserveCount >= 200) {
throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,请先核销");
}
}
@@ -1552,8 +1557,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
for (Long dinerUserId : dinerUserIds) {
Long reserveCount = isHaveCanteenReserve(dinerUserId);
if (Math.addExact(reserveCount, (long) contexts.size()) > 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<HardOrderMapper, HardOrder
}
/**
* 把下单时命中的配置身份、规则和可确定截止时间写入订单。周期规则缺少有效批次时整组快照留空。
* 把下单时命中的配置身份、规则和用餐日核销结束时间写入订单。核销结束时间不受退款开关控制,
* 即使当前关闭过期退款,也要保留订单自身快照,供以后调整延迟并立即重扫时使用。
*
* @param order 尚未入库的每日菜谱订单
* @param reservation 已校验预约上下文
* @param config 本批次唯一配置查询结果;为空时不写快照
* @param config 本批次唯一配置查询结果;为空时仍保留预约时间快照,但不创建退款配置快照
*/
private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order,
HardApptReservationService.ReservationValidation reservation,
MchAutoRefundConfigVo config) {
if (config == null || reservation == null) {
if (reservation == null) {
return;
}
order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime());
if (config == null) {
return;
}
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
@@ -1932,11 +1942,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
&& config.getExpiredReserveRefundDelayHours() != null
&& config.getExpiredReserveRefundDelayHours() >= 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<HardOrderMapper, HardOrder
private void validateCanteenSpuOrderInventory(HardOrderEntity order) {
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
if (!usesCanteenSpuInventory(parentCar)) return;
updateCanteenSpuOrderInventory(order, null, YesNoEnum.YES, null, true);
updateCanteenSpuOrderInventory(order, null, YesNoEnum.YES, null, null, true);
}
/**
@@ -2208,16 +2217,48 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 商户未配置监护关系时只能为付款人本人订餐,服务端必须再次校验以防绕过 App 限制。
* 按商户配置校验代订关系和用餐人身份,服务端必须再次校验以防绕过 App 限制。
*
* @param payerUserId 当前付款人用户 ID
* @param dinerUserIds 本批次实际用餐人用户 ID,已经过关系授权和去重校验
* @param merchant 当前订餐商户及其定餐配置
*/
private void validateMerchantReserveDinerPermissions(Long payerUserId, List<Long> 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<String> allowedIdentityTypes = conf.getUserIdentityTypeList().stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(identityType -> UserIdentityTypeEnum.valueOfByType(identityType) != null)
.distinct()
.toList();
if (allowedIdentityTypes.isEmpty()) {
return;
}
R<List<UserVO>> userResponse = userService.getUserDeptByIds(dinerUserIds);
if (userResponse == null || !userResponse.isOk() || userResponse.getData() == null) {
throw new RuntimeException(userResponse == null
? "查询用餐人身份无响应"
: StrUtil.blankToDefault(userResponse.getMsg(), "查询用餐人身份失败"));
}
Map<Long, UserVO> 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<HardOrderMapper, HardOrder
.walletId(dinerWallet.getId())
.isOfflineOrder(YesNoEnum.NO.getCode())
.apptUseDate(reservation.apptDate())
// 餐段配置修改频繁,必须在预约校验通过的下单时刻固化,后续重扫不得按当前配置反推历史订单。
.expiredRefundUsageEndTime(reservation.usageEndDateTime())
.productDesc(subject)
.build();
order.setHourType(reservation.hourType());
@@ -2705,7 +2748,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 判断是否有重复的预约订单
Long reserveCount = isHaveCanteenReserve(dto.getUserId());
if (reserveCount > 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<HardOrderMapper, HardOrder
* @param refundCarList 本次退款商品;为空表示整单商品
* @param isReduce 是否扣减库存
* @param afterSaleId 本次售后单 ID,用于隔离同一订单的多次部分退款
* @param inventoryBizId 调用方指定的本轮库存幂等业务号;为空时按订单或售后单生成
* @param validateOnly 是否仅使用当前权威库存校验购买数量,不发起库存变更
*/
private void updateCanteenSpuOrderInventory(HardOrderEntity order, List<HardCarEntity> 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<HardOrderMapper, HardOrder
// 保留已上线的 DAILY_MENU 幂等号前缀,避免升级后对历史每日菜谱订单重复扣减。
// 售后单 ID 隔离多次部分退款;同一售后单的支付重试必须复用同一业务号,避免重复恢复库存。
String inventoryBizId = afterSaleId == null
String resolvedInventoryBizId = StrUtil.isNotBlank(inventoryBizId) ? inventoryBizId : afterSaleId == null
? "HARD_DAILY_MENU_ORDER:" + order.getId()
: "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId;
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, inventoryBizId);
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, inventoryBizId, "APPLY");
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
}
/**
@@ -4310,6 +4355,23 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Transactional(rollbackFor = Exception.class)
public void fastenerInventory(HardOrderEntity order, List<HardCarEntity> 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<HardCarEntity> 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<HardOrderMapper, HardOrder
if (ObjectUtil.equals(hardTypeEnum, HardTypeEnum.CANTEEN)) {
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
if (usesCanteenSpuInventory(parentCar)) {
updateCanteenSpuOrderInventory(order, refundCarList, isReduce, afterSaleId, false);
updateCanteenSpuOrderInventory(order, refundCarList, isReduce, afterSaleId, inventoryBizId, false);
return;
}
// 只有预约在线菜品使用 canteen_online_spu;其他未知或历史来源保持原行为,避免跨表错扣。
@@ -4839,20 +4901,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 用户部门
CompletableFuture<Void> deptFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<Long> userIds = list.stream().map(HardOrderVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
List<UserVO> userList = userService.getUserDeptByIds(userIds).getData();
if (ObjectUtil.isNotEmpty(userList)) {
Map<Long, UserVO> 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<Void> currentDeptFuture = CompletableFuture.runAsync(() -> {
@@ -4935,4 +4984,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
});
CompletableFuture.allOf(deptFuture, mchFuture, currentDeptFuture, walletFuture, findCarListFuture, findApptListFuture).join();
}
/**
* 将身份条件预筛选为用户主键,保持订单 SQL 的原有用户条件和数据范围语义。
*
* @param dto 订单查询条件
*/
private void applyIdentityQuery(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);
}
}
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.common.core.util.BeanUtils;
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.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -47,6 +48,8 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -149,7 +152,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
HardOrderDto totalDto = BeanUtil.toBean(dto, HardOrderDto.class);
totalDto.setOrderStatusCodes(HardOrderStatusEnum.getPayPrice());
DataScope totalDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// DataScope 继承 HashMap,空对象仍包含数据权限配置,不能按 Map 非空判断丢弃权限范围。
totalDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
totalDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -161,7 +165,8 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
refundDto.setRefundStatusCodes(List.of(HardSaleStatusEnum.COMPLETE.getStatus()));
refundDto.setOrderStatusCodes(HardOrderStatusEnum.getPayErrorCodes());
DataScope refundDataScope = null;
if (ObjectUtil.isNotEmpty(dataScope)) {
if (dataScope != null) {
// 退款汇总与支付汇总必须使用同一份租户及部门数据权限快照。
refundDataScope = BeanUtils.toBean(dataScope, DataScope.class).func(DataScopeFuncEnum.ALL);
refundDataScope.setScopeDeptName("merchant_dept_id");
}
@@ -192,7 +197,10 @@ public class HardOrderTotalServiceImpl implements HardOrderTotalService {
}
/**
* 商户交易记录
* 汇总商户交易记录,并补全商户及商户部门展示信息。
*
* @param dto 商户、部门、用餐时段及交易时间等统计条件
* @return 按商户汇总的流水、退款和净消费数据;历史部门不存在时部门名为空
*/
@Override
public List<HardOrderCsVo> 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<HardOrderCsVo> 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<HardOrderCsVo> refundList = hardOrderMapper.listRefundCs(refundDto, refundDataScope);
// 有退款没有流水的情况
if (ObjectUtil.isAllNotEmpty(totalList, refundList)) {
List<Long> 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<Long, HardOrderCsVo> 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<HardOrderCsVo> 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<Long> mchIds = totalList.stream().map(HardOrderCsVo::getMerchantId).distinct().toList();
List<Long> mchIds = statisticsList.stream().map(HardOrderCsVo::getMerchantId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
List<MchManageVo> mchList = remoteMchService.getByIds(mchIds).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
WalletDataUtil.convertMchList(totalList, CollectionUtils.convertMap(mchList, MchManageVo::getId));
R<List<MchManageVo>> mchResponse = remoteMchService.getByIds(mchIds);
if (ObjectUtil.isNotEmpty(mchResponse) && mchResponse.isOk() && ObjectUtil.isNotEmpty(mchResponse.getData())) {
WalletDataUtil.convertMchList(statisticsList, CollectionUtils.convertMap(mchResponse.getData(), MchManageVo::getId));
}
}
// 商户部门
List<Long> mchDeptIds = totalList.stream().map(HardOrderCsVo::getMerchantDeptId).distinct().toList();
List<Long> mchDeptIds = statisticsList.stream().map(HardOrderCsVo::getMerchantDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(mchDeptIds)) {
Map<Long, SysDept> deptMap = remoteDeptService.getDeptByDeptIds(mchDeptIds).getData();
if (ObjectUtil.isNotEmpty(deptMap)) {
for (HardOrderCsVo v : totalList) {
R<Map<Long, SysDept>> deptResponse = remoteDeptService.getDeptByDeptIds(mchDeptIds);
if (ObjectUtil.isNotEmpty(deptResponse) && deptResponse.isOk() && ObjectUtil.isNotEmpty(deptResponse.getData())) {
Map<Long, SysDept> 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<HardDeptTotalVo> pageListDept(HardDeptTotalDto dto) {
// 查询部门
List<SysDept> deptList = queryDepartmentList(dto);
if (ObjectUtil.isEmpty(deptList)) {
return List.of();
}
List<WalletTransactionSumAndCountVo> walletBatchList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME));
List<WalletTransactionSumAndCountVo> walletReduceList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.BATCH.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE));
List<WalletTransactionSumAndCountVo> walletReturnCanteenList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.RETURN_CANTEEN.getCode(), null));
List<WalletTransactionSumAndCountVo> walletOnceBatchList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_INCOME));
List<WalletTransactionSumAndCountVo> walletOnceReduceList = queryWalletStatistics(
buildWalletStatisticsDto(dto, WalletBizEnum.UPDATE.getCode(), WalletTransactionSumAndCountDto.TYPE_EXPENSE));
List<HardOrderSumAndCountVo> payList = hardOrderMapper.sumAndCount(buildOrderStatisticsDto(dto, false));
List<HardOrderSumAndCountVo> refundList = hardOrderMapper.refundSumAndCount(buildOrderStatisticsDto(dto, true));
Map<Long, WalletTransactionSumAndCountVo> walletBatchMap = indexWalletStatistics(walletBatchList);
Map<Long, WalletTransactionSumAndCountVo> walletReduceMap = indexWalletStatistics(walletReduceList);
Map<Long, WalletTransactionSumAndCountVo> walletReturnCanteenMap = indexWalletStatistics(walletReturnCanteenList);
Map<Long, WalletTransactionSumAndCountVo> walletOnceBatchMap = indexWalletStatistics(walletOnceBatchList);
Map<Long, WalletTransactionSumAndCountVo> walletOnceReduceMap = indexWalletStatistics(walletOnceReduceList);
Map<Long, HardOrderSumAndCountVo> payMap = indexOrderStatistics(payList);
Map<Long, HardOrderSumAndCountVo> refundMap = indexOrderStatistics(refundList);
List<HardDeptTotalVo> 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<SysDept> 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<SysDept> deptList = remoteDeptService.getInnerAllDept(deptDto).getData();
if (ObjectUtil.isEmpty(deptList)) return null;
// List<Long> deptIds = deptList.stream().map(SysDept::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
List<HardDeptTotalVo> resultList = deptList.stream().map(v -> {
HardDeptTotalVo item = new HardDeptTotalVo();
item.setDeptId(v.getDeptId());
item.setDeptName(v.getName());
return item;
}).toList();
// 查询钱包批量充值流水
CompletableFuture<List<WalletTransactionSumAndCountVo>> 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<List<WalletTransactionSumAndCountVo>> 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<List<WalletTransactionSumAndCountVo>> 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<List<WalletTransactionSumAndCountVo>> 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<List<WalletTransactionSumAndCountVo>> 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<List<HardOrderSumAndCountVo>> 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<List<HardOrderSumAndCountVo>> 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<Void> allFutures = CompletableFuture.allOf(walletBatchFuture, walletReduceFuture, payFuture, refundFuture, walletOnceBatchFuture, walletOnceReduceFuture);
allFutures.join();
List<WalletTransactionSumAndCountVo> walletBatchList = walletBatchFuture.join();
List<WalletTransactionSumAndCountVo> walletReduceList = walletReduceFuture.join();
List<WalletTransactionSumAndCountVo> walletRetrunCanteenList = walletRetrunCanteenFuture.join();
List<WalletTransactionSumAndCountVo> walletOnceBatchList = walletOnceBatchFuture.join();
List<WalletTransactionSumAndCountVo> walletOnceReduceList = walletOnceReduceFuture.join();
List<HardOrderSumAndCountVo> payList = payFuture.join();
List<HardOrderSumAndCountVo> 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<List<SysDept>> 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<WalletTransactionSumAndCountVo> queryWalletStatistics(WalletTransactionSumAndCountDto walletDto) {
R<List<WalletTransactionSumAndCountVo>> 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<Long, WalletTransactionSumAndCountVo> indexWalletStatistics(List<WalletTransactionSumAndCountVo> statisticsList) {
Map<Long, WalletTransactionSumAndCountVo> 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<Long, HardOrderSumAndCountVo> indexOrderStatistics(List<HardOrderSumAndCountVo> statisticsList) {
Map<Long, HardOrderSumAndCountVo> 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);
}
/**
@@ -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<HardSpuRepertoryMap
private final HardSpuJoinService hardSpuJoinService;
private final RemoteMchService remoteMchService;
private final RemoteUserService remoteUserService;
/** 库存上架记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
/**
* 分页查询库存更新记录,并补齐上架用户身份及关联展示信息。
*
* @param page 分页参数
* @param dto 库存记录、用户身份及用户范围筛选条件
* @param dataScope 当前管理人员的数据范围
* @return 已完成用户、商户和硬件展示资料装配的分页结果
*/
@Override
public Page<HardSpuRepertoryVo> scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardSpuRepertoryVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -235,6 +242,12 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
return false;
}
/**
* 批量补齐库存记录的商户及用户展示信息。
*
* @param list 当前页库存更新记录;空列表不触发远程查询
* @param dto 当前查询条件,保留参数以兼容原装配方法契约
*/
private void disposeList(List<HardSpuRepertoryVo> list, HardSpuRepertoryDto dto) {
if (ObjectUtil.isEmpty(list)) return;
// 商户
@@ -246,11 +259,24 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardSpuRepertoryVo::getUserId).distinct().toList();
R<List<SysUser>> 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);
}
}
/**
* 将库存上架记录的身份条件转换为最终用户主键集合,保持原有 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);
}
}
@@ -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 后台列表的用户身份查询与展示资料装配器。
*
* <p>订单等业务表只保存用户主键和必要快照,身份筛选统一交给 UPMS 数据权限用户接口处理,
* Hardware 仅将命中的用户主键并入原有查询,并为当前页批量回填身份展示资料。</p>
*/
@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<Long> findIdentityUserIds(BaseUserIdentityQueryInterface query, Long userId, List<Long> userIds) {
List<Long> constrainedUserIds = mergeUserConstraints(userId, userIds);
if (constrainedUserIds != null && constrainedUserIds.isEmpty()) {
return List.of(NO_MATCH_USER_ID);
}
UserDTO userQuery = buildUserQuery(query, constrainedUserIds);
List<UserVO> 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 <T> 同时实现用户部门和用户身份展示契约的业务 VO 类型
*/
public <T extends BaseUserDeptInterface & BaseUserIdentityInterface> void fillUserInfo(List<T> rows,
boolean updateDepartment) {
if (rows == null || rows.isEmpty()) {
return;
}
List<Long> userIds = rows.stream().map(BaseUserDeptInterface::getUserId).filter(Objects::nonNull).distinct().toList();
if (userIds.isEmpty()) {
return;
}
UserDTO userQuery = new UserDTO();
userQuery.setUserIds(userIds);
Map<Long, UserVO> 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<Long> mergeUserConstraints(Long userId, List<Long> userIds) {
LinkedHashSet<Long> 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<Long> 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<UserVO> fetchAllUsers(UserDTO query) {
List<UserVO> users = new ArrayList<>();
long current = 1L;
long total;
do {
R<Page<UserVO>> 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<UserVO> page = response.getData();
List<UserVO> 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 <T> 列表元素类型
* @return 独立列表;源列表为空值时返回 {@code null}
*/
private <T> List<T> copyList(List<T> source) {
return source == null ? null : new ArrayList<>(source);
}
}
@@ -41,6 +41,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -70,4 +76,4 @@
</where>
order by a.create_time desc , a.id desc
</select>
</mapper>
</mapper>
@@ -51,6 +51,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -87,4 +93,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
@@ -84,4 +84,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more