diff --git a/db/20260822_add_expired_reserve_refund.sql b/db/20260822_add_expired_reserve_refund.sql
new file mode 100644
index 00000000..728aa179
--- /dev/null
+++ b/db/20260822_add_expired_reserve_refund.sql
@@ -0,0 +1,49 @@
+-- 每日菜谱预约订单过期自动退款扩展。
+-- 只增加新快照和任务字段,不回填历史订单;执行前应确认目标字段/索引不存在。
+
+ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config`
+ ADD COLUMN `expired_reserve_refund_enabled` char(1) NOT NULL DEFAULT '0'
+ COMMENT '过期预约自动退款开关,0关闭,1开启' AFTER `pay_time_limit_hours`,
+ ADD COLUMN `expired_reserve_refund_delay_hours` int(11) NOT NULL DEFAULT 2
+ COMMENT '餐段结束后的过期退款延迟小时数,允许1至168' AFTER `expired_reserve_refund_enabled`,
+ ADD COLUMN `expired_reserve_refund_restore_inventory` char(1) NOT NULL DEFAULT '1'
+ COMMENT '过期预约退款是否恢复库存,0不恢复,1恢复' AFTER `expired_reserve_refund_delay_hours`;
+
+ALTER TABLE `pjcloudx_hardware`.`hard_order`
+ ADD COLUMN `expired_refund_config_id` bigint(20) DEFAULT NULL
+ COMMENT '过期预约退款配置ID快照,仅新支付成功订单写入' AFTER `auto_refund_deadline`,
+ ADD COLUMN `expired_refund_usage_end_time` datetime DEFAULT NULL
+ COMMENT '支付成功时快照的实际餐段结束时间,无法解析时为空' AFTER `expired_refund_config_id`,
+ ADD COLUMN `expired_refund_deadline` datetime DEFAULT NULL
+ COMMENT '实际餐段结束时间加延迟后的过期退款触发时间' AFTER `expired_refund_usage_end_time`,
+ ADD COLUMN `expired_refund_restore_inventory` char(1) DEFAULT NULL
+ COMMENT '过期退款退库存快照,0不恢复,1恢复' AFTER `expired_refund_deadline`,
+ ADD KEY `idx_hard_order_expired_refund_scan`
+ (`tenant_id`, `expired_refund_deadline`, `order_status`, `del_flag`);
+
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期预约任务首次执行前为空',
+ MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空',
+ MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空',
+ ADD COLUMN `task_type` char(10) NOT NULL DEFAULT '10'
+ COMMENT '任务场景,10用户免审售后,20过期预约退款' AFTER `after_sale_id`,
+ ADD COLUMN `trigger_time` datetime DEFAULT NULL
+ COMMENT '过期预约任务按订单快照触发时间,旧售后任务为空' AFTER `eligibility_deadline`,
+ ADD COLUMN `restore_inventory` char(1) DEFAULT NULL
+ COMMENT '任务退库存快照,0不恢复,1恢复' AFTER `trigger_time`;
+
+-- MySQL 5.7 不支持函数索引,使用可索引的存储生成列承载场景业务主键:
+-- 普通售后取 after_sale_id,允许同一订单存在多个售后任务;过期预约取 order_id,保证一单一任务。
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+ ADD COLUMN `scene_biz_id` bigint(20)
+ GENERATED ALWAYS AS (CASE WHEN `task_type` = '20' THEN `order_id` ELSE `after_sale_id` END) STORED
+ COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`,
+ DROP INDEX `uk_hard_auto_refund_task_after_sale`,
+ ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz`
+ (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`),
+ ADD KEY `idx_hard_auto_refund_task_scan_type`
+ (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`);
+
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期任务创建售后前为空',
+ MODIFY COLUMN `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,过期任务按订单生成';
diff --git a/db/20260823_add_hard_order_evidence_image.sql b/db/20260823_add_hard_order_evidence_image.sql
new file mode 100644
index 00000000..d0561bd3
--- /dev/null
+++ b/db/20260823_add_hard_order_evidence_image.sql
@@ -0,0 +1,44 @@
+-- 订单识别证据图片从表。
+-- 本表独立于 hard_order,退款、撤销和订单逻辑删除均不自动清理证据文件或证据记录。
+CREATE TABLE `pjcloudx_hardware`.`hard_order_evidence_image`
+(
+ `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
+ `order_id` bigint(20) NOT NULL COMMENT '订单主键,来源hard_order.id',
+ `android_order_code` varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
+ `evidence_type` varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
+ `evidence_role` varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
+ `sequence_no` int(11) NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
+ `user_id` bigint(20) DEFAULT NULL COMMENT '识别用户主键快照',
+ `face_sample_id` varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
+ `reference_image_url` varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
+ `similarity` decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
+ `similarity_threshold` decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
+ `hard_id` bigint(20) NOT NULL COMMENT '产生证据的设备主键',
+ `captured_at` datetime DEFAULT NULL COMMENT '终端实际采集时间',
+ `sys_file_id` bigint(20) DEFAULT NULL COMMENT '文件中心sys_file记录主键',
+ `storage_file_name` varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
+ `image_url` varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
+ `image_hash` char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
+ `image_size` bigint(20) DEFAULT NULL COMMENT '实际文件大小,单位字节',
+ `image_width` int(11) DEFAULT NULL COMMENT '实际图片宽度,单位像素',
+ `image_height` int(11) DEFAULT NULL COMMENT '实际图片高度,单位像素',
+ `status` varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
+ `failure_reason` varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
+ `app_version` varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime DEFAULT NULL COMMENT '修改时间',
+ `del_flag` char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
+ `tenant_id` bigint(20) NOT NULL COMMENT '租户主键',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_hard_order_evidence_position`
+ (`tenant_id`, `order_id`, `evidence_type`, `evidence_role`, `sequence_no`, `del_flag`),
+ KEY `idx_hard_order_evidence_android_order`
+ (`tenant_id`, `hard_id`, `android_order_code`, `del_flag`),
+ KEY `idx_hard_order_evidence_status`
+ (`tenant_id`, `status`, `update_time`, `del_flag`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT = '硬件订单识别证据图片';
diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql
new file mode 100644
index 00000000..344c3c96
--- /dev/null
+++ b/db/20260823_add_hard_order_verification_time.sql
@@ -0,0 +1,16 @@
+-- 硬件订单新增独立核销时间。
+-- 历史二维码核销订单只能使用最后更新时间近似回填;外卖等非核销完成状态不参与回填。
+-- 新核销订单由业务事务写入准确时间,后续订单更新不再改变该值。
+
+ALTER TABLE `pjcloudx_hardware`.`hard_order`
+ ADD COLUMN `verification_time` datetime DEFAULT NULL
+ COMMENT '订单实际核销完成时间,后续订单更新不得覆盖' AFTER `refund_status`,
+ ADD KEY `idx_hard_order_verification_time`
+ (`tenant_id`, `verification_time`, `del_flag`);
+
+UPDATE `pjcloudx_hardware`.`hard_order`
+SET `verification_time` = `update_time`
+WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4'))
+ OR `pay_type` IN ('5', '6', '50'))
+ AND `verification_time` IS NULL
+ AND `update_time` IS NOT NULL;
diff --git a/db/20260824_add_third_payment_task.sql b/db/20260824_add_third_payment_task.sql
new file mode 100644
index 00000000..31468234
--- /dev/null
+++ b/db/20260824_add_third_payment_task.sql
@@ -0,0 +1,153 @@
+-- thirdparty 支付任务事实表与执行尝试表。
+-- Quartz 是唯一周期触发入口;任务表只保存可恢复事实、租约和幂等状态。
+CREATE TABLE IF NOT EXISTS `third_payment_task`
+(
+ `id` bigint NOT NULL COMMENT '任务主键',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID',
+ `task_type` varchar(48) NOT NULL COMMENT '任务类型编码',
+ `task_key` varchar(192) NOT NULL COMMENT '租户内稳定幂等任务键',
+ `pay_order_id` bigint DEFAULT NULL COMMENT 'third_pay_order主键',
+ `fz_order_id` bigint DEFAULT NULL COMMENT 'third_fz_order主键',
+ `aggregate_session_id` bigint DEFAULT NULL COMMENT 'third_aggregate_pay_session主键',
+ `pay_no` varchar(128) DEFAULT NULL COMMENT '平台支付单号',
+ `refund_no` varchar(128) DEFAULT NULL COMMENT '平台退款单号',
+ `fz_no` varchar(128) DEFAULT NULL COMMENT '平台分账单号',
+ `aggregate_no` varchar(128) DEFAULT NULL COMMENT '聚合会话单号',
+ `status` varchar(16) NOT NULL COMMENT '任务状态:READY/PROCESSING/RETRY/SUCCESS/FAILED/ALERT/CANCELLED',
+ `next_run_time` datetime NOT NULL COMMENT '下一次可执行时间',
+ `priority` int NOT NULL DEFAULT 0 COMMENT '任务优先级,数值越大越优先',
+ `lease_owner` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
+ `lease_token` varchar(64) DEFAULT NULL COMMENT '本次执行租约令牌',
+ `lease_expire_time` datetime DEFAULT NULL COMMENT '租约失效时间',
+ `attempt_count` int NOT NULL DEFAULT 0 COMMENT '累计执行次数',
+ `last_external_status` varchar(64) DEFAULT NULL COMMENT '最近一次渠道状态',
+ `last_error_code` varchar(128) DEFAULT NULL COMMENT '最近一次错误码',
+ `last_error_message` varchar(1024) DEFAULT NULL COMMENT '最近一次错误摘要',
+ `finished_time` datetime DEFAULT NULL COMMENT '任务终态时间',
+ `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime NOT NULL COMMENT '创建时间',
+ `update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
+ `update_time` datetime NOT NULL COMMENT '更新时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_payment_task_tenant_key` (`tenant_id`, `task_key`),
+ KEY `idx_third_payment_task_due` (`task_type`, `status`, `next_run_time`, `priority`, `id`),
+ KEY `idx_third_payment_task_lease` (`task_type`, `status`, `lease_expire_time`),
+ KEY `idx_third_payment_task_pay_no` (`tenant_id`, `pay_no`),
+ KEY `idx_third_payment_task_fz_no` (`tenant_id`, `fz_no`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务事实';
+
+CREATE TABLE IF NOT EXISTS `third_payment_task_attempt`
+(
+ `id` bigint NOT NULL COMMENT '尝试记录主键',
+ `task_id` bigint NOT NULL COMMENT '任务主键',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID',
+ `attempt_no` int NOT NULL COMMENT '任务内尝试序号',
+ `trigger_source` varchar(32) NOT NULL COMMENT '触发来源:QUARTZ/CALLBACK/SYNC/MANUAL',
+ `worker_id` varchar(128) DEFAULT NULL COMMENT 'Worker实例标识',
+ `lease_token` varchar(64) DEFAULT NULL COMMENT '执行租约令牌',
+ `request_snapshot` text DEFAULT NULL COMMENT '脱敏请求快照',
+ `response_snapshot` text DEFAULT NULL COMMENT '脱敏响应快照',
+ `external_status` varchar(64) DEFAULT NULL COMMENT '渠道状态',
+ `error_code` varchar(128) DEFAULT NULL COMMENT '错误码',
+ `error_message` varchar(1024) DEFAULT NULL COMMENT '错误摘要',
+ `started_time` datetime NOT NULL COMMENT '开始时间',
+ `finished_time` datetime DEFAULT NULL COMMENT '结束时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_third_payment_attempt_task` (`task_id`, `attempt_no`),
+ KEY `idx_third_payment_attempt_tenant_time` (`tenant_id`, `started_time`)
+) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '第三方支付任务执行尝试';
+
+-- Quartz 会按业务事实持续物化任务。百万级订单量下,如果业务表只有主键,每个固定触发器都会退化为全表扫描。
+-- 以下索引只覆盖实际扫描谓词和租户内业务号关联;使用 information_schema 判断保证迁移可以重复执行。
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_task_scan'),
+ 'SELECT ''idx_third_pay_order_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_task_scan` (`status`, `del_flag`, `pay_platform_type`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_fz_scan'),
+ 'SELECT ''idx_third_pay_order_fz_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_scan` (`status`, `enable_fz`, `del_flag`, `pay_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_callback_scan'),
+ 'SELECT ''idx_third_pay_order_callback_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_callback_scan` (`status`, `is_biz_callback`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_close_scan'),
+ 'SELECT ''idx_third_pay_order_close_scan already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_close_scan` (`status`, `biz_type`, `del_flag`, `create_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_pay_order' AND index_name = 'idx_third_pay_order_tenant_pay_no'),
+ 'SELECT ''idx_third_pay_order_tenant_pay_no already exists'' AS message',
+ 'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_task_scan'),
+ 'SELECT ''idx_third_fz_order_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_fz_order' AND index_name = 'idx_third_fz_order_tenant_pay_no'),
+ 'SELECT ''idx_third_fz_order_tenant_pay_no already exists'' AS message',
+ 'ALTER TABLE `third_fz_order` ADD INDEX `idx_third_fz_order_tenant_pay_no` (`tenant_id`, `pay_no`, `del_flag`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_task_scan'),
+ 'SELECT ''idx_third_aggregate_task_scan already exists'' AS message',
+ 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_task_scan` (`status`, `del_flag`, `update_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+SET @index_ddl = IF(
+ EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE()
+ AND table_name = 'third_aggregate_pay_session' AND index_name = 'idx_third_aggregate_close_scan'),
+ 'SELECT ''idx_third_aggregate_close_scan already exists'' AS message',
+ 'ALTER TABLE `third_aggregate_pay_session` ADD INDEX `idx_third_aggregate_close_scan` (`status`, `del_flag`, `create_time`, `id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 生产执行建议:低峰期使用在线 DDL 能力建索引,完成后 ANALYZE TABLE 并用真实状态分布执行 EXPLAIN ANALYZE。
diff --git a/db/20260824_optimize_sys_user_page_indexes.sql b/db/20260824_optimize_sys_user_page_indexes.sql
new file mode 100644
index 00000000..9718c431
--- /dev/null
+++ b/db/20260824_optimize_sys_user_page_indexes.sql
@@ -0,0 +1,111 @@
+-- 系统用户分页查询索引优化。
+--
+-- 适用接口:POST /admin/user/page、POST /admin/user/select。
+-- 执行影响:仅新增普通索引,不修改字段、约束和历史数据;建索引期间会消耗 IO,建议在业务低峰期执行。
+-- 执行前建议:先备份数据库,并使用文末查询核对生产库已有索引及 EXPLAIN 计划。
+
+-- 用户分页的默认排序索引:覆盖租户、逻辑删除和 create_time + user_id 稳定倒序分页。
+SET @index_ddl = IF(
+ EXISTS (
+ SELECT 1
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'sys_user'
+ AND index_name = 'idx_sys_user_tenant_del_created'
+ ),
+ 'SELECT ''idx_sys_user_tenant_del_created already exists'' AS message',
+ 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_created` (`tenant_id`, `del_flag`, `create_time`, `user_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 = 'sys_user'
+ AND index_name = 'idx_sys_user_tenant_del_dept_status_created'
+ ),
+ 'SELECT ''idx_sys_user_tenant_del_dept_status_created already exists'' AS message',
+ 'ALTER TABLE `sys_user` ADD INDEX `idx_sys_user_tenant_del_dept_status_created` (`tenant_id`, `del_flag`, `dept_id`, `process_status`, `create_time`, `user_id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 角色反向筛选索引:现有主键通常为 user_id + role_id,本索引用于从角色快速定位用户。
+SET @index_ddl = IF(
+ EXISTS (
+ SELECT 1
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'sys_user_role'
+ AND index_name = 'idx_sys_user_role_role_user'
+ ),
+ 'SELECT ''idx_sys_user_role_role_user already exists'' AS message',
+ 'ALTER TABLE `sys_user_role` ADD INDEX `idx_sys_user_role_role_user` (`role_id`, `user_id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 当前页人脸聚合及有无人脸 EXISTS 筛选索引,避免按用户查询时回表扫描租户全部人脸。
+SET @index_ddl = IF(
+ EXISTS (
+ SELECT 1
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'sys_user_face'
+ AND index_name = 'idx_sys_user_face_tenant_user_del'
+ ),
+ 'SELECT ''idx_sys_user_face_tenant_user_del already exists'' AS message',
+ 'ALTER TABLE `sys_user_face` ADD INDEX `idx_sys_user_face_tenant_user_del` (`tenant_id`, `user_id`, `del_flag`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 身份类型反向筛选索引:支持 identity_type 条件先定位用户,用户 ID 同时用于半连接。
+SET @index_ddl = IF(
+ EXISTS (
+ SELECT 1
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'sys_user_identity'
+ AND index_name = 'idx_sys_user_identity_type_user'
+ ),
+ 'SELECT ''idx_sys_user_identity_type_user already exists'' AS message',
+ 'ALTER TABLE `sys_user_identity` ADD INDEX `idx_sys_user_identity_type_user` (`identity_type`, `user_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 = 'sys_dept'
+ AND index_name = 'idx_sys_dept_tenant_del_parent'
+ ),
+ 'SELECT ''idx_sys_dept_tenant_del_parent already exists'' AS message',
+ 'ALTER TABLE `sys_dept` ADD INDEX `idx_sys_dept_tenant_del_parent` (`tenant_id`, `del_flag`, `parent_id`, `dept_id`)'
+);
+PREPARE index_stmt FROM @index_ddl;
+EXECUTE index_stmt;
+DEALLOCATE PREPARE index_stmt;
+
+-- 执行后核对新增索引;确认无重复或同前缀冗余索引后,再按生产变更流程决定是否清理旧索引。
+SELECT table_name, index_name, seq_in_index, column_name
+FROM information_schema.statistics
+WHERE table_schema = DATABASE()
+ AND table_name IN ('sys_user', 'sys_user_role', 'sys_user_face', 'sys_user_identity', 'sys_dept')
+ORDER BY table_name, index_name, seq_in_index;
+
+-- 建议在执行索引后更新统计信息,并用真实租户、部门和状态参数执行 EXPLAIN ANALYZE。
+-- ANALYZE TABLE sys_user, sys_user_role, sys_user_face, sys_user_identity, sys_dept;
diff --git a/db/20260825_add_third_mch_fz_relation.sql b/db/20260825_add_third_mch_fz_relation.sql
new file mode 100644
index 00000000..25115867
--- /dev/null
+++ b/db/20260825_add_third_mch_fz_relation.sql
@@ -0,0 +1,79 @@
+USE `pjcloudx_thirdparty`;
+
+-- 商户默认平台分佣关系。微信关系由 sub_mchid 区分,支付宝关系由调用应用、授权商户和接收账号共同确定。
+-- 表中只保存渠道标识和脱敏调用快照,不保存私钥、公钥或完整 app_auth_token。
+CREATE TABLE IF NOT EXISTS `third_mch_fz_relation`
+(
+ `id` bigint NOT NULL COMMENT '关系主键,应用侧雪花算法生成',
+ `tenant_id` bigint NOT NULL COMMENT '租户ID,与 mch_manage 所属租户一致',
+ `mch_id` bigint NOT NULL COMMENT 'mch_manage 商户主键',
+ `pay_setting_id` bigint NOT NULL COMMENT 'mch_pay_setting 支付配置主键',
+ `platform_type` varchar(32) NOT NULL COMMENT '支付平台类型:10微信、20支付宝',
+ `relation_scene` varchar(64) NOT NULL COMMENT '本地关系场景,默认平台分佣为 DEFAULT_PLATFORM_COMMISSION',
+ `channel_app_id` varchar(64) NOT NULL COMMENT '微信服务商AppID或支付宝平台AppID快照',
+ `channel_sub_mch_id` varchar(64) NOT NULL COMMENT '微信子商户号或支付宝直付通smid/ip_role_id',
+ `account_key` varchar(64) DEFAULT NULL COMMENT '支付宝命名平台账号键;默认账号及微信为空',
+ `receiver_type` varchar(32) NOT NULL COMMENT '渠道接收方类型,如MERCHANT_ID、userId、loginName',
+ `receiver_account` varchar(128) NOT NULL COMMENT '平台分佣接收账号;管理端展示时必须脱敏',
+ `receiver_name` varchar(128) DEFAULT NULL COMMENT '平台分佣接收方名称快照',
+ `channel_relation_type` varchar(64) DEFAULT NULL COMMENT '渠道关系类型;微信默认SERVICE_PROVIDER,支付宝为空',
+ `out_request_no` varchar(64) NOT NULL COMMENT '支付宝绑定稳定幂等请求号;微信作为本地稳定审计号',
+ `status` varchar(32) NOT NULL COMMENT 'PENDING_BIND、BINDING、BOUND、BIND_FAILED或SUPERSEDED',
+ `is_default` char(1) NOT NULL DEFAULT '1' COMMENT '是否为当前支付配置的默认平台分佣关系:0否、1是',
+ `bind_req` text DEFAULT NULL COMMENT '脱敏后的关系绑定请求快照',
+ `bind_res` text DEFAULT NULL COMMENT '渠道关系绑定或查询响应快照',
+ `bind_error` varchar(1000) DEFAULT NULL COMMENT '最近一次绑定错误摘要,不含凭据',
+ `last_external_status` varchar(128) DEFAULT NULL COMMENT '最近一次渠道结果码或外部状态',
+ `last_query_time` datetime DEFAULT NULL COMMENT '最近一次关系绑定或查询时间',
+ `bound_time` datetime DEFAULT NULL COMMENT '渠道明确确认关系已绑定的时间',
+ `create_by` varchar(32) DEFAULT NULL COMMENT '创建人',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_by` varchar(32) DEFAULT NULL COMMENT '更新人',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0正常、1删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_third_mch_fz_relation_identity`
+ (`tenant_id`, `pay_setting_id`, `platform_type`, `channel_app_id`, `channel_sub_mch_id`,
+ `receiver_type`, `receiver_account`, `relation_scene`),
+ KEY `idx_third_mch_fz_relation_default`
+ (`tenant_id`, `mch_id`, `platform_type`, `is_default`, `status`, `del_flag`),
+ KEY `idx_third_mch_fz_relation_task`
+ (`status`, `is_default`, `update_time`, `id`)
+) ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_general_ci
+ COMMENT ='商户第三方支付分账关系';
+
+-- 支付任务通过结构化主键定位关系事实,禁止从 task_key 字符串反解析业务主键。
+SET @relation_task_column_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'third_payment_task'
+ AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @relation_task_column_sql = IF(
+ @relation_task_column_exists = 0,
+ 'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_id`, ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
+ 'SELECT 1'
+);
+PREPARE relation_task_column_stmt FROM @relation_task_column_sql;
+EXECUTE relation_task_column_stmt;
+DEALLOCATE PREPARE relation_task_column_stmt;
+
+-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
+SET @pay_relation_column_exists = (
+ SELECT COUNT(1)
+ FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'third_pay_order'
+ AND COLUMN_NAME = 'fz_relation_id'
+);
+SET @pay_relation_column_sql = IF(
+ @pay_relation_column_exists = 0,
+ 'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`, ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
+ 'SELECT 1'
+);
+PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
+EXECUTE pay_relation_column_stmt;
+DEALLOCATE PREPARE pay_relation_column_stmt;
diff --git a/db/20260825_add_wallet_transaction_remark.sql b/db/20260825_add_wallet_transaction_remark.sql
new file mode 100644
index 00000000..ee5f5b73
--- /dev/null
+++ b/db/20260825_add_wallet_transaction_remark.sql
@@ -0,0 +1,3 @@
+-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。
+ALTER TABLE wallet_transaction
+ ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags;
diff --git a/db/800pjcloudx_hardware.sql b/db/800pjcloudx_hardware.sql
index 0c2a37f1..173d7d84 100644
--- a/db/800pjcloudx_hardware.sql
+++ b/db/800pjcloudx_hardware.sql
@@ -301,6 +301,50 @@ CREATE TABLE hard_order
PRIMARY KEY (id)
) ENGINE = InnoDB COMMENT ='订单表';
+-- ----------------------------
+-- Table structure for hard_order_evidence_image
+-- ----------------------------
+DROP TABLE IF EXISTS `hard_order_evidence_image`;
+CREATE TABLE hard_order_evidence_image
+(
+ id bigint NOT NULL AUTO_INCREMENT COMMENT '证据记录自增主键',
+ order_id bigint NOT NULL COMMENT '订单主键,来源hard_order.id',
+ android_order_code varchar(128) NOT NULL COMMENT '终端本地稳定订单号快照',
+ evidence_type varchar(32) NOT NULL COMMENT '证据类型,FACE_RECOGNITION或MENU_RECOGNITION',
+ evidence_role varchar(32) NOT NULL COMMENT '图片角色,FACE_REFERENCE、FACE_LIVE或MENU_LIVE',
+ sequence_no int NOT NULL DEFAULT 0 COMMENT '同类型同角色图片序号,从0开始',
+ user_id bigint DEFAULT NULL COMMENT '识别用户主键快照',
+ face_sample_id varchar(64) DEFAULT NULL COMMENT '虹软人脸样本编号快照',
+ reference_image_url varchar(512) DEFAULT NULL COMMENT '识别时后台参考人脸来源地址快照,不作为展示地址',
+ similarity decimal(8, 6) DEFAULT NULL COMMENT '识别相似度快照,取值0至1',
+ similarity_threshold decimal(8, 6) DEFAULT NULL COMMENT '终端生效的相似度阈值快照,取值0至1',
+ hard_id bigint NOT NULL COMMENT '产生证据的设备主键',
+ captured_at datetime DEFAULT NULL COMMENT '终端实际采集时间',
+ sys_file_id bigint DEFAULT NULL COMMENT '文件中心sys_file记录主键',
+ storage_file_name varchar(255) DEFAULT NULL COMMENT '文件中心对象名',
+ image_url varchar(512) DEFAULT NULL COMMENT '订单证据图片访问地址',
+ image_hash char(64) DEFAULT NULL COMMENT '实际文件SHA-256哈希',
+ image_size bigint DEFAULT NULL COMMENT '实际文件大小,单位字节',
+ image_width int DEFAULT NULL COMMENT '实际图片宽度,单位像素',
+ image_height int DEFAULT NULL COMMENT '实际图片高度,单位像素',
+ status varchar(32) NOT NULL COMMENT '证据状态,PENDING、AVAILABLE或UNAVAILABLE',
+ failure_reason varchar(500) DEFAULT NULL COMMENT '永久缺失或复制失败摘要,不保存终端完整路径',
+ app_version varchar(64) DEFAULT NULL COMMENT '产生证据的终端应用版本',
+ create_by varchar(64) DEFAULT NULL COMMENT '创建人',
+ create_time datetime DEFAULT NULL COMMENT '创建时间',
+ update_by varchar(64) DEFAULT NULL COMMENT '修改人',
+ update_time datetime DEFAULT NULL COMMENT '修改时间',
+ del_flag char(1) DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
+ tenant_id bigint NOT NULL COMMENT '租户主键',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_hard_order_evidence_position
+ (tenant_id, order_id, evidence_type, evidence_role, sequence_no, del_flag),
+ KEY idx_hard_order_evidence_android_order
+ (tenant_id, hard_id, android_order_code, del_flag),
+ KEY idx_hard_order_evidence_status
+ (tenant_id, status, update_time, del_flag)
+) ENGINE = InnoDB COMMENT ='硬件订单识别证据图片';
+
-- ----------------------------
-- Table structure for hard_order_address
@@ -724,4 +768,3 @@ ALTER TABLE hard_xfj_log
ADD INDEX idx_start_time (start_time);
ALTER TABLE hard_xfj_log
ADD INDEX idx_end_time (end_time);
-
diff --git a/db/800pjcloudx_wallet.sql b/db/800pjcloudx_wallet.sql
index c4583ce2..462e9448 100644
--- a/db/800pjcloudx_wallet.sql
+++ b/db/800pjcloudx_wallet.sql
@@ -456,6 +456,7 @@ CREATE TABLE wallet_transaction
price int NOT NULL COMMENT '交易金额, 单位分',
balance int NOT NULL COMMENT '余额, 单位分',
tags varchar(512) DEFAULT NULL COMMENT '财务统计标签,多个wallet_transaction_tag字典值使用英文逗号分隔',
+ remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符',
create_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
index 7e8738ce..be6fef78 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
@@ -26,6 +26,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -67,6 +68,16 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/updateSkuStock")
R updateSkuStock(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+ /**
+ * 按稳定业务号原子应用或回滚每日菜谱基础 SPU 库存变更。
+ *
+ * @param updateStockReqDTO 商品增量、操作类型、业务号和操作前库存快照
+ * @return 操作成功时返回成功响应,重复请求按幂等成功处理
+ */
+ @PostMapping("/canteenSpu/inner/updateSkuStockIdempotent")
+ @NoToken
+ R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+
/**
* 根据ids查询菜品
@@ -75,6 +86,16 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/getSpuListByIds")
R> getSpuListByIds(@RequestBody List ids);
+ /**
+ * 供 Hardware 定时任务等无用户上下文的服务间调用批量查询菜品。
+ *
+ * @param ids 菜品 SPU 主键列表
+ * @return 当前租户下命中的菜品列表
+ */
+ @PostMapping("/canteenSpu/inner/getSpuListByIds")
+ @NoToken
+ R> getInnerSpuListByIds(@RequestBody List ids);
+
/**
* 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。
*
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
index ca682974..c09b7c08 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
@@ -25,9 +25,10 @@ public class CanteenSpuUpdateStockReqDTO {
private String bizId;
/**
- * DEDUCT 扣减,ROLLBACK 恢复;仅在 bizId 非空时参与幂等控制。
+ * 库存幂等操作类型。DEDUCT 保留给在线菜品旧协议,APPLY 按 incrCount 正负值应用基础菜品库存变更,
+ * ROLLBACK 使用同一业务号反向撤销 APPLY/DEDUCT;仅在 bizId 非空时参与幂等控制。
*/
- @Pattern(regexp = "DEDUCT|ROLLBACK", message = "库存操作类型错误")
+ @Pattern(regexp = "DEDUCT|APPLY|ROLLBACK", message = "库存操作类型错误")
private String operation;
/**
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
index 27a6466e..45bfeb97 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
@@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.common.security.annotation.Inner;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -68,6 +69,19 @@ public class CanteenSpuController {
private final CanteenMerchantScopeHelper merchantScopeHelper;
+ /**
+ * 每日菜谱订单按业务号应用或回滚基础菜品库存,调用方必须携带操作前库存快照。
+ *
+ * @param updateStockReqDTO 库存业务号、操作类型及商品增量
+ * @return 幂等库存操作结果
+ */
+ @Inner
+ @PostMapping("/inner/updateSkuStockIdempotent")
+ @Transactional(rollbackFor = Exception.class)
+ public R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO) {
+ return canteenSpuService.updateSkuStockIdempotent(updateStockReqDTO);
+ }
+
/**
* 分页查询
*
@@ -369,7 +383,30 @@ public class CanteenSpuController {
@Operation(summary = "通过ids查询" , description = "通过ids查询" )
@PostMapping("/ad/getSpuListByIds" )
public R> getByIds(@RequestBody List ids) {
- List list = canteenSpuService.list(new LambdaQueryWrapper().in(CanteenSpuEntity::getId, ids));
+ return querySpuByIds(ids);
+ }
+
+ /**
+ * 为无用户令牌的 Hardware 服务间调用提供菜品批量查询,保持原管理端接口的鉴权行为不变。
+ *
+ * @param ids 菜品 SPU 主键列表
+ * @return 当前租户下命中的菜品列表
+ */
+ @Inner
+ @PostMapping("/inner/getSpuListByIds")
+ public R> getInnerByIds(@RequestBody List ids) {
+ return querySpuByIds(ids);
+ }
+
+ /**
+ * 统一执行菜品 SPU 批量查询,避免管理端和内部端点产生不同的数据筛选口径。
+ *
+ * @param ids 菜品 SPU 主键列表
+ * @return 当前租户下命中的菜品列表
+ */
+ private R> querySpuByIds(List ids) {
+ List list = canteenSpuService.list(new LambdaQueryWrapper()
+ .in(CanteenSpuEntity::getId, ids));
return R.ok(list);
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
index 227f5f9a..706a6b64 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
@@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper {
* 更新商品 SPU 库存
*
* @param id 商品 SPU 编号
- * @param incrCount 增加的库存数量
+ * @param incrCount 库存增量,正数恢复库存、负数扣减库存
*/
default int updateStock(Long id, Integer incrCount) {
// 拼接 SQL
- if (incrCount == 0) {
+ if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE) {
return 0;
}
String sql;
@@ -29,7 +29,36 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper {
// 执行更新
LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper()
.setSql(sql)
- .eq(CanteenSpuEntity::getId, id);
+ .eq(CanteenSpuEntity::getId, id)
+ // 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。
+ .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
-}
\ No newline at end of file
+
+ /**
+ * 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。
+ *
+ * @param id 商品 SPU 编号
+ * @param incrCount 库存增量,负数扣减、正数恢复
+ * @param expectedStock 本次业务读取到的操作前库存
+ * @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0
+ */
+ default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) {
+ if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE
+ || expectedStock == null || expectedStock < 0) {
+ return 0;
+ }
+ String sql;
+ if (incrCount > 0) {
+ sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount;
+ } else {
+ sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount);
+ }
+ LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper()
+ .setSql(sql)
+ .eq(CanteenSpuEntity::getId, id)
+ .eq(CanteenSpuEntity::getStock, expectedStock)
+ .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
+ return update(null, updateWrapper);
+ }
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
index 6164cd82..de78463e 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
@@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService {
*/
R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+ /**
+ * 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。
+ *
+ * @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型
+ * @return 成功、幂等命中或库存校验失败的统一响应
+ */
+ R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+
/**
* 根据菜品分类id获取菜品信息
@@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService {
* 批量加载套餐组成(App/每日菜谱展示)
*/
Map> loadComboItemVos(List comboSpuIds);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
index 20913428..008f3853 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
+import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -26,10 +27,14 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.CollectionUtils;
+import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@@ -44,6 +49,17 @@ import java.util.stream.Collectors;
@AllArgsConstructor
public class CanteenSpuServiceImpl extends ServiceImpl implements CanteenSpuService {
+ /** 每日菜谱基础 SPU 库存幂等状态键前缀,租户隔离由 Redis 基础设施统一处理。 */
+ private static final String ORDER_STOCK_BIZ_KEY_PREFIX = "canteen:spu:order-stock:";
+
+ /** 库存幂等状态最长保留时间,覆盖远程超时重试和延迟请求后自动释放 Redis 空间。 */
+ private static final Duration ORDER_STOCK_BIZ_STATE_TTL = Duration.ofDays(7);
+
+ /** 已确认 APPLY 成功且随后完成反向补偿;允许业务任务使用同一幂等号显式重试。 */
+ private static final String ORDER_STOCK_BIZ_STATE_COMPENSATED = "COMPENSATED";
+
+ /** 库存业务状态存储,用于识别跨服务超时后的重复应用和补偿请求。 */
+ private final StringRedisTemplate stringRedisTemplate;
private final CanteenDailyMenuMapper canteenDailyMenuMapper;
private final CanteenCategoryMapper canteenCategoryMapper;
@@ -58,29 +74,152 @@ public class CanteenSpuServiceImpl extends ServiceImpl {
- // 查询当前库存
+ if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) {
+ return R.failed("库存商品不能为空");
+ }
+ // 必须先校验完整批次再更新,后一个商品失败时由本地事务保证前面的商品不会单独提交。
+ for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) {
+ if (item == null || item.getId() == null || item.getIncrCount() == null
+ || item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE) {
+ return R.failed("库存商品参数错误");
+ }
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
if (spu == null) {
- R.failed(spu.getName() + "商品不存在");
+ return R.failed("商品不存在");
}
- // 检查库存是否足够
- if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > spu.getStock()) {
- R.failed(spu.getName() + "库存不足,无法完成操作");
+ if (item.getIncrCount() == null || item.getIncrCount() == 0) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存变化数量无效");
}
- int i = baseMapper.updateStock(item.getId(), item.getIncrCount());
-// if (item.getIncrCount() <= 0) {
-// if (i == 0) {
-// R.failed("商品库存不足");
-// }
-// }
- });
+ if (item.getIncrCount() < 0 && (spu.getStock() == null || Math.abs(item.getIncrCount()) > spu.getStock())) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作");
+ }
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) {
+ if (baseMapper.updateStock(item.getId(), item.getIncrCount()) == 0) {
+ throw new RuntimeException("库存不足,无法完成操作");
+ }
+ }
return R.ok();
}
+ /**
+ * 对同一业务号串行应用或反向补偿基础 SPU 库存。APPLY 同时支持下单负增量和退款正增量,
+ * ROLLBACK 必须携带原 APPLY 的增量与操作前快照,避免远程响应超时时重复扣减或重复恢复。
+ *
+ * @param dto 商品增量、操作前库存快照、稳定业务号和操作类型
+ * @return 成功、幂等命中或库存状态冲突的统一响应
+ */
+ @Override
+ @Lock4j(keys = {"#dto.bizId"})
+ @Transactional(rollbackFor = Exception.class)
+ public R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto) {
+ String operation = dto == null ? null : StrUtil.trim(dto.getOperation());
+ boolean applyOperation = "APPLY".equals(operation);
+ boolean rollbackOperation = "ROLLBACK".equals(operation);
+ if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
+ || (!applyOperation && !rollbackOperation)
+ || dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null
+ || item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
+ return R.failed("库存业务参数错误");
+ }
+ if (dto.getItems().stream().map(CanteenSpuUpdateStockReqDTO.Item::getId).distinct().count() != dto.getItems().size()) {
+ return R.failed("库存商品不能重复");
+ }
+
+ String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId();
+ String state = stringRedisTemplate.opsForValue().get(stateKey);
+ // ROLLED_BACK 表示补偿先于 APPLY 或无法确认 APPLY 成功,必须阻断延迟请求;
+ // COMPENSATED 表示已确认 APPLY 后又完成反向补偿,任务重试可以使用新库存快照重新执行。
+ if (applyOperation && ("APPLIED".equals(state) || "ROLLED_BACK".equals(state))) {
+ return R.ok();
+ }
+ if (rollbackOperation && ("ROLLED_BACK".equals(state)
+ || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) {
+ return R.ok();
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ if (spu == null || spu.getStock() == null) {
+ return R.failed("商品不存在或库存异常");
+ }
+ int expectedStock = item.getBeforeStock();
+ if (expectedStock < 0) {
+ return R.failed("库存快照异常");
+ }
+ int targetStock = Math.addExact(expectedStock, item.getIncrCount());
+ if (targetStock < 0) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作");
+ }
+ if (applyOperation) {
+ // 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。
+ // 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。
+ if (spu.getStock() != expectedStock) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存已变化,请重新操作");
+ }
+ } else if ("APPLIED".equals(state)) {
+ int rollbackDelta = -item.getIncrCount();
+ if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法自动补偿");
+ }
+ }
+ }
+
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ int expectedStock = item.getBeforeStock();
+ int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
+ if (rollbackOperation && "APPLIED".equals(state)) {
+ // 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。
+ // 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。
+ if (baseMapper.updateStock(item.getId(), delta) == 0) {
+ throw new RuntimeException("库存补偿失败,请重试");
+ }
+ continue;
+ }
+ // 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
+ if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) {
+ throw new RuntimeException("库存已变化,请重新操作");
+ }
+ }
+ String targetState = applyOperation ? "APPLIED"
+ : "APPLIED".equals(state) ? ORDER_STOCK_BIZ_STATE_COMPENSATED : "ROLLED_BACK";
+ updateStockBizState(stateKey, state, targetState);
+ return R.ok();
+ }
+
+ /**
+ * 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。
+ *
+ * @param stateKey 当前租户下的库存业务状态键
+ * @param previousState 调用前状态,空值表示此前没有记录
+ * @param targetState 本次库存操作完成后的状态
+ */
+ private void updateStockBizState(String stateKey, String previousState, String targetState) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new RuntimeException("库存事务状态异常,请稍后重试");
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 数据库事务回滚后同步撤销本次 Redis 状态,后续请求才能根据实际库存重新判断。
+ *
+ * @param status Spring 本地事务完成状态
+ */
+ @Override
+ public void afterCompletion(int status) {
+ if (status != TransactionSynchronization.STATUS_ROLLED_BACK) {
+ return;
+ }
+ if (previousState == null) {
+ stringRedisTemplate.delete(stateKey);
+ } else {
+ stringRedisTemplate.opsForValue().set(stateKey, previousState, ORDER_STOCK_BIZ_STATE_TTL);
+ }
+ }
+ });
+ stringRedisTemplate.opsForValue().set(stateKey, targetState, ORDER_STOCK_BIZ_STATE_TTL);
+ }
+
@Override
public R getCanteenSpuList(String categoryId) {
List canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper()
@@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl valueOperations;
+
+ /**
+ * 为每个用例建立独立的事务同步上下文,模拟 Hardware 远程调用进入食堂本地事务后的状态写入。
+ */
+ @BeforeEach
+ void setUp() {
+ spuMapper = mock(CanteenSpuMapper.class);
+ StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
+ valueOperations = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+ CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null,
+ mock(CanteenMerchantScopeHelper.class));
+ ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
+ service = target;
+ TransactionSynchronizationManager.initSynchronization();
+ }
+
+ /** 清理线程级事务同步状态,避免测试间相互污染。 */
+ @AfterEach
+ void tearDown() {
+ TransactionSynchronizationManager.clearSynchronization();
+ }
+
+ /**
+ * 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
+ */
+ @Test
+ void compensatedRefundInventoryShouldAllowRetry() {
+ CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
+ when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
+ .thenReturn("COMPENSATED");
+ when(spuMapper.selectById(1L)).thenReturn(spu(10));
+ when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1);
+
+ assertTrue(service.updateSkuStockIdempotent(dto).isOk());
+
+ verify(spuMapper).updateStockIfMatch(1L, 2, 10);
+ verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
+ Duration.ofDays(7));
+ }
+
+ /**
+ * 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
+ */
+ @Test
+ void confirmedApplyRollbackShouldBecomeCompensated() {
+ CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10);
+ when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
+ .thenReturn("APPLIED");
+ when(spuMapper.selectById(1L)).thenReturn(spu(12));
+ when(spuMapper.updateStock(1L, -2)).thenReturn(1);
+
+ assertTrue(service.updateSkuStockIdempotent(dto).isOk());
+
+ verify(spuMapper).updateStock(1L, -2);
+ verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "COMPENSATED",
+ Duration.ofDays(7));
+ }
+
+ /**
+ * 构造单个基础 SPU 的幂等库存请求。
+ *
+ * @param operation APPLY 或 ROLLBACK
+ * @param increment 原 APPLY 使用的库存增量
+ * @param beforeStock 当前业务尝试的操作前库存快照
+ * @return 可直接调用库存服务的请求
+ */
+ private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) {
+ CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
+ item.setId(1L);
+ item.setIncrCount(increment);
+ item.setBeforeStock(beforeStock);
+ CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
+ dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
+ dto.setOperation(operation);
+ dto.setItems(List.of(item));
+ return dto;
+ }
+
+ /**
+ * 构造当前库存快照。
+ *
+ * @param stock 当前基础 SPU 库存
+ * @return 仅包含本用例所需字段的实体
+ */
+ private CanteenSpuEntity spu(int stock) {
+ CanteenSpuEntity entity = new CanteenSpuEntity();
+ entity.setId(1L);
+ entity.setStock(stock);
+ entity.setName("测试菜品");
+ return entity;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java
new file mode 100644
index 00000000..673352bb
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardCanteenStatisticsLevelEnum.java
@@ -0,0 +1,44 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 食堂销售统计展示层级协议。
+ *
+ * 该编码仅用于接口返回和管理端组织多维统计行,不对应数据库字段;调用方不得将名称作为稳定判断条件。
+ */
+@Getter
+@AllArgsConstructor
+public enum HardCanteenStatisticsLevelEnum {
+
+ /** 按自然日汇总全部餐段和当前来源筛选范围。 */
+ DATE_TOTAL("DATE_TOTAL", "当日合计"),
+
+ /** 按自然日和餐段汇总当前来源筛选范围。 */
+ HOUR_TOTAL("HOUR_TOTAL", "餐段合计"),
+
+ /** 按自然日拆分来源,供当日合计行横向比较。 */
+ DATE_SOURCE("DATE_SOURCE", "当日来源"),
+
+ /** 按自然日、餐段拆分来源,供餐段合计行横向比较。 */
+ HOUR_SOURCE("HOUR_SOURCE", "餐段来源");
+
+ /** 前后端接口使用的稳定层级编码,不写入数据库。 */
+ private final String code;
+
+ /** 层级的中文展示名称,仅用于界面说明。 */
+ private final String description;
+
+ /**
+ * 根据接口层级编码查找枚举。
+ *
+ * @param code 管理端统计接口返回的层级编码;为空或未知时不匹配
+ * @return 匹配的层级枚举,未匹配时返回 {@code null}
+ */
+ public static HardCanteenStatisticsLevelEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(value -> value.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java
new file mode 100644
index 00000000..64e0846f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceRoleEnum.java
@@ -0,0 +1,58 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 订单证据图片角色,编码用于唯一位置约束和跨端展示。
+ */
+@Getter
+public enum HardOrderEvidenceRoleEnum {
+
+ /**
+ * 人脸库中被命中的后台参考人脸,由服务端从受信文件中心复制为订单快照。
+ */
+ FACE_REFERENCE("FACE_REFERENCE", "后台参考人脸"),
+
+ /**
+ * 终端实际通过比对的现场人脸裁剪图,由终端异步上传。
+ */
+ FACE_LIVE("FACE_LIVE", "现场识别人脸"),
+
+ /**
+ * 菜谱识别的现场餐盘图片预留角色,当前版本不创建该类证据。
+ */
+ MENU_LIVE("MENU_LIVE", "餐盘现场图");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据图片角色。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceRoleEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析图片角色,未知编码返回 {@code null}。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应角色;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceRoleEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java
new file mode 100644
index 00000000..f74b4c13
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceStatusEnum.java
@@ -0,0 +1,58 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 服务端订单证据状态,表达证据是否仍待补传、已经可查看或已永久缺失。
+ */
+@Getter
+public enum HardOrderEvidenceStatusEnum {
+
+ /**
+ * 订单已记录识别元数据,但图片仍等待终端上传或服务端复制。
+ */
+ PENDING("PENDING", "待补传"),
+
+ /**
+ * 文件中心已经保存订单专属图片并可在订单详情中查看。
+ */
+ AVAILABLE("AVAILABLE", "可查看"),
+
+ /**
+ * 本地文件过期、损坏或参考图片变化,已确认无法继续补齐。
+ */
+ UNAVAILABLE("UNAVAILABLE", "永久缺失");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据状态。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceStatusEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析证据状态,未知编码返回 {@code null}。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应状态;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceStatusEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java
new file mode 100644
index 00000000..53101e28
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardOrderEvidenceTypeEnum.java
@@ -0,0 +1,53 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+
+/**
+ * 订单证据业务类型,编码同时用于数据库、终端 SQLite 和跨端接口,禁止随展示文案调整。
+ */
+@Getter
+public enum HardOrderEvidenceTypeEnum {
+
+ /**
+ * 人脸识别证据,当前版本保存后台参考人脸和现场识别人脸。
+ */
+ FACE_RECOGNITION("FACE_RECOGNITION", "人脸识别"),
+
+ /**
+ * 菜谱识别证据预留值,当前版本不执行菜谱识别或图片采集。
+ */
+ MENU_RECOGNITION("MENU_RECOGNITION", "菜谱识别");
+
+ /**
+ * 数据库和接口使用的稳定编码。
+ */
+ private final String code;
+
+ /**
+ * PC 订单详情使用的中文名称。
+ */
+ private final String description;
+
+ /**
+ * 构造订单证据业务类型。
+ *
+ * @param code 数据库与跨端协议编码
+ * @param description 中文展示名称
+ */
+ HardOrderEvidenceTypeEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 按协议编码解析证据类型,未知编码返回 {@code null} 供调用方拒绝非法请求。
+ *
+ * @param code 数据库或接口编码
+ * @return 对应类型;未知时返回 {@code null}
+ */
+ public static HardOrderEvidenceTypeEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java
new file mode 100644
index 00000000..293faed0
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/order/HardSaleSourceEnum.java
@@ -0,0 +1,57 @@
+package com.bsd.pjcloud.hard.api.constant.order;
+
+import lombok.Getter;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 食堂销售统计来源。
+ *
+ * 编码只用于统计查询和管理端展示,不写入订单表;来源由订单类型、预约关联、订单状态以及对应业务时间计算得到。
+ */
+@Getter
+public enum HardSaleSourceEnum {
+
+ /** 订单类型 1 的线下购买,统计时间取订单支付时间。 */
+ OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"),
+
+ /** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */
+ RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销");
+
+ /** 统计接口和前端标签页使用的来源编码。 */
+ private final String code;
+
+ /** 来源中文名称。 */
+ private final String description;
+
+ /**
+ * 创建稳定的统计来源协议项;编码只用于接口筛选,不对应数据库持久化值。
+ *
+ * @param code 前后端共同使用的来源编码
+ * @param description 管理端与导出使用的中文名称
+ */
+ HardSaleSourceEnum(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ /**
+ * 返回全部可筛选来源编码。
+ *
+ * @return 来源编码列表,顺序与管理端标签页一致
+ */
+ public static List getCodes() {
+ return Arrays.stream(values()).map(HardSaleSourceEnum::getCode).toList();
+ }
+
+ /**
+ * 按统计来源编码解析枚举。
+ *
+ * @param code 统计接口传入的来源编码
+ * @return 匹配的来源;为空或未知编码返回 {@code null}
+ */
+ public static HardSaleSourceEnum valueOfCode(String code) {
+ return Arrays.stream(values()).filter(item -> item.code.equals(code)).findFirst().orElse(null);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
index 6e1edc41..d90891ce 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAfterSaleOperateTypeEnum.java
@@ -33,6 +33,9 @@ public enum HardAfterSaleOperateTypeEnum {
/** 系统根据订单快照和商户配置免审完成退款。 */
SYSTEM_AUTO_REFUND("31", "系统自动退款"),
+ /** 系统按每日菜谱预约过期快照创建整单退款。 */
+ SYSTEM_EXPIRED_RESERVE_REFUND("32", "系统过期预约退款"),
+
/** 会员取消尚未审核的退款申请。 */
MEMBER_CANCEL("40", "会员取消退款"),
;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java
new file mode 100644
index 00000000..fa11785b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTaskTypeEnum.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.hard.api.constant.refund;
+
+import lombok.Getter;
+
+/**
+ * 自动退款任务场景;编码持久化到任务表并用于区分不同的配置和售后创建规则。
+ */
+@Getter
+public enum HardAutoRefundTaskTypeEnum {
+
+ /** 用户申请免审退款后创建的技术自动退款任务。 */
+ AFTER_SALE("10", "售后申请自动退款"),
+
+ /** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */
+ EXPIRED_RESERVE("20", "过期预约自动退款");
+
+ /** 数据库存储的稳定任务场景编码。 */
+ private final String type;
+
+ /** 管理端展示名称。 */
+ private final String description;
+
+ /**
+ * 构造任务场景协议项。
+ *
+ * @param type 数据库存储编码
+ * @param description 展示名称
+ */
+ HardAutoRefundTaskTypeEnum(String type, String description) {
+ this.type = type;
+ this.description = description;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
index 31e6da14..b28ea530 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/constant/refund/HardAutoRefundTriggerTypeEnum.java
@@ -21,7 +21,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端技术人员对耗尽任务执行的一次额外尝试。
*/
- MANUAL("30", "人工技术重试", false);
+ MANUAL("30", "人工技术重试", false),
+
+ /**
+ * 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
+ */
+ EXPIRED_MANUAL("40", "后台逐单过期退款", false);
/**
* 数据库存储的稳定触发编码。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
index f3169e9e..f0be9f4d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardCreateOrderDto.java
@@ -4,7 +4,9 @@ import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
@@ -75,4 +77,17 @@ public class HardCreateOrderDto {
@Schema(description = "离线支付是否成功")
private Boolean offlinePaySuccess;
+
+ /**
+ * 当前终端应用版本,仅用于订单证据问题追溯,不参与订单计价和支付。
+ */
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+
+ /**
+ * 人脸支付成功后需要挂接的证据元数据;旧终端不传时保持订单创建协议兼容。
+ */
+ @Schema(description = "订单识别证据元数据")
+ @Valid
+ private List evidenceMetadataList;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
index a4594a3a..5808071b 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardAutoRefundTaskPageDto.java
@@ -43,6 +43,10 @@ public class HardAutoRefundTaskPageDto {
@Schema(description = "任务状态")
private String status;
+ /** 任务场景编码;候选列表固定筛选过期预约任务。 */
+ @Schema(description = "任务场景")
+ private String taskType;
+
/**
* 任务创建时间闭区间,数组长度应为 2。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
index e8384b42..d16c4ae4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
@@ -1,6 +1,9 @@
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.BaseIsIncludeTreeInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -17,7 +20,7 @@ import java.util.List;
*/
@Data
@Schema(description = "食堂用户预约查询条件")
-public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface {
+public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */
@NotNull(message = "用餐日期范围不能为空")
@@ -29,6 +32,22 @@ public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface {
@Schema(description = "用户姓名、登录账号或手机号")
private String nameOrPhone;
+ /** 用户身份协议编码集合;为空时不限制身份类型,编码由 UPMS 身份表和前端字典共同约束。 */
+ @Schema(description = "用户身份类型集合")
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;字段匹配语义沿用 UPMS 用户查询。 */
+ @Schema(description = "学生身份扩展查询条件")
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;老师学号为空时不限制老师资料。 */
+ @Schema(description = "老师身份扩展查询条件")
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态筛选:1 表示存在未删除人脸,0 表示不存在,空值表示不限制。 */
+ @Schema(description = "是否已录入人脸:1已录入,0未录入")
+ private String hasFace;
+
/** 部门树选中的部门主键集合;为空时使用当前操作者完整的数据权限范围。 */
@Schema(description = "部门主键集合")
private List deptIds;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
index 7c1ea2a2..d5699377 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCarDto.java
@@ -26,11 +26,23 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
private Long merchantId;
+ /** 订单类型协议编码;详情查询传预约编码时仅统计预约订单,其他场景为空表示不限定。 */
+ private String orderType;
+
+ /** 是否只保留关联预约规则或预约记录的订单;为空或 false 时不增加预约关联限制。 */
+ private Boolean apptIdOrApptRuleIdNotNull;
+
private List mchIds;
/** 预计使用日期范围。 */
private LocalDateTime[] apptUseDateTimeList;
+ /** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */
+ private LocalDateTime[] statisticsDateTimeList;
+
+ /** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */
+ private List saleSourceCodes;
+
/**
* 前端树搜索
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
index e7ba33ec..a25062a1 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java
@@ -38,11 +38,29 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private LocalDateTime[] createTimeList;
- /** 更新时间范围采用左闭右开区间,供按订单实际状态变更时间查询。 */
+ /** 更新时间范围采用左闭右开区间,供兼容旧调用方按订单状态变更时间查询。 */
private LocalDateTime[] updateTimeList;
+ /** 订单核销时间闭区间,仅按订单实际核销完成时间查询。 */
+ private LocalDateTime[] verificationTimeList;
+
private LocalDateTime[] payTimeList;
+ /**
+ * 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。
+ */
+ private LocalDateTime[] statisticsDateTimeList;
+
+ /**
+ * 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。
+ */
+ private List saleSourceCodes;
+
+ /**
+ * 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。
+ */
+ private String productName;
+
private LocalDateTime[] refundTimeList;
/** 预计使用日期范围。 */
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java
new file mode 100644
index 00000000..585bbd7b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceMetadataDto.java
@@ -0,0 +1,130 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.DecimalMax;
+import jakarta.validation.constraints.DecimalMin;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 订单创建和证据补传共用的识别元数据,不包含图片字节和服务端文件信息。
+ */
+@Data
+@Schema(description = "订单识别证据元数据")
+public class HardOrderEvidenceMetadataDto {
+
+ /**
+ * 证据业务类型,使用 HardOrderEvidenceTypeEnum 的稳定编码。
+ */
+ @NotBlank(message = "证据类型不能为空")
+ @Size(max = 32, message = "证据类型不能超过32个字符")
+ @Schema(description = "证据类型")
+ private String evidenceType;
+
+ /**
+ * 图片角色,使用 HardOrderEvidenceRoleEnum 的稳定编码。
+ */
+ @NotBlank(message = "证据图片角色不能为空")
+ @Size(max = 32, message = "证据图片角色不能超过32个字符")
+ @Schema(description = "证据图片角色")
+ private String evidenceRole;
+
+ /**
+ * 同类型同角色下的图片序号,从0开始;人脸证据固定为0,菜谱识别可递增。
+ */
+ @NotNull(message = "证据图片序号不能为空")
+ @Min(value = 0, message = "证据图片序号不能小于0")
+ @Max(value = 99, message = "证据图片序号不能大于99")
+ @Schema(description = "证据图片序号")
+ private Integer sequenceNo;
+
+ /**
+ * 识别用户主键;未识别到平台用户时为空,不能由终端使用其他用户替代。
+ */
+ @Schema(description = "识别用户主键")
+ private Long userId;
+
+ /**
+ * 虹软搜索样本编号;仅人脸识别角色使用,服务端据此校验后台参考人脸。
+ */
+ @Size(max = 64, message = "人脸样本编号不能超过64个字符")
+ @Schema(description = "人脸样本编号")
+ private String faceSampleId;
+
+ /**
+ * 识别时终端人脸库记录的参考图片地址,仅作为服务端复制参考图的线索,不直接作为可访问地址返回。
+ */
+ @Size(max = 512, message = "参考人脸地址不能超过512个字符")
+ @Schema(description = "参考人脸地址")
+ private String referenceImageUrl;
+
+ /**
+ * 识别时终端人脸快照对应的后台更新时间;旧终端允许为空,新终端用于识别同地址内容替换。
+ */
+ @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ @Schema(description = "参考人脸来源版本")
+ private LocalDateTime referenceImageVersion;
+
+ /**
+ * 识别时实际相似度,取值0到1;为空表示当前证据类型不使用相似度。
+ */
+ @DecimalMin(value = "0", message = "相似度不能小于0")
+ @DecimalMax(value = "1", message = "相似度不能大于1")
+ @Schema(description = "识别相似度")
+ private BigDecimal similarity;
+
+ /**
+ * 识别时终端生效的相似度阈值快照,取值0到1。
+ */
+ @DecimalMin(value = "0", message = "相似度阈值不能小于0")
+ @DecimalMax(value = "1", message = "相似度阈值不能大于1")
+ @Schema(description = "相似度阈值快照")
+ private BigDecimal similarityThreshold;
+
+ /**
+ * 终端实际完成识别的本地时间;为空时服务端仅保留订单创建时间。
+ */
+ @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]", timezone = "Asia/Shanghai")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ @Schema(description = "终端采集时间")
+ private LocalDateTime capturedAt;
+
+ /**
+ * 终端按原始证据文件计算的 SHA-256;图片尚未生成或永久缺失时允许为空。
+ */
+ @Pattern(regexp = "(?i)^[0-9a-f]{64}$", message = "图片哈希必须为64位SHA-256")
+ @Schema(description = "图片SHA-256")
+ private String imageHash;
+
+ /**
+ * 终端证据文件大小,单位字节;参考图无法读取或文件尚未生成时允许为空。
+ */
+ @Min(value = 0, message = "图片大小不能小于0")
+ @Schema(description = "图片大小,单位字节")
+ private Long imageSize;
+
+ /**
+ * 终端解码后的图片宽度,单位像素。
+ */
+ @Min(value = 1, message = "图片宽度必须大于0")
+ @Schema(description = "图片宽度,单位像素")
+ private Integer imageWidth;
+
+ /**
+ * 终端解码后的图片高度,单位像素。
+ */
+ @Min(value = 1, message = "图片高度必须大于0")
+ @Schema(description = "图片高度,单位像素")
+ private Integer imageHeight;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java
new file mode 100644
index 00000000..510a7e4e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceOrderRefDto.java
@@ -0,0 +1,45 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import cn.hutool.core.util.StrUtil;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.Positive;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 终端订单识别证据的订单定位参数。
+ *
+ * 消费机现场下单继续使用终端订单号对账;预约核销订单不是当前终端创建的,核销成功后改用
+ * 服务端订单 ID 定位。两种方式都必须结合请求头中的设备 ID 校验订单归属。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单识别证据的订单定位参数")
+public abstract class HardOrderEvidenceOrderRefDto extends HardOrderEvidenceMetadataDto {
+
+ /**
+ * 终端本地稳定订单号,用于消费机现场下单后对账;预约核销证据允许为空。
+ */
+ @Size(max = 128, message = "终端订单号不能超过128个字符")
+ @Schema(description = "终端订单号,与服务端订单ID二选一")
+ private String androidOrderCode;
+
+ /**
+ * 已存在的服务端订单主键,仅用于当前设备已经成功核销的预约订单;现场下单证据允许为空。
+ */
+ @Positive(message = "服务端订单ID必须大于0")
+ @Schema(description = "服务端订单ID,与终端订单号二选一")
+ private Long orderId;
+
+ /**
+ * 校验至少提供一种订单定位信息,防止无条件查询或写入订单证据。
+ *
+ * @return 提供终端订单号或服务端订单 ID 时返回 {@code true}
+ */
+ @AssertTrue(message = "终端订单号和服务端订单ID不能同时为空")
+ public boolean isOrderReferencePresent() {
+ return StrUtil.isNotBlank(androidOrderCode) || orderId != null;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java
new file mode 100644
index 00000000..343b0978
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceSubmitDto.java
@@ -0,0 +1,38 @@
+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 HardOrderEvidenceSubmitDto extends HardOrderEvidenceOrderRefDto {
+
+ /**
+ * 终端请求写入的目标状态,仅允许 PENDING 或 UNAVAILABLE;现场图片上传成功由服务端改为 AVAILABLE。
+ */
+ @NotBlank(message = "证据状态不能为空")
+ @Size(max = 32, message = "证据状态不能超过32个字符")
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * 永久缺失原因;状态为 UNAVAILABLE 时必填,禁止包含本地完整敏感路径。
+ */
+ @Size(max = 500, message = "失败原因不能超过500个字符")
+ @Schema(description = "永久缺失原因")
+ private String failureReason;
+
+ /**
+ * 产生识别证据的终端应用版本,用于问题追溯。
+ */
+ @Size(max = 64, message = "应用版本不能超过64个字符")
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java
new file mode 100644
index 00000000..4b8f3363
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderEvidenceUploadDto.java
@@ -0,0 +1,22 @@
+package com.bsd.pjcloud.hard.api.dto.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 终端上传订单现场证据图片时随 multipart 一并提交的元数据。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单现场证据上传请求")
+public class HardOrderEvidenceUploadDto extends HardOrderEvidenceOrderRefDto {
+
+ /**
+ * 产生识别证据的终端应用版本,用于问题追溯。
+ */
+ @Size(max = 64, message = "应用版本不能超过64个字符")
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
index 21024beb..ad5e6842 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
@@ -18,7 +18,8 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
- * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。
+ * Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定,
+ * 失败后由该记录持续驱动恢复。
*/
@Data
@Builder
@@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model {
private Long id;
/**
- * 售后单 ID,是任务唯一业务键和钱包退款幂等键。
+ * 售后单 ID;普通售后任务创建时必填,过期预约任务创建系统售后单前为空,完成后作为钱包退款幂等键。
*/
@Schema(description = "售后单ID")
private Long afterSaleId;
+ /** 任务场景编码;10为用户免审售后,20为过期预约退款。 */
+ @Schema(description = "任务场景")
+ private String taskType;
+
/**
* 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。
*/
@@ -73,6 +78,14 @@ public class HardAutoRefundTaskEntity extends Model {
@Schema(description = "免审资格截止时间")
private LocalDateTime eligibilityDeadline;
+ /** 过期预约任务的订单快照触发时间;旧售后任务为空。 */
+ @Schema(description = "过期退款触发时间")
+ private LocalDateTime triggerTime;
+
+ /** 任务创建时快照的退库存开关,1恢复、0不恢复。 */
+ @Schema(description = "退库存快照")
+ private String restoreInventory;
+
/**
* 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
index b866cf6c..d124723d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
@@ -159,6 +159,22 @@ public class HardOrderEntity extends Model {
@Schema(description = "自动退款申请截止时间")
private LocalDateTime autoRefundDeadline;
+ /** 每日菜谱过期退款配置 ID 快照;仅支付成功且下单时开关开启的新订单写入。 */
+ @Schema(description = "过期预约退款配置ID快照")
+ private Long expiredRefundConfigId;
+
+ /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
+ @Schema(description = "过期预约餐段实际结束时间快照")
+ private LocalDateTime expiredRefundUsageEndTime;
+
+ /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
+ @Schema(description = "过期预约退款触发时间")
+ private LocalDateTime expiredRefundDeadline;
+
+ /** 过期退款是否恢复库存的订单快照,1恢复、0不恢复。 */
+ @Schema(description = "过期预约退款退库存快照")
+ private String expiredRefundRestoreInventory;
+
/**
* 订单设备类型
*/
@@ -219,6 +235,12 @@ public class HardOrderEntity extends Model {
@Schema(description = "退款状态")
private String refundStatus;
+ /**
+ * 订单核销完成时间;仅在订单实际完成核销时写入,避免被后续退款等订单更新覆盖。
+ */
+ @Schema(description = "订单核销时间")
+ private LocalDateTime verificationTime;
+
/**
* 订单取消时间(退款订单)
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java
new file mode 100644
index 00000000..e7e6c59b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEvidenceImageEntity.java
@@ -0,0 +1,208 @@
+package com.bsd.pjcloud.hard.api.entity.order;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 订单识别证据图片,从属于 hard_order 并长期保存争议处理所需的文件快照和识别元数据。
+ */
+@Data
+@TenantTable
+@TableName("hard_order_evidence_image")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单识别证据图片")
+public class HardOrderEvidenceImageEntity extends Model {
+
+ /**
+ * 证据记录自增主键。
+ */
+ @TableId(type = IdType.AUTO)
+ @Schema(description = "证据记录主键")
+ private Long id;
+
+ /**
+ * 服务端订单主键,来源于 hard_order.id。
+ */
+ @Schema(description = "订单主键")
+ private Long orderId;
+
+ /**
+ * 证据订单引用快照;现场订单取终端订单号,预约等服务端订单取订单号或服务端主键内部引用。
+ */
+ @Schema(description = "证据订单引用快照")
+ private String androidOrderCode;
+
+ /**
+ * 证据业务类型,保存 HardOrderEvidenceTypeEnum.code。
+ */
+ @Schema(description = "证据类型")
+ private String evidenceType;
+
+ /**
+ * 图片角色,保存 HardOrderEvidenceRoleEnum.code。
+ */
+ @Schema(description = "证据图片角色")
+ private String evidenceRole;
+
+ /**
+ * 同一订单、类型和角色下的图片序号,从0开始。
+ */
+ @Schema(description = "证据图片序号")
+ private Integer sequenceNo;
+
+ /**
+ * 识别用户主键快照;无法识别到平台用户时允许为空。
+ */
+ @Schema(description = "识别用户主键")
+ private Long userId;
+
+ /**
+ * 虹软人脸样本编号快照,来源于 sys_user_face.auto_code。
+ */
+ @Schema(description = "人脸样本编号")
+ private String faceSampleId;
+
+ /**
+ * 识别时终端命中的后台参考图片地址快照,仅用于与 UPMS 权威记录比对,不直接作为展示地址。
+ */
+ @Schema(description = "参考人脸来源地址快照")
+ private String referenceImageUrl;
+
+ /**
+ * 终端识别相似度快照,取值0到1。
+ */
+ @Schema(description = "识别相似度")
+ private BigDecimal similarity;
+
+ /**
+ * 终端识别相似度阈值快照,取值0到1。
+ */
+ @Schema(description = "识别相似度阈值")
+ private BigDecimal similarityThreshold;
+
+ /**
+ * 产生证据的服务端设备主键,来源于 hard_order.hard_id。
+ */
+ @Schema(description = "设备主键")
+ private Long hardId;
+
+ /**
+ * 终端实际完成识别的本地时间,用于与订单时间交叉核验。
+ */
+ @Schema(description = "终端采集时间")
+ private LocalDateTime capturedAt;
+
+ /**
+ * 文件中心 sys_file 主键;状态非 AVAILABLE 时允许为空。
+ */
+ @Schema(description = "文件中心记录主键")
+ private Long sysFileId;
+
+ /**
+ * 文件中心对象名,仅供服务端读取或下载,不由终端提交。
+ */
+ @Schema(description = "文件中心对象名")
+ private String storageFileName;
+
+ /**
+ * PC 可使用统一请求层访问的文件相对地址。
+ */
+ @Schema(description = "图片访问地址")
+ private String imageUrl;
+
+ /**
+ * 实际保存文件的 SHA-256,用于幂等和防止证据被静默替换。
+ */
+ @Schema(description = "图片SHA-256")
+ private String imageHash;
+
+ /**
+ * 实际保存文件大小,单位字节。
+ */
+ @Schema(description = "图片大小,单位字节")
+ private Long imageSize;
+
+ /**
+ * 实际解码图片宽度,单位像素。
+ */
+ @Schema(description = "图片宽度,单位像素")
+ private Integer imageWidth;
+
+ /**
+ * 实际解码图片高度,单位像素。
+ */
+ @Schema(description = "图片高度,单位像素")
+ private Integer imageHeight;
+
+ /**
+ * 证据状态,保存 HardOrderEvidenceStatusEnum.code。
+ */
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * 永久缺失或服务端复制失败的摘要,不保存终端完整文件路径。
+ */
+ @Schema(description = "失败原因")
+ private String failureReason;
+
+ /**
+ * 产生证据的终端应用版本快照。
+ */
+ @Schema(description = "终端应用版本")
+ private String appVersion;
+
+ /**
+ * 创建人,由 MyBatis 填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建人")
+ private String createBy;
+
+ /**
+ * 证据占位记录创建时间,由 MyBatis 填充器写入。
+ */
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ /**
+ * 修改人,由 MyBatis 填充器在状态变化时写入。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改人")
+ private String updateBy;
+
+ /**
+ * 最近一次状态、文件或错误信息更新时间。
+ */
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ @Schema(description = "修改时间")
+ private LocalDateTime updateTime;
+
+ /**
+ * 逻辑删除标记;订单退款、取消和删除不会联动改变该值。
+ */
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ @Schema(description = "删除标记")
+ private String delFlag;
+
+ /**
+ * 租户主键,由当前请求租户上下文写入并由数据层自动隔离。
+ */
+ @Schema(description = "租户主键")
+ private Long tenantId;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
index cb1fec68..49b3e4ad 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAfterSaleVo.java
@@ -15,6 +15,24 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
*/
private String autoRefundTaskStatus;
+ /**
+ * 自动退款任务场景编码;10 表示规定时间内用户免审退款,20 表示过期订单自动退款,人工审核时为空。
+ * 该字段仅用于管理端列表响应,不对应售后表持久化字段。
+ */
+ private String autoRefundTaskType;
+
+ /**
+ * 用户免审退款采用的规则名称,来源于自动退款任务创建时的配置快照;非免审任务为空。
+ * 该字段仅用于管理端列表响应,不对应售后表持久化字段。
+ */
+ private String autoRefundRuleType;
+
+ /**
+ * 用户免审退款资格截止时间,来源于自动退款任务创建时的规则快照;非免审任务为空。
+ * 该字段仅用于管理端列表响应,不对应售后表持久化字段。
+ */
+ private LocalDateTime autoRefundEligibilityDeadline;
+
/**
* 用户相关
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
index bb2c3106..e44b13d4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
@@ -42,4 +42,12 @@ public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity {
*/
@Schema(description = "售后状态")
private String afterSaleStatus;
+
+ /** 预约订单实际使用日期,过期候选列表展示用。 */
+ @Schema(description = "预约日期")
+ private java.time.LocalDate apptUseDate;
+
+ /** 预约餐段编码,过期候选列表展示用。 */
+ @Schema(description = "餐段")
+ private String hourType;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
new file mode 100644
index 00000000..96f057a1
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductApptExportRecord.java
@@ -0,0 +1,46 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import lombok.Data;
+
+/**
+ * 食堂菜品销售三级导出的聚合记录。
+ *
+ * 同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段;
+ * 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。
+ */
+@Data
+public class HardCanteenProductApptExportRecord {
+
+ /** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
+ private String statisticsDate;
+
+ /** 用餐时间段协议编码。 */
+ private String hourType;
+
+ /** 销售来源协议编码;为空表示当前筛选范围内的跨来源合计。 */
+ private String saleSource;
+
+ /** 食堂商户主键。 */
+ private Long merchantId;
+
+ /** 购买用户所属部门主键。 */
+ private Long userDeptId;
+
+ /** 商品主键;商户部门聚合记录为空。 */
+ private Long productId;
+
+ /** 订单快照中的商品名称;商户部门聚合记录为空。 */
+ private String productName;
+
+ /** 当前统计层级内去重后的交易人数。 */
+ private Long payUserCount;
+
+ /** 当前统计层级内去重后的订单数量。 */
+ private Long timeCount;
+
+ /** 有效商品总数量,预约来源已扣减退款完成数量。 */
+ private Long payCount;
+
+ /** 有效商品总价,单位为分;预约来源已扣减退款完成金额。 */
+ private Long totalPrice;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java
index 3543aa62..d5af79b1 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenProductTotalVo.java
@@ -42,9 +42,31 @@ public class HardCanteenProductTotalVo {
@Schema(description = "购买次数")
private Long timeCount;
+ /** 统计来源编码,来源于 HardSaleSourceEnum,不对应数据库字段。 */
+ private String saleSource;
+
+ /** 统计来源名称,用于管理端和导出展示,不对应数据库字段。 */
+ private String saleSourceName;
+
+ /**
+ * 统计展示层级,取值来自 HardCanteenStatisticsLevelEnum,不对应数据库字段。
+ * 空值表示兼容旧统计接口返回结构。
+ */
+ private String statisticsLevel;
+
+ /** 统计展示层级中文名称,用于管理端说明当前行为日期合计或餐段合计。 */
+ private String statisticsLevelName;
+
+ /**
+ * 当前合计行的来源拆分结果;管理端用它在同一行比较线下购买与预约核销。
+ * 空集合表示当前筛选范围没有来源明细或属于旧统计接口。
+ */
+ private List sourceRows;
+
@Schema(description = "用餐部门总数")
private Integer deptCount;
+ /** 菜品统计中承载餐段合计行,部门统计中承载部门餐段行;空值表示无下一级统计。 */
private List children;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java
new file mode 100644
index 00000000..47c433fa
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDailyStatisticsVo.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * 指定用餐日期的用户预约统计。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "食堂用户每日预约统计")
+public class HardCanteenUserApptDailyStatisticsVo extends HardCanteenUserApptStatisticsSummaryVo {
+
+ /** 统计对应的用餐日期,格式由接口序列化器输出为 yyyy-MM-dd。 */
+ @Schema(description = "用餐日期")
+ private LocalDate date;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java
new file mode 100644
index 00000000..2fbd187d
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptDishStatisticsVo.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 用户预约按餐段和菜品快照聚合的统计数据。
+ */
+@Data
+@Schema(description = "食堂用户预约菜品统计")
+public class HardCanteenUserApptDishStatisticsVo {
+
+ /** 餐段字典编码,空值表示历史订单未记录餐段。 */
+ @Schema(description = "餐段编码")
+ private String hourType;
+
+ /** 下单时固化的菜品名称;历史空值由服务层归一为未命名菜品。 */
+ @Schema(description = "菜品名称")
+ private String productName;
+
+ /** 菜品快照的总份数。 */
+ @Schema(description = "菜品份数")
+ private Long productCount = 0L;
+
+ /** 包含该菜品的去重预约订单数。 */
+ @Schema(description = "涉及订单数")
+ private Long orderCount = 0L;
+
+ /** 该菜品快照总价,单位为分。 */
+ @Schema(description = "菜品总价,单位为分")
+ private Long totalPrice = 0L;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java
new file mode 100644
index 00000000..dcd15ddd
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptMealStatisticsVo.java
@@ -0,0 +1,32 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 用户预约按餐段聚合的统计数据。
+ */
+@Data
+@Schema(description = "食堂用户预约餐段统计")
+public class HardCanteenUserApptMealStatisticsVo {
+
+ /** 餐段字典编码,属于 PC、App 与后端共用协议。 */
+ @Schema(description = "餐段编码")
+ private String hourType;
+
+ /** 该餐段去重后的用户-用餐日期数;每日表示人数,范围总计表示可与每日相加的订餐人次。 */
+ @Schema(description = "预约人数或人次")
+ private Long reservedUserCount = 0L;
+
+ /** 该餐段的有效预约订单数。 */
+ @Schema(description = "预约订单数")
+ private Long orderCount = 0L;
+
+ /** 该餐段购物车快照中的菜品总份数。 */
+ @Schema(description = "菜品份数")
+ private Long dishCount = 0L;
+
+ /** 该餐段有效预约商品总价,单位为分。 */
+ @Schema(description = "预约总价,单位为分")
+ private Long totalPrice = 0L;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
index dac4aa9c..9e12c442 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java
@@ -36,7 +36,7 @@ public class HardCanteenUserApptOrderVo {
@Schema(description = "订单状态协议编码")
private String orderStatus;
- /** 订单最近更新时间;已核销订单中作为核销时间展示。 */
+ /** 核销时间兼容字段;优先来源于订单独立核销时间,历史空值回退到订单最近更新时间。 */
@Schema(description = "核销时间")
private LocalDateTime updateTime;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java
new file mode 100644
index 00000000..0c85a200
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsSummaryVo.java
@@ -0,0 +1,53 @@
+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;
+
+/**
+ * 用户预约统计的通用汇总数据。
+ *
+ * 金额字段统一使用分;用户数量按“用户-用餐日期”记录累计,避免多日查询把同一用户重复隐藏。
+ */
+@Data
+@Schema(description = "食堂用户预约统计汇总")
+public class HardCanteenUserApptStatisticsSummaryVo {
+
+ /** 当前统计范围内的用户-用餐日期记录数。 */
+ @Schema(description = "用户日期记录数")
+ private Long userCount = 0L;
+
+ /** 当前统计范围内去重后的用户数。 */
+ @Schema(description = "去重用户数")
+ private Long uniqueUserCount = 0L;
+
+ /** 有效预约记录的用户-用餐日期数。 */
+ @Schema(description = "已订餐记录数")
+ private Long reservedUserCount = 0L;
+
+ /** 没有有效预约记录的用户-用餐日期数。 */
+ @Schema(description = "未订餐记录数")
+ private Long unreservedUserCount = 0L;
+
+ /** 有效预约订单数。 */
+ @Schema(description = "预约订单数")
+ private Long orderCount = 0L;
+
+ /** 购物车快照中的菜品总份数。 */
+ @Schema(description = "菜品总份数")
+ private Long dishCount = 0L;
+
+ /** 有效预约订单商品总价,单位为分。 */
+ @Schema(description = "预约总价,单位为分")
+ private Long totalPrice = 0L;
+
+ /** 按餐段聚合的预约人数、订单数和菜品份数。 */
+ @Schema(description = "餐段统计")
+ private List meals = new ArrayList<>();
+
+ /** 按餐段和菜品名称聚合的菜品份数。 */
+ @Schema(description = "菜品统计")
+ private List dishes = new ArrayList<>();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java
new file mode 100644
index 00000000..5cc19bec
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptStatisticsVo.java
@@ -0,0 +1,23 @@
+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;
+
+/**
+ * 用户预约统计响应,包含查询范围总计和逐日统计。
+ */
+@Data
+@Schema(description = "食堂用户预约统计")
+public class HardCanteenUserApptStatisticsVo {
+
+ /** 当前查询条件下所有用餐日期的汇总结果。 */
+ @Schema(description = "查询范围总计")
+ private HardCanteenUserApptStatisticsSummaryVo total = new HardCanteenUserApptStatisticsSummaryVo();
+
+ /** 当前查询条件下按用餐日期升序排列的每日结果。 */
+ @Schema(description = "每日统计")
+ private List daily = new ArrayList<>();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java
index 0ba972bd..daa2fca5 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCarVo.java
@@ -9,6 +9,12 @@ import lombok.Data;
@Data
public class HardCarVo extends HardCarEntity {
+ /** 统计明细来源编码,来源于 HardSaleSourceEnum,不对应购物车持久化字段。 */
+ private String saleSource;
+
+ /** 统计明细来源名称,用于管理端展示,不对应购物车持久化字段。 */
+ private String saleSourceName;
+
/** 超市商品 SPU ID,仅超市统计和展示场景赋值。 */
private Long spuId;
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java
new file mode 100644
index 00000000..ee164957
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceImageVo.java
@@ -0,0 +1,33 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * PC 订单详情使用的订单证据图片,附带稳定编码对应的中文名称。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单识别证据图片详情")
+public class HardOrderEvidenceImageVo extends HardOrderEvidenceImageEntity {
+
+ /**
+ * 证据类型中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据类型名称")
+ private String evidenceTypeName;
+
+ /**
+ * 图片角色中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据图片角色名称")
+ private String evidenceRoleName;
+
+ /**
+ * 证据状态中文名称,未知历史编码时回退原编码。
+ */
+ @Schema(description = "证据状态名称")
+ private String statusName;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java
new file mode 100644
index 00000000..4c022089
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceResolveVo.java
@@ -0,0 +1,34 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 终端按本地订单号对账时返回的服务端订单与证据快照。
+ */
+@Data
+@Builder
+@Schema(description = "订单证据对账结果")
+public class HardOrderEvidenceResolveVo {
+
+ /**
+ * 服务端订单主键。
+ */
+ @Schema(description = "服务端订单主键")
+ private Long orderId;
+
+ /**
+ * 服务端订单状态,终端仅在现有支付流程确认成功后上传证据。
+ */
+ @Schema(description = "订单状态")
+ private String orderStatus;
+
+ /**
+ * 当前服务端已登记的证据位置和状态,用于终端幂等跳过成功项。
+ */
+ @Schema(description = "证据列表")
+ private List evidenceImages;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java
new file mode 100644
index 00000000..43388162
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderEvidenceUploadResultVo.java
@@ -0,0 +1,50 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 终端提交元数据、复制参考图或上传现场图后的幂等处理结果。
+ */
+@Data
+@Builder
+@Schema(description = "订单证据处理结果")
+public class HardOrderEvidenceUploadResultVo {
+
+ /**
+ * 服务端证据记录主键。
+ */
+ @Schema(description = "证据记录主键")
+ private Long evidenceId;
+
+ /**
+ * 服务端订单主键。
+ */
+ @Schema(description = "订单主键")
+ private Long orderId;
+
+ /**
+ * 处理后的证据状态。
+ */
+ @Schema(description = "证据状态")
+ private String status;
+
+ /**
+ * AVAILABLE 状态下的图片访问地址,其他状态允许为空。
+ */
+ @Schema(description = "图片访问地址")
+ private String imageUrl;
+
+ /**
+ * 是否实际上传或复制了新文件;幂等命中时为 false。
+ */
+ @Schema(description = "是否新写入文件")
+ private Boolean uploaded;
+
+ /**
+ * 面向终端同步日志的简短处理结果。
+ */
+ @Schema(description = "处理结果说明")
+ private String message;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
index 95cf42a0..ffe257dd 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderVo.java
@@ -13,6 +13,12 @@ import java.util.List;
@Data
public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface {
+ /** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */
+ private String saleSource;
+
+ /** 统计明细来源名称,用于管理端明确区分支付和核销来源。 */
+ private String saleSourceName;
+
/**
* 用户相关
*/
@@ -102,4 +108,9 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
* 当前访问者是否可以查看预约核销码。
*/
private Boolean canViewReserveCode;
+
+ /**
+ * 订单详情中的长期识别证据列表,按证据类型、角色和序号排序。
+ */
+ private List evidenceImages;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java
new file mode 100644
index 00000000..44132e02
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenDeptTotalExcelVo.java
@@ -0,0 +1,54 @@
+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 lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 食堂菜品部门销售统计导出行,按日期、食堂、来源、部门和餐段拆分。
+ */
+@Data
+@ColumnWidth(20)
+public class HardCanteenDeptTotalExcelVo {
+
+ /** 业务发生日期;线下取支付日期,预约取核销日期。 */
+ @ExcelProperty("日期")
+ private LocalDate statisticsDate;
+
+ /** 当前用户商户权限范围内的食堂名称。 */
+ @ExcelProperty("食堂")
+ private String merchantName;
+
+ /** 销售来源中文名称,取值为线下购买或预约核销。 */
+ @ExcelProperty("来源")
+ private String saleSourceName;
+
+ /** 下单用户所属部门名称;无法解析时为空。 */
+ @ExcelProperty("部门")
+ private String userDeptName;
+
+ /** 餐段字典名称,来源于 daily_menu。 */
+ @ExcelProperty("餐段")
+ private String hourTypeName;
+
+ /** 当前部门餐段内去重后的交易用户数量,单位为人。 */
+ @ExcelProperty("交易人数")
+ private Long payUserCount;
+
+ /** 当前部门餐段内去重后的订单数量,单位为次。 */
+ @ExcelProperty("交易次数")
+ private Long timeCount;
+
+ /** 退款扣减口径下的有效商品数量,单位为份。 */
+ @ExcelProperty("商品数量")
+ private Long payCount;
+
+ /** 有效商品总金额,单位由分转换为元。 */
+ @ExcelProperty("总价(元)")
+ @NumberFormat("#,##0.00")
+ private BigDecimal totalPrice;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
new file mode 100644
index 00000000..f83e31a4
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductApptExcelVo.java
@@ -0,0 +1,111 @@
+package com.bsd.pjcloud.hard.api.vo.order.excel;
+
+import com.alibaba.excel.annotation.ExcelIgnore;
+import com.alibaba.excel.annotation.ExcelProperty;
+import com.alibaba.excel.annotation.format.NumberFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 食堂菜品销售统计自定义导出行。
+ *
+ * 一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。
+ * 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。
+ */
+@Data
+public class HardCanteenProductApptExcelVo {
+
+ /** 日期餐段汇总中的业务发生日期。 */
+ @ExcelProperty({"日期餐段汇总", "统计日期"})
+ private LocalDate summaryDate;
+
+ /** 日期餐段汇总中的餐段编码或字典名称。 */
+ @ExcelProperty({"日期餐段汇总", "餐段"})
+ private String summaryHourType;
+
+ /** 日期餐段汇总的来源口径,取值为筛选范围合计、线下购买或预约核销。 */
+ @ExcelProperty({"日期餐段汇总", "来源口径"})
+ private String summarySaleSourceName;
+
+ /** 日期餐段汇总中的去重交易人数。 */
+ @ExcelProperty({"日期餐段汇总", "交易人数"})
+ private Long summaryPayUserCount;
+
+ /** 日期餐段汇总中的交易次数。 */
+ @ExcelProperty({"日期餐段汇总", "交易次数"})
+ private Long summaryTimeCount;
+
+ /** 日期餐段汇总中的商品总数量。 */
+ @ExcelProperty({"日期餐段汇总", "商品数量"})
+ private Long summaryPayCount;
+
+ /** 日期餐段汇总中的总价,单位为元。 */
+ @ExcelProperty({"日期餐段汇总", "总价"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal summaryTotalPrice;
+
+ /** 商户部门统计中的食堂名称。 */
+ @ExcelProperty({"商户部门统计", "商户"})
+ private String merchantName;
+
+ /** 商户部门统计中的用户部门名称。 */
+ @ExcelProperty({"商户部门统计", "部门"})
+ private String departmentName;
+
+ /** 商户部门统计中的业务发生日期。 */
+ @ExcelProperty({"商户部门统计", "统计日期"})
+ private LocalDate merchantDate;
+
+ /** 商户部门统计中的餐段名称。 */
+ @ExcelProperty({"商户部门统计", "餐段"})
+ private String merchantHourType;
+
+ /** 商户部门统计中的去重交易人数。 */
+ @ExcelProperty({"商户部门统计", "交易人数"})
+ private Long merchantPayUserCount;
+
+ /** 商户部门统计中的交易次数。 */
+ @ExcelProperty({"商户部门统计", "交易次数"})
+ private Long merchantTimeCount;
+
+ /** 商户部门统计中的有效商品总数量。 */
+ @ExcelProperty({"商户部门统计", "商品数量"})
+ private Long merchantPayCount;
+
+ /** 商户部门统计中的总价,单位为元。 */
+ @ExcelProperty({"商户部门统计", "总价"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal merchantTotalPrice;
+
+ /** 商品明细中的商品名称。 */
+ @ExcelProperty({"商品明细", "商品"})
+ private String productName;
+
+ /** 商品明细中的商品数量。 */
+ @ExcelProperty({"商品明细", "数量"})
+ private Long productCount;
+
+ /** 商品明细中的商品总价,单位为元。 */
+ @ExcelProperty({"商品明细", "总价"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal productTotalPrice;
+
+ /** 商品统计中的商品名称,同一商户部门内按规范化后的名称合并。 */
+ @ExcelProperty({"商品统计", "商品名称"})
+ private String statisticProductName;
+
+ /** 商品统计中的商品总数量,预约按退款扣减,套餐按组成商品展开后的有效明细累计。 */
+ @ExcelProperty({"商品统计", "商品总数量"})
+ private Long statisticProductCount;
+
+ /** 商品统计中的商品总价,单位为元,按退款扣减及套餐展开后的有效明细累计。 */
+ @ExcelProperty({"商品统计", "商品总价格"})
+ @NumberFormat("#,##0.00")
+ private BigDecimal statisticProductTotalPrice;
+
+ /** 导出行层级,仅供合并处理器和测试识别,不写入 Excel。 */
+ @ExcelIgnore
+ private String rowLevel;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java
new file mode 100644
index 00000000..8716e2d0
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenProductTotalExcelVo.java
@@ -0,0 +1,46 @@
+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 lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 食堂菜品销售统计导出行,按统计日期、餐段和销售来源拆分。
+ */
+@Data
+@ColumnWidth(20)
+public class HardCanteenProductTotalExcelVo {
+
+ /** 业务发生日期;线下取支付日期,预约取核销日期。 */
+ @ExcelProperty("日期")
+ private LocalDate statisticsDate;
+
+ /** 餐段字典名称,来源于 daily_menu。 */
+ @ExcelProperty("餐段")
+ private String hourTypeName;
+
+ /** 销售来源中文名称,取值为线下购买或预约核销。 */
+ @ExcelProperty("来源")
+ private String saleSourceName;
+
+ /** 来源内去重后的交易用户数量,单位为人。 */
+ @ExcelProperty("交易人数")
+ private Long payUserCount;
+
+ /** 来源内去重后的订单数量,单位为次。 */
+ @ExcelProperty("交易次数")
+ private Long timeCount;
+
+ /** 退款扣减口径下的有效商品数量,单位为份。 */
+ @ExcelProperty("商品数量")
+ private Long payCount;
+
+ /** 有效商品总金额,单位由分转换为元。 */
+ @ExcelProperty("总价(元)")
+ @NumberFormat("#,##0.00")
+ private BigDecimal totalPrice;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java
new file mode 100644
index 00000000..4ec314d7
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardOrderEvidenceImageController.java
@@ -0,0 +1,87 @@
+package com.bsd.pjcloud.hard.controller.hard;
+
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo;
+import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 收银终端订单识别证据接口,使用统一 Token、租户头和 DEVICE-ID 校验调用方身份。
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/hard-order-evidence")
+@Tag(description = "hard-order-evidence", name = "订单识别证据")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class HardOrderEvidenceImageController {
+
+ /**
+ * 订单识别证据业务服务。
+ */
+ private final HardOrderEvidenceImageService hardOrderEvidenceImageService;
+
+ /**
+ * 对账终端订单并返回服务端证据状态。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param androidOrderCode 终端本地订单号,预约核销证据允许为空
+ * @param orderId 服务端订单主键,现场下单证据允许为空
+ * @return 服务端订单和证据列表
+ */
+ @GetMapping("/ad/resolve")
+ @Operation(summary = "按终端订单号或服务端订单ID对账识别证据")
+ public R resolve(@RequestHeader("DEVICE-ID") Long hardId,
+ @RequestParam(required = false) String androidOrderCode,
+ @RequestParam(required = false) Long orderId) {
+ return R.ok(hardOrderEvidenceImageService.resolve(hardId, androidOrderCode, orderId));
+ }
+
+ /**
+ * 登记证据元数据、复制参考图或报告终端图片永久缺失。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param dto 证据元数据和目标状态
+ * @return 处理后的证据状态
+ */
+ @PostMapping("/ad/submit")
+ @Operation(summary = "提交订单识别证据元数据")
+ public R submit(@RequestHeader("DEVICE-ID") Long hardId,
+ @Valid @RequestBody HardOrderEvidenceSubmitDto dto) {
+ return R.ok(hardOrderEvidenceImageService.submit(hardId, dto));
+ }
+
+ /**
+ * 上传现场识别图片,服务端校验图片内容后写入订单专属文件目录。
+ *
+ * @param hardId 当前绑定设备主键
+ * @param dto 订单与识别元数据
+ * @param file JPEG 或 PNG 现场图片
+ * @return 幂等上传结果
+ */
+ @PostMapping(value = "/ad/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(summary = "上传订单现场识别图片")
+ public R upload(@RequestHeader("DEVICE-ID") Long hardId,
+ @Valid @ModelAttribute HardOrderEvidenceUploadDto dto,
+ @RequestPart("file") MultipartFile file) {
+ return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
index a66b061a..faecc1da 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
@@ -80,6 +80,35 @@ public class HardAutoRefundTaskController {
return R.ok(taskService.pageTasks(page, dto));
}
+ /**
+ * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
+ */
+ @GetMapping("/expired-candidates")
+ @Operation(summary = "查询待过期预约退款订单")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
+ public R> expiredCandidates(@ParameterObject Page page,
+ @ParameterObject HardAutoRefundTaskPageDto dto) {
+ List scopedMchIds = listScopedMerchantIds();
+ if (scopedMchIds.isEmpty()) {
+ Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
+ empty.setRecords(List.of());
+ return R.ok(empty);
+ }
+ dto.setMchIds(scopedMchIds);
+ return R.ok(taskService.pageExpiredCandidates(page, dto));
+ }
+
+ /** 管理端确认后逐单执行过期预约整单退款。 */
+ @PostMapping("/{id}/expired-refund")
+ @Operation(summary = "过期预约订单退款")
+ @Idempotent(expireTime = 5, info = "正在退款,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R expiredRefund(@PathVariable Long id) {
+ validateTaskScope(id);
+ taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId());
+ return R.ok(true);
+ }
+
/**
* 查询指定任务全部只读尝试日志。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
index 097bb40a..26048912 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
@@ -16,8 +16,12 @@ 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.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;
@@ -51,11 +55,19 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCanteenApptTotalController {
+ /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */
+ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
+
/**
* 食堂预约统计服务,负责菜品和用户维度的预约聚合。
*/
private final HardCanteenApptTotalService canteenApptTotalService;
+ /**
+ * 食堂预约导出服务,负责异步任务、分批写入和文件上传,不让统计服务承担长耗时导出职责。
+ */
+ private final HardCanteenApptTotalExportService canteenApptTotalExportService;
+
/**
* 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。
*/
@@ -170,6 +182,21 @@ public class HardCanteenApptTotalController {
return R.ok(canteenApptTotalService.userReservationDetail(userId, apptUseDate));
}
+ /**
+ * 查询当前用户预约筛选条件对应的每日和范围总计。
+ *
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 预约统计结果
+ */
+ @Operation(summary = "用户预约统计", description = "按日统计用户预约人数、餐段及菜品份数")
+ @ApiCryptoAes
+ @PostMapping("/user-reserv-statistics")
+ @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')")
+ public R userReservationStatistics(
+ @Valid @RequestBody HardCanteenUserApptQueryDto query) {
+ return R.ok(canteenApptTotalService.userReservationStatistics(query));
+ }
+
/**
* 提交用户每日预约异步导出任务。
*
@@ -182,7 +209,7 @@ public class HardCanteenApptTotalController {
@PostMapping("/user-reserv-export-task")
public R submitUserReservationExport(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
- return R.ok(canteenApptTotalService.submitUserReservationExport(query), "用户预约导出任务已提交");
+ return R.ok(canteenApptTotalExportService.submitUserReservationExport(query), "用户预约导出任务已提交");
}
/**
@@ -198,7 +225,70 @@ public class HardCanteenApptTotalController {
@PostMapping("/user-reserv-export")
public List userReservationExport(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
- return canteenApptTotalService.userReservationExport(query);
+ return canteenApptTotalExportService.userReservationExport(query);
+ }
+
+ /**
+ * 提交菜品预约统计异步导出任务。
+ *
+ * @param query 菜品预约日期、餐段、食堂和部门筛选条件
+ * @return 供前端轮询的初始任务进度
+ */
+ @Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel")
+ @SysLog("菜品预约统计异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @PostMapping("/product-reserv-export-task")
+ public R submitProductReservationExport(@RequestBody HardOrderDto query) {
+ applyCanteenMerchantScope(query);
+ return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
+ }
+
+ /**
+ * 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
+ */
+ @Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
+ @SysLog("菜品预约统计导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
+ @PostMapping("/product-reserv-export")
+ public List productReservationExport(@RequestBody HardOrderDto query) {
+ applyCanteenMerchantScope(query);
+ return canteenApptTotalExportService.productReservationExport(query);
+ }
+
+ /**
+ * 为后台菜品预约导出补充当前操作者可见的食堂范围。
+ *
+ * 分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
+ * 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。
+ *
+ * @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
+ */
+ private void applyCanteenMerchantScope(HardOrderDto query) {
+ if (query == null) {
+ return;
+ }
+ MchManageDto mchManageDto = new MchManageDto();
+ mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
+ R> scopeResponse = remoteMchService.scopeList(mchManageDto);
+ List mchList = scopeResponse == null ? null : scopeResponse.getData();
+ List visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
+ : mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (ObjectUtil.isEmpty(visibleMerchantIds)) {
+ query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
+ return;
+ }
+ if (ObjectUtil.isEmpty(query.getMchIds())) {
+ query.setMchIds(visibleMerchantIds);
+ return;
+ }
+ // 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
+ List allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
+ query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
+ ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
index 4df49ca7..0b509485 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenDeptTotalController.java
@@ -4,18 +4,20 @@ import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
+import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
-import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
-import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.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.service.HardCanteenTotalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -44,8 +46,23 @@ public class HardCanteenDeptTotalController {
PjcloudUser user = SecurityUtils.getUser();
dto.setTotalIsFindChildren(true);
- dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenTotalService.listByDept(dto, user, dataScope));
}
+ /**
+ * 导出食堂部门销售统计,筛选和商户范围与部门统计主表一致。
+ *
+ * @param dto 来源统计日期、来源、食堂、餐段和部门筛选
+ * @return 按日期、食堂、来源、部门和餐段拆分的 Excel 行
+ */
+ @Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计")
+ @SysLog("食堂菜品部门统计导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
+ @ResponseExcel(name = "食堂菜品部门统计")
+ @PostMapping("/export-dept")
+ public List exportDept(@RequestBody HardOrderDto dto) {
+ PjcloudUser user = SecurityUtils.getUser();
+ return canteenTotalService.exportDept(dto, user, null);
+ }
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
index 7c926661..d0ae0980 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenTotalController.java
@@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
+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;
@@ -21,6 +23,7 @@ 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.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -32,6 +35,7 @@ import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.HttpHeaders;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -138,9 +142,45 @@ public class HardCanteenTotalController {
return R.ok(canteenTotalService.productTotal(page, dto, null));
}
+ /**
+ * 导出食堂菜品销售统计,筛选范围与菜品统计主表完全一致。
+ *
+ * @param dto 来源统计日期、来源、食堂、餐段和部门筛选
+ * @return 按日期、餐段和来源拆分的 Excel 行
+ */
+ @Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计")
+ @SysLog("食堂菜品销售统计导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')")
+ @ResponseExcel(name = "食堂菜品销售统计")
+ @PostMapping("/export-product")
+ public List exportProduct(@RequestBody HardOrderDto dto) {
+ MchManageDto mchManageDto = new MchManageDto();
+ mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
+ List mchList = remoteMchService.scopeList(mchManageDto).getData();
+ if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
+ dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
+ }
+ return canteenTotalService.exportProduct(dto, null);
+ }
+
+ /**
+ * 查询菜品销售明细;新版来源统计补充当前用户可见食堂范围,旧查询仍沿用原接口行为。
+ *
+ * @param page 分页参数
+ * @param dto 商品名称、统计日期、来源、食堂、餐段和部门筛选
+ * @return 按商品聚合的销售明细分页结果
+ */
@Operation(summary = "菜品出售统计", description = "菜品出售统计")
@PostMapping("/proudct-query")
public R> proudctQuery(@ParameterObject Page page, @RequestBody HardCarDto dto) {
+ if (dto.getStatisticsDateTimeList() != null && ObjectUtil.isEmpty(dto.getMchIds())) {
+ MchManageDto mchManageDto = new MchManageDto();
+ mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
+ List mchList = remoteMchService.scopeList(mchManageDto).getData();
+ if (ObjectUtil.isNotEmpty(mchList)) {
+ dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
+ }
+ }
return R.ok(canteenTotalService.proudctQuery(page, dto));
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index d3780447..b9936a98 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -538,6 +538,20 @@ public class HardOrderController {
return R.ok(hardwareOrderService.handCancelCode(body));
}
+ /**
+ * 将工作人员误核销的线上预订订单恢复到待核销状态,沿用手动核销权限控制。
+ *
+ * @param orderId 已核销的线上预订订单主键
+ * @return 订单及预约二维码均恢复成功时返回 {@code true}
+ */
+ @Operation(summary = "admin退回线上预订核销", description = "将已核销的线上预订订单退回到待核销状态")
+ @PutMapping("/reserve-verification-rollback/{orderId}")
+ @Idempotent(expireTime = 1, key = "'/hard-order/reserve-verification-rollback' + #orderId ", info = "正在退回核销状态,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_cancel')")
+ public R rollbackReserveVerification(@PathVariable Long orderId) {
+ return R.ok(hardwareOrderService.rollbackReserveVerification(orderId));
+ }
+
@Operation(summary = "分页查询", description = "分页查询")
@PostMapping("/page")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
@@ -621,7 +635,7 @@ public class HardOrderController {
@GetMapping("/{id}")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
public R getById(@PathVariable("id") Long id) {
- return R.ok(hardwareOrderService.join(id));
+ return R.ok(hardwareOrderService.joinWithEvidence(id));
}
@Operation(summary = "离线订单手动支付", description = "离线订单手动支付")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
new file mode 100644
index 00000000..fa9811af
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
@@ -0,0 +1,326 @@
+package com.bsd.pjcloud.hard.excel;
+
+import com.alibaba.excel.write.handler.RowWriteHandler;
+import com.alibaba.excel.write.handler.WorkbookWriteHandler;
+import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
+import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
+import com.alibaba.excel.write.metadata.holder.WriteTableHolder;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFCellStyle;
+import org.apache.poi.xssf.usermodel.XSSFColor;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 食堂菜品销售统计三级导出合并处理器。
+ *
+ * 数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列;
+ * 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。
+ * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。
+ */
+public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler {
+
+ /** 日期餐段汇总列。 */
+ private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5, 6};
+
+ /** 商户部门统计列。 */
+ private static final int[] MERCHANT_COLUMNS = {7, 8, 9, 10, 11, 12, 13, 14};
+
+ /** Easy Excel 双层表头占用两行,数据从第三行开始。 */
+ private static final int DATA_START_ROW = 2;
+
+ /** 导出列总数,用于逐行应用居中样式。 */
+ private static final int COLUMN_COUNT = 21;
+
+ /** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */
+ private static final short HEAD_FONT_SIZE = 10;
+
+ /** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */
+ 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}
+ };
+
+ /**
+ * 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。
+ */
+ private static final int[] COLUMN_WIDTHS = {
+ 12, 11, 16, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11, 32, 14, 12
+ };
+
+ /** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */
+ private final Map centeredStyles = new HashMap<>();
+
+ /** 原样式、日期颜色和边框位置到彩色边框样式的缓存,避免重复创建工作簿样式。 */
+ private final Map dateGroupBorderStyles = new HashMap<>();
+
+ /** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */
+ private int summaryGroupStart = -1;
+
+ /** 当前商户部门分组的起始行,使用 POI 从零开始的行号。 */
+ private int merchantGroupStart = -1;
+
+ /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
+ private int lastDataRow = -1;
+
+ /** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */
+ private int dateGroupIndex = -1;
+
+ /** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */
+ private String currentDateKey;
+
+ /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
+ private Sheet currentSheet;
+
+ /**
+ * 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。
+ *
+ * Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性,
+ * 不能直接替换成全新的样式,否则金额会失去两位小数格式。
+ *
+ * @param writeSheetHolder 当前工作表持有者
+ * @param writeTableHolder 当前表格持有者,本导出未使用独立表格
+ * @param row 刚完成写入且尚在内存中的行
+ * @param relativeRowIndex 当前批次中的相对行号
+ * @param isHead 是否为表头行
+ */
+ @Override
+ public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row,
+ Integer relativeRowIndex, Boolean isHead) {
+ if (row == null) {
+ return;
+ }
+ if (Boolean.TRUE.equals(isHead)) {
+ applyCompactHeadFont(row, writeSheetHolder.getParentWriteWorkbookHolder());
+ return;
+ }
+ if (row.getRowNum() < DATA_START_ROW) {
+ return;
+ }
+ currentSheet = writeSheetHolder.getSheet();
+ applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder());
+ int rowIndex = row.getRowNum();
+ boolean summaryRow = hasValue(row, 0);
+ boolean merchantRow = hasValue(row, 7);
+ boolean newDateGroup = false;
+ if (summaryRow) {
+ String summaryDateKey = getCellValueKey(row, 0);
+ newDateGroup = currentDateKey == null || !currentDateKey.equals(summaryDateKey);
+ if (newDateGroup) {
+ Row previousDateLastRow = currentSheet.getRow(rowIndex - 1);
+ applyDateGroupBorder(previousDateLastRow, writeSheetHolder.getParentWriteWorkbookHolder(),
+ dateGroupIndex, false, true);
+ currentDateKey = summaryDateKey;
+ dateGroupIndex++;
+ }
+ mergeColumns(currentSheet, summaryGroupStart, rowIndex - 1, SUMMARY_COLUMNS);
+ summaryGroupStart = rowIndex;
+ mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS);
+ merchantGroupStart = -1;
+ }
+ applyDateGroupBorder(row, writeSheetHolder.getParentWriteWorkbookHolder(), dateGroupIndex,
+ newDateGroup, false);
+ if (merchantRow) {
+ mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS);
+ merchantGroupStart = rowIndex;
+ }
+ lastDataRow = rowIndex;
+ }
+
+ /**
+ * 工作簿关闭前收尾最后一个日期餐段和商户部门分组,并统一设置列宽。
+ *
+ * @param holder Easy Excel 当前工作簿持有者
+ */
+ @Override
+ public void afterWorkbookDispose(WriteWorkbookHolder holder) {
+ if (currentSheet == null || lastDataRow < DATA_START_ROW) {
+ return;
+ }
+ mergeColumns(currentSheet, summaryGroupStart, lastDataRow, SUMMARY_COLUMNS);
+ mergeColumns(currentSheet, merchantGroupStart, lastDataRow, MERCHANT_COLUMNS);
+ Row lastRow = currentSheet.getRow(lastDataRow);
+ if (lastRow != null) {
+ applyDateGroupBorder(lastRow, holder, dateGroupIndex, false, true);
+ }
+ for (int column = 0; column < COLUMN_WIDTHS.length; column++) {
+ currentSheet.setColumnWidth(column, COLUMN_WIDTHS[column] * 256);
+ }
+ }
+
+ /**
+ * 在当前行仍处于流式窗口时复制原数字格式并应用水平、垂直居中。
+ *
+ * @param row 当前数据行
+ * @param holder 当前工作簿持有者,用于创建可复用样式
+ */
+ private void applyCenteredStyle(Row row, WriteWorkbookHolder holder) {
+ for (int column = 0; column < COLUMN_COUNT; column++) {
+ Cell cell = row.getCell(column);
+ if (cell == null) {
+ continue;
+ }
+ CellStyle originalStyle = cell.getCellStyle();
+ short styleIndex = originalStyle.getIndex();
+ CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> {
+ CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
+ copiedStyle.cloneStyleFrom(originalStyle);
+ copiedStyle.setAlignment(HorizontalAlignment.CENTER);
+ copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER);
+ return copiedStyle;
+ });
+ cell.setCellStyle(centeredStyle);
+ }
+ }
+
+ /**
+ * 缩小当前表头行使用的字体;只调整字号并保留 Easy Excel 生成的字体名称、颜色和加粗属性。
+ *
+ * @param row 当前表头行
+ * @param holder 当前工作簿持有者,用于定位表头样式引用的字体
+ */
+ private void applyCompactHeadFont(Row row, WriteWorkbookHolder holder) {
+ for (int column = 0; column < COLUMN_COUNT; column++) {
+ Cell cell = row.getCell(column);
+ if (cell == null) {
+ continue;
+ }
+ Font font = holder.getWorkbook().getFontAt(cell.getCellStyle().getFontIndex());
+ font.setFontHeightInPoints(HEAD_FONT_SIZE);
+ }
+ }
+
+ /**
+ * 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
+ *
+ * 流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
+ * 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。
+ *
+ * @param row 当前数据行
+ * @param holder 当前工作簿持有者,用于创建彩色边框样式
+ * @param groupIndex 当前预约日期分组序号,未识别到日期时为负数
+ * @param topBorder 是否为当天首行并设置顶部边框
+ * @param bottomBorder 是否为当天末行并设置底部边框
+ */
+ private void applyDateGroupBorder(Row row, WriteWorkbookHolder holder, int groupIndex, boolean topBorder,
+ boolean bottomBorder) {
+ if (row == null || groupIndex < 0) {
+ return;
+ }
+ int colorIndex = groupIndex % DATE_GROUP_BORDER_COLORS.length;
+ for (int column = 0; column < COLUMN_COUNT; column++) {
+ Cell cell = row.getCell(column);
+ if (cell == null) {
+ continue;
+ }
+ boolean leftBorder = column == 0;
+ boolean rightBorder = column == COLUMN_COUNT - 1;
+ if (!leftBorder && !rightBorder && !topBorder && !bottomBorder) {
+ continue;
+ }
+ CellStyle borderedStyle = getDateGroupBorderStyle(cell.getCellStyle(), holder, colorIndex,
+ leftBorder, rightBorder, topBorder, bottomBorder);
+ cell.setCellStyle(borderedStyle);
+ }
+ }
+
+ /**
+ * 创建或复用保留原有数字格式、对齐方式的彩色边框样式。
+ *
+ * @param originalStyle 当前单元格原样式
+ * @param holder 当前工作簿持有者
+ * @param colorIndex 日期边框颜色索引
+ * @param leftBorder 是否设置左边框
+ * @param rightBorder 是否设置右边框
+ * @param topBorder 是否设置上边框
+ * @param bottomBorder 是否设置下边框
+ * @return 应用分组边框后的样式
+ */
+ private CellStyle getDateGroupBorderStyle(CellStyle originalStyle, WriteWorkbookHolder holder, int colorIndex,
+ boolean leftBorder, boolean rightBorder, boolean topBorder,
+ boolean bottomBorder) {
+ String cacheKey = originalStyle.getIndex() + ":" + colorIndex + ":" + leftBorder + rightBorder
+ + topBorder + bottomBorder;
+ CellStyle cachedStyle = dateGroupBorderStyles.get(cacheKey);
+ if (cachedStyle != null) {
+ return cachedStyle;
+ }
+ CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
+ copiedStyle.cloneStyleFrom(originalStyle);
+ if (!(copiedStyle instanceof XSSFCellStyle xssfStyle)) {
+ throw new IllegalStateException("菜品销售统计导出仅支持 XLSX 彩色边框样式");
+ }
+ XSSFColor color = new XSSFColor(DATE_GROUP_BORDER_COLORS[colorIndex]);
+ if (leftBorder) {
+ xssfStyle.setBorderLeft(DATE_GROUP_BORDER_STYLE);
+ xssfStyle.setLeftBorderColor(color);
+ }
+ if (rightBorder) {
+ xssfStyle.setBorderRight(DATE_GROUP_BORDER_STYLE);
+ xssfStyle.setRightBorderColor(color);
+ }
+ if (topBorder) {
+ xssfStyle.setBorderTop(DATE_GROUP_BORDER_STYLE);
+ xssfStyle.setTopBorderColor(color);
+ }
+ if (bottomBorder) {
+ xssfStyle.setBorderBottom(DATE_GROUP_BORDER_STYLE);
+ xssfStyle.setBottomBorderColor(color);
+ }
+ dateGroupBorderStyles.put(cacheKey, copiedStyle);
+ return copiedStyle;
+ }
+
+ /**
+ * 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。
+ *
+ * @param row 当前日期餐段汇总行
+ * @param columnIndex 预约日期所在列索引
+ * @return 用于判断日期是否变化的非空值标识
+ */
+ private String getCellValueKey(Row row, int columnIndex) {
+ Cell cell = row.getCell(columnIndex);
+ return cell == null ? "" : cell.toString().trim();
+ }
+
+ /**
+ * 按列范围添加合并单元格;单行或尚未开始的分组无需创建合并区域。
+ *
+ * @param sheet 当前导出工作表
+ * @param firstRow 分组起始行,使用 POI 从零开始的行号
+ * @param lastRow 分组结束行,使用 POI 从零开始的行号
+ * @param columns 需要纵向合并的列索引
+ */
+ private void mergeColumns(Sheet sheet, int firstRow, int lastRow, int[] columns) {
+ if (sheet == null || firstRow < DATA_START_ROW || firstRow >= lastRow) {
+ return;
+ }
+ for (int column : columns) {
+ sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, column, column));
+ }
+ }
+
+ /**
+ * 判断当前尚在内存中的行是否在指定列包含非空值。
+ *
+ * @param row 当前数据行
+ * @param columnIndex 用于识别行层级的列索引
+ * @return 指定单元格存在且包含非空文本时返回 {@code true}
+ */
+ private boolean hasValue(Row row, int columnIndex) {
+ Cell cell = row.getCell(columnIndex);
+ return cell != null && !cell.toString().trim().isEmpty();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
index 41ea25c2..911c3d71 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
@@ -6,6 +6,9 @@ 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 org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
/**
* 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
@@ -39,6 +42,7 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page,
+ @Param("dto") HardAutoRefundTaskPageDto dto,
+ @Param("now") LocalDateTime now);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
index 40f4ac68..ef4e2303 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
+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;
@@ -35,10 +36,74 @@ public interface HardCarMapper extends PjcloudBaseMapper {
Page productTotal(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 按业务发生日期、餐段和来源分页统计菜品销售。
+ *
+ * @param page 分页参数
+ * @param dto 统计日期、来源、食堂、餐段和部门筛选
+ * @param dataScope 订单所属部门数据范围;为空时不附加范围条件
+ * @return 来源独立分组的菜品销售统计页
+ */
+ Page productTotalStats(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+
+ /**
+ * 查询菜品多维统计行且不应用数据库分页。
+ *
+ * 同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
+ * 服务层按完整自然日分页,导出仅选择餐段来源层。
+ *
+ * @param dto 统计日期、来源、食堂、餐段和部门筛选
+ * @param dataScope 订单所属部门数据范围;为空时不附加范围条件
+ * @return 按日期、餐段和来源层级排序的多维统计行
+ */
+ List listProductTotalStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
+ /**
+ * 按当前主表行口径查询部门汇总,仅供新版菜品统计展开行使用。
+ *
+ * @param dto 单日、餐段、来源、食堂和用户部门筛选
+ * @param dataScope 订单所属部门数据范围;为空时不附加范围条件
+ * @return 按用户部门拆分且在当前来源范围内独立去重的销售统计行
+ */
+ List productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 查询菜品销售三级导出的日期餐段汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
+ */
+ List listSalesExportSummary(@Param("dto") HardOrderDto dto);
+
+ /**
+ * 查询菜品销售三级导出的商户部门汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个日期餐段下商户部门的跨来源合计及各来源统计
+ */
+ List listSalesExportMerchant(@Param("dto") HardOrderDto dto);
+
+ /**
+ * 查询菜品销售三级导出的商品汇总及来源拆分。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @return 每个商户部门块下商品的跨来源合计及各来源统计
+ */
+ List listSalesExportProduct(@Param("dto") HardOrderDto dto);
+
Page proudctQuery(Page page, @Param("dto") HardCarDto dto);
+ /**
+ * 按来源和业务发生时间分页查询菜品汇总明细。
+ *
+ * @param page 分页参数
+ * @param dto 单日、来源、食堂、餐段、部门和商品名称筛选
+ * @return 按商品聚合的来源明细页
+ */
+ Page proudctQueryStats(Page page, @Param("dto") HardCarDto dto);
+
/**
* 菜品出售商户统计
*/
@@ -46,8 +111,41 @@ public interface HardCarMapper extends PjcloudBaseMapper {
List listByDept(@Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 按日期和食堂查询部门统计合计行及其来源拆分行。
+ *
+ * @param dto 统计日期、来源、食堂、餐段和用户部门筛选
+ * @param dataScope 订单所属部门数据范围;为空时不附加范围条件
+ * @return 日期食堂合计行和日期食堂来源行,服务层据此装配主从层级
+ */
+ List listByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
List listByHourType(@Param("dto") HardOrderDto dto);
+ /**
+ * 按日期、部门和餐段查询展开合计行及其来源拆分行。
+ *
+ * @param dto 单日、食堂、来源、餐段和用户部门筛选
+ * @return 部门餐段合计行和部门餐段来源行,服务层据此装配主从层级
+ */
+ List listByHourTypeStats(@Param("dto") HardOrderDto dto);
+
+ /**
+ * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。
+ *
+ * @param parentCarIds 预约订单关联的父购物车主键集合
+ * @return 未删除的购物车商品快照;传入空集合时返回空列表
+ */
+ default List listByParentCarIds(List parentCarIds) {
+ if (parentCarIds == null || parentCarIds.isEmpty()) {
+ return List.of();
+ }
+ return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .in(HardCarEntity::getParentCarId, parentCarIds)
+ .orderByAsc(HardCarEntity::getParentCarId)
+ .orderByAsc(HardCarEntity::getId));
+ }
+
/**
* 菜品销量按照分类查询
*/
@@ -62,4 +160,4 @@ public interface HardCarMapper extends PjcloudBaseMapper {
* 个人菜品喜好排行
*/
List productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java
new file mode 100644
index 00000000..c00bf28f
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderEvidenceImageMapper.java
@@ -0,0 +1,47 @@
+package com.bsd.pjcloud.hard.mapper.order;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 订单识别证据 Mapper,所有查询由租户拦截器自动限定当前租户。
+ */
+@Mapper
+public interface HardOrderEvidenceImageMapper extends PjcloudBaseMapper {
+
+ /**
+ * 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。
+ *
+ * @param orderId 服务端订单主键
+ * @return 当前租户下的订单证据列表
+ */
+ default List listByOrderId(Long orderId) {
+ return selectList(new LambdaQueryWrapper()
+ .eq(HardOrderEvidenceImageEntity::getOrderId, orderId)
+ .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceType)
+ .orderByAsc(HardOrderEvidenceImageEntity::getEvidenceRole)
+ .orderByAsc(HardOrderEvidenceImageEntity::getSequenceNo)
+ .orderByAsc(HardOrderEvidenceImageEntity::getId));
+ }
+
+ /**
+ * 查询订单中的唯一证据位置,用于上传幂等和内容冲突检查。
+ *
+ * @param orderId 服务端订单主键
+ * @param evidenceType 证据类型稳定编码
+ * @param evidenceRole 图片角色稳定编码
+ * @param sequenceNo 同角色图片序号
+ * @return 已存在的证据;不存在时返回 {@code null}
+ */
+ default HardOrderEvidenceImageEntity findPosition(Long orderId, String evidenceType, String evidenceRole, Integer sequenceNo) {
+ return selectOne(new LambdaQueryWrapper()
+ .eq(HardOrderEvidenceImageEntity::getOrderId, orderId)
+ .eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType)
+ .eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole)
+ .eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo));
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
index c87698d7..f7e54249 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
@@ -130,8 +130,43 @@ public interface HardOrderMapper extends PjcloudBaseMapper {
return selectListByScope(commonWrapper(dto), DataScope.of());
}
+ /**
+ * 按订单筛选条件和数据范围分页查询订单详情,并保持创建时间或更新时间倒序。
+ *
+ * @param page 分页参数;App 可见性查询会由服务层指定对应的轻量统计语句
+ * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
+ * @param dataScope 后台数据范围;App 查询传 {@code null}
+ * @return 当前页订单详情及符合相同筛选条件的总数
+ */
Page scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。
+ *
+ * @param page 主键分页参数,排序规则与订单列表保持一致
+ * @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
+ * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
+ * @return 当前页有序订单主键及符合条件的总数
+ */
+ Page appVisibilityPageIds(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+
+ /**
+ * 按已分页的订单主键批量读取展示详情,仅关联当前页设备、最新售后单和付款人。
+ *
+ * @param orderIds 当前页订单主键集合,调用方保证非空且已完成租户和可见范围过滤
+ * @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排
+ */
+ List listAppPageDetails(@Param("orderIds") List orderIds);
+
+ /**
+ * 统计 App 当前访问者可见的订单数量,不执行列表展示字段所需的多表关联。
+ *
+ * @param dto 订单基础筛选、设备类型、最新售后单号及 App 可见范围条件
+ * @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
+ * @return 当前租户内符合全部筛选条件的未删除订单数量
+ */
+ Long appVisibilityCount(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
index 50431a38..90a46764 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
@@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService {
*/
void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId);
+ /**
+ * 按过期预约任务创建并完成每日菜谱整单系统退款;任务快照决定是否恢复库存。
+ *
+ * @param task 过期预约任务,afterSaleId 为空时由本方法创建系统售后
+ */
+ Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task);
+
+ /**
+ * 完成过期预约整单退款,并允许调用方声明是否需要校验过期自动退款开关。
+ * 管理端逐单业务退款即使商户关闭自动开关也必须可用,因此该路径不执行自动开关校验;
+ * 调度器和自动重试路径必须传入 {@code true},继续由 Wallet 做最终开关校验。
+ *
+ * @param task 过期预约退款任务,afterSaleId 为空时由本方法创建系统售后
+ * @param enforceAutoSwitch 是否按自动任务场景校验当前过期退款开关
+ * @return 完成或复用的系统售后单 ID
+ */
+ Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task,
+ boolean enforceAutoSwitch);
+
+ /**
+ * 在资金退款前独立提交过期预约系统售后单及商品快照,确保跨服务退款成功后本地回滚仍可恢复。
+ *
+ * @param task 过期预约任务,必须尚未关联售后单
+ * @return 新建或已存在的系统售后单 ID
+ */
+ Long prepareExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task);
+
/**
* 同意售后
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
index 4d5f3bbc..fa8a34ee 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
@@ -39,8 +39,9 @@ public interface HardApptReservationService {
* @param apptDate 实际用餐日期
* @param apptRuleId 周期预约规则 ID;动态模式为空
* @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
+ * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款
*/
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
- LocalDateTime eligibilityDeadline) {
+ LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) {
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
index c88ac2c1..212fb749 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
@@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService pageTasks(Page page, HardAutoRefundTaskPageDto dto);
+ /** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
+ Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto);
+
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService {
*/
void doneBroadcast(HardOrderEntity orderdetail, UserInfo userInfo);
+ /**
+ * 将误核销预约订单的取餐广播恢复为待处理,并通知当前商户的取餐设备重新展示。
+ *
+ * @param orderdetail 已由订单服务校验为线上预订且正在回退的订单
+ */
+ void rollbackDoneBroadcast(HardOrderEntity orderdetail);
+
/**
* android完成一轮广播
*/
void androidDoneBroadcast(HardAdBroadcastDto dto);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java
new file mode 100644
index 00000000..53172794
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalExportService.java
@@ -0,0 +1,50 @@
+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;
+
+/**
+ * 食堂预约统计导出服务。
+ *
+ * 导出属于独立的长耗时任务边界,负责异步任务、分批读取、Easy Excel 写入和文件中心上传;
+ * 普通统计服务只负责页面查询和统计,不持有导出任务状态。
+ */
+public interface HardCanteenApptTotalExportService {
+
+ /**
+ * 提交用户预约异步导出任务。
+ *
+ * @param query 用户预约日期、部门、食堂、餐段和状态筛选条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
+
+ /**
+ * 兼容旧同步用户预约导出接口。
+ *
+ * @param query 用户预约筛选条件
+ * @return 用户预约导出行
+ */
+ List userReservationExport(HardCanteenUserApptQueryDto query);
+
+ /**
+ * 提交菜品预约异步导出任务。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 可供前端轮询的初始任务进度
+ */
+ AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
+
+ /**
+ * 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 按日期餐段、商户部门、商品三级结构排列的导出行
+ */
+ List productReservationExport(HardOrderDto query);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java
index a463b076..61eba838 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java
@@ -1,14 +1,13 @@
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.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.HardCanteenUserApptVo;
-import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import java.time.LocalDate;
import java.util.List;
@@ -47,18 +46,11 @@ public interface HardCanteenApptTotalService {
List userReservationDetail(Long userId, LocalDate apptUseDate);
/**
- * 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。
+ * 按当前用户预约查询条件统计每日和查询范围内的预约人数、餐段及菜品。
*
- * @param query 用户、日期范围和预约状态筛选条件
- * @return 供前端轮询的初始任务进度
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 按日和总计组织的预约统计结果
*/
- AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
+ HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query);
- /**
- * 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。
- *
- * @param query 用户、日期范围和预约状态筛选条件
- * @return 用户和日期维度的 Excel 导出行,最后一行为餐段及当日金额合计
- */
- List userReservationExport(HardCanteenUserApptQueryDto query);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java
index 7dec6c5f..7502b74e 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenTotalService.java
@@ -15,6 +15,7 @@ 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.HardCanteenProductApptExcelVo;
import java.util.List;
import java.util.Map;
@@ -42,7 +43,12 @@ public interface HardCanteenTotalService {
List productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope);
/**
- * 食堂菜品购买统计
+ * 分页查询食堂菜品购买统计。
+ *
+ * @param page 分页参数;来源统计模式下每页数量表示自然日数量
+ * @param dto 统计日期、来源、食堂、餐段和部门筛选
+ * @param dataScope 订单部门数据范围;为空时不追加数据范围条件
+ * @return 当日合计和餐段合计行,来源统计模式下每行包含对应来源明细
*/
Page productTotal(Page page, HardOrderDto dto, DataScope dataScope);
@@ -60,4 +66,16 @@ public interface HardCanteenTotalService {
* 食堂菜品购买部门统计
*/
List listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope);
+
+ /**
+ * 按页面筛选条件导出统一的菜品销售三级统计。
+ *
+ * 每个日期餐段同时生成当前来源范围的合计块以及各来源块,每个块继续按商户部门和商品展开;
+ * 菜品统计页与部门统计页共用该方法,避免两套导出口径再次分叉。
+ *
+ * @param dto 统计日期、来源、餐段、食堂和部门筛选条件
+ * @param dataScope 部门数据权限;为空时沿用控制器既有范围
+ * @return 日期餐段、商户部门、商品三级排列的导出行
+ */
+ List exportSales(HardOrderDto dto, DataScope dataScope);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
index fe8e1f9f..356b7b78 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
@@ -25,6 +25,14 @@ public interface HardCarService extends IService {
List getByParentCarId(Long parentCarId);
+ /**
+ * 按父购物车主键批量查询未删除的购物车商品快照,供订单分页结果一次性装配商品列表。
+ *
+ * @param parentCarIds 父购物车主键集合,空集合不发起数据库查询
+ * @return 按父购物车主键和商品主键排序的商品快照
+ */
+ List getByParentCarIds(List parentCarIds);
+
List mchDetail(Long merchantId, Long userId, HardTypeEnum hardType);
Integer computerCarTotalPrice(@NotNull HardTypeEnum sysType, @NotNull HardOrderTypeEnum orderType, @NotEmpty List list);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java
new file mode 100644
index 00000000..2312cabb
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderEvidenceImageService.java
@@ -0,0 +1,66 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceMetadataDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceSubmitDto;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderEvidenceUploadDto;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEvidenceImageEntity;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceImageVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceResolveVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderEvidenceUploadResultVo;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+/**
+ * 订单识别证据服务,负责设备与订单校验、幂等占位、现场图上传和参考图复制。
+ */
+public interface HardOrderEvidenceImageService extends IService {
+
+ /**
+ * 按终端订单号或服务端订单 ID 对账订单和已登记证据。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param androidOrderCode 终端本地稳定订单号,预约核销证据允许为空
+ * @param orderId 服务端订单主键,现场下单证据允许为空
+ * @return 服务端订单及证据状态
+ */
+ HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId);
+
+ /**
+ * 提交证据元数据、触发参考图复制或报告永久缺失。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param dto 证据位置、识别快照和目标状态
+ * @return 幂等处理结果
+ */
+ HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto);
+
+ /**
+ * 上传终端现场证据图片,实际文件内容会与终端哈希和尺寸交叉校验。
+ *
+ * @param hardId 请求头中的当前设备主键
+ * @param dto 订单与证据元数据
+ * @param file JPEG 或 PNG 图片
+ * @return 幂等上传结果
+ */
+ HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file);
+
+ /**
+ * 为已成功创建的订单登记证据占位;该操作失败不得回滚订单事务。
+ *
+ * @param order 已创建订单快照
+ * @param metadataList 终端随订单提交的可选证据元数据
+ * @param appVersion 终端应用版本
+ */
+ void registerOrderEvidence(HardOrderEntity order, List metadataList, String appVersion);
+
+ /**
+ * 查询订单详情所需的证据列表并补齐中文展示名称。
+ *
+ * @param orderId 服务端订单主键
+ * @return 排序后的证据列表
+ */
+ List listByOrderId(Long orderId);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index 473680f2..d5d02cba 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -52,6 +52,14 @@ public interface HardOrderService extends IService {
*/
Page appMchPageJoin(Page page, HardOrderDto dto, PjcloudUser user);
+ /**
+ * 按筛选条件和数据范围分页查询订单,并装配列表展示所需的关联信息。
+ *
+ * @param page 订单分页参数
+ * @param dto 订单筛选与关联信息加载选项
+ * @param dataScope 后台数据范围;App 查询传 {@code null}
+ * @return 订单分页结果
+ */
Page scopePageList(Page page, HardOrderDto dto, DataScope dataScope);
/**
@@ -68,6 +76,14 @@ public interface HardOrderService extends IService {
HardOrderVo join(Long id);
+ /**
+ * 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。
+ *
+ * @param id 订单主键
+ * @return 订单详情及识别证据;订单不存在时返回 {@code null}
+ */
+ HardOrderVo joinWithEvidence(Long id);
+
/**
* 查询并校验 App 用户可查看的订单详情。
*/
@@ -268,11 +284,29 @@ public interface HardOrderService extends IService {
void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce);
+ /**
+ * 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。
+ *
+ * @param order 原订单
+ * @param refundCarList 本次退款商品;为空表示整单商品
+ * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存
+ * @param afterSaleId 本次售后单 ID,非退款调用可为空
+ */
+ void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId);
+
/**
* 核销硬件二维码
*/
Boolean handCancelCode(HandCancelCodeDto dto);
+ /**
+ * 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。
+ *
+ * @param orderId 已核销的线上预订订单主键
+ * @return 订单及二维码均恢复成功时返回 {@code true}
+ */
+ Boolean rollbackReserveVerification(Long orderId);
+
/**
* 核销二维码
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
index 3c1e3be6..43a8c4ba 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
@@ -357,7 +357,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!availability.available()) {
throw new RuntimeException(availability.unavailableReason());
}
- return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime());
+ return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(),
+ availability.endDateTime());
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
@@ -377,7 +378,34 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!match.availability().available()) {
throw new RuntimeException(match.availability().unavailableReason());
}
- return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime());
+ return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(),
+ resolveCycleUsageEndDateTime(mchId, hourType, apptDate));
+ }
+
+ /**
+ * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析,
+ * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。
+ *
+ * @param mchId 订单所属商户 ID
+ * @param hourType 订单选中的标准餐段编码
+ * @param apptDate 实际用餐日期
+ * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null}
+ */
+ private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) {
+ List candidates = hardApptService.list(new LambdaQueryWrapper()
+ .eq(HardApptEntity::getMchId, mchId)
+ .eq(HardApptEntity::getHourType, hourType)
+ .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
+ .orderByAsc(HardApptEntity::getId));
+ if (candidates.size() != 1) {
+ return null;
+ }
+ HardApptEntity appt = candidates.get(0);
+ HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
+ if (endType == null || appt.getEndTime() == null) {
+ return null;
+ }
+ return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java
index f7cfbfe0..0d70dd8a 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardBroadcastServiceImpl.java
@@ -214,6 +214,73 @@ public class HardBroadcastServiceImpl extends ServiceImpl()
+ .eq(HardBroadcastEntity::getBizId, orderdetail.getId())
+ .orderByDesc(HardBroadcastEntity::getCreateTime)
+ .orderByDesc(HardBroadcastEntity::getId)
+ .last("limit 1"));
+ if (ObjectUtil.isEmpty(broadcastDetail)) return;
+ baseMapper.update(null, new LambdaUpdateWrapper()
+ .set(HardBroadcastEntity::getIsBroadcast, YesNoEnum.YES.getCode())
+ .set(HardBroadcastEntity::getIsDone, YesNoEnum.NO.getCode())
+ .set(HardBroadcastEntity::getIsDoneTime, null)
+ .eq(HardBroadcastEntity::getBizId, String.valueOf(orderdetail.getId())));
+
+ UserInfo userInfo;
+ try {
+ R userResult = remoteUserService.getInnerByUserId(orderdetail.getUserId());
+ userInfo = userResult == null ? null : userResult.getData();
+ } catch (Exception exception) {
+ log.warn("|{}|恢复预约广播时查询用户失败,orderId:|{}|,errorType:|{}|", TAG,
+ orderdetail.getId(), exception.getClass().getSimpleName());
+ return;
+ }
+ if (userInfo == null || userInfo.getSysUser() == null) return;
+ List hardList = hardManagerMapper.selectList(new LambdaQueryWrapper()
+ .eq(HardManagerEntity::getMerchantId, orderdetail.getMerchantId())
+ .eq(HardManagerEntity::getIsTakeFood, YesNoEnum.YES.getCode()));
+ if (ObjectUtil.isEmpty(hardList)) return;
+ for (HardManagerEntity hard : hardList) {
+ try {
+ String topic = hard.getMainTopic().replaceAll("/#", "");
+ HardTakeFoodBroadcastMsg msg = new HardTakeFoodBroadcastMsg();
+ msg.setTopic(topic);
+ msg.setMchDeptId(hard.getDeptId());
+ msg.setHardwareId(hard.getId());
+ msg.setAction(MqttActionEnum.TAKE_FOOD_BROADCAST);
+ msg.setUserId(String.valueOf(userInfo.getSysUser().getUserId()));
+ msg.setUsername(userInfo.getSysUser().getName());
+ msg.setUserMobile(userInfo.getSysUser().getPhone());
+ msg.setId(String.valueOf(broadcastDetail.getId()));
+ msg.setOrderId(String.valueOf(orderdetail.getId()));
+ msg.setTitle(broadcastDetail.getTitle());
+ msg.setContent(broadcastDetail.getContent());
+ msg.setBroadcastMaxCount(broadcastDetail.getBroadcastMaxCount());
+ msg.setProductDesc(orderdetail.getProductDesc());
+ msg.setPayPrice(orderdetail.getPayPrice());
+ msg.setHourType(orderdetail.getHourType());
+ msg.setIsDone(YesNoEnum.NO.getCode());
+ remoteMqttEventService.sendTakeFoodBroadcast(msg);
+ } catch (Exception exception) {
+ log.warn("|{}|恢复预约广播实时通知失败,orderId:|{}|,hardId:|{}|,errorType:|{}|", TAG,
+ orderdetail.getId(), hard.getId(), exception.getClass().getSimpleName());
+ }
+ }
+ }
+
/**
* android完成一轮广播
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
index c4a17493..b9364e4d 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/VerificationRecordServiceImpl.java
@@ -22,15 +22,28 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
+/** 终端预约核销记录查询服务,统一使用订单独立核销时间完成筛选和展示。 */
@Service
@RequiredArgsConstructor
public class VerificationRecordServiceImpl implements VerificationRecordService {
+ /** 当前接口支持的预约核销类型协议值。 */
private static final String TYPE_RESERVATION = "RESERVATION";
+ /** 设备服务,用于校验请求设备存在且与查询设备一致。 */
private final HardManagerService hardManagerService;
+
+ /** 订单服务,用于按设备、订单类型、状态和核销时间分页查询。 */
private final HardOrderService hardOrderService;
+ /**
+ * 分页查询指定设备在日期范围内完成的预约核销记录。
+ *
+ * @param page 分页参数,页码最小为 1、每页最大为 50
+ * @param hardId 当前请求头中的设备主键
+ * @param dto 核销类型、设备和日期范围查询条件
+ * @return 已按核销时间倒序排列的终端核销记录分页
+ */
@Override
public Page page(Page> page, Long hardId, VerificationRecordQueryDto dto) {
if (!Objects.equals(hardId, dto.getHardId())) {
@@ -57,7 +70,7 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
query.setOrderStatusCodes(List.of(HardOrderStatusEnum.CANCEL.getType()));
// 活动核销历史上复用了预约订单类型,支付方式范围需明确排除 ACT_CANCEL。
query.setExcludeOrderPayTypeCodes(List.of(HardPayTypeEnum.ACT_CANCEL.getType()));
- query.setUpdateTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay()});
+ query.setVerificationTimeList(new LocalDateTime[]{startDate.atStartOfDay(), endDate.atTime(23, 59, 59)});
long current = Math.max(1, page.getCurrent());
long size = Math.min(50, Math.max(1, page.getSize()));
@@ -71,6 +84,12 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
return result;
}
+ /**
+ * 将订单查询结果转换为终端核销记录展示对象。
+ *
+ * @param order 已完成核销的预约订单
+ * @return 终端核销记录
+ */
private static VerificationRecordVo convert(HardOrderVo order) {
VerificationRecordVo vo = new VerificationRecordVo();
vo.setOrderId(order.getId());
@@ -87,7 +106,15 @@ public class VerificationRecordServiceImpl implements VerificationRecordService
return vo;
}
+ /**
+ * 解析核销记录的展示时间;新数据使用独立核销时间,旧数据按更新时间、支付时间、创建时间依次降级。
+ *
+ * @param order 已核销订单
+ * @return 可供终端展示的核销时间,历史数据全部缺失时返回 {@code null}
+ */
private static LocalDateTime resolveVerificationTime(HardOrderVo order) {
+ if (order.getVerificationTime() != null) return order.getVerificationTime();
+ // 历史订单在新增独立字段前没有核销时间,兼容返回最近更新时间,避免旧记录在终端列表中变为空。
if (order.getUpdateTime() != null) return order.getUpdateTime();
if (order.getPayTime() != null) return order.getPayTime();
return order.getCreateTime();
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
index 1b9958b1..ed069be6 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
@@ -164,9 +164,8 @@ public class HardThirdSelfZyServiceImpl implements HardThirdSelfZyService {
.content(HardSaleStatusEnum.COMPLETE.getContent())
.build();
hardAfterSaleLogMapper.insert(saveLogObj);
- // 执行退款
+ // 正元订单不属于每日菜谱,普通库存接口没有跨服务补偿能力,保持先退款后恢复库存。
hardAfterSaleService.refund(saveObj.getId(), "zy");
- // 退库存
hardOrderService.fastenerInventory(order, YesNoEnum.NO);
HardCallbackRefundVo result = new HardCallbackRefundVo();
result.setCode(HardCallbackRefundStatus.SUCCESS);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
index bf69eb2a..98ca45dd 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
@@ -23,6 +23,7 @@ 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.HardProductNatureEnum;
+import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
@@ -31,6 +32,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleOperateTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleWayEnum;
import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto;
@@ -142,7 +144,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl orderList = hardOrderMapper.list(hardOrderDto);
HardUtil.convertOrderList(list.getRecords(), CollectionUtils.convertMap(orderList, HardOrderEntity::getId));
}
- fillAutoRefundTaskStatus(list.getRecords());
+ fillAutoRefundTaskInfo(list.getRecords());
}
return list;
}
@@ -167,7 +169,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto);
if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) {
throw new RuntimeException("钱包退款错误");
@@ -639,7 +657,15 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
- if (ObjectUtil.isEmpty(refundCars)) {
- hardOrderService.fastenerInventory(order, YesNoEnum.NO);
- } else {
- hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
- }
recomputeOrderRefundState(order.getId());
}
+ /**
+ * 过期预约任务的系统整单退款。订单和任务已由任务状态机串行保护,这里仍锁订单行并重新核对可退条件,
+ * 防止核销、人工退款或重复调度在任务到期前改变订单状态。退库存只读取任务快照,不读取当前商户配置。
+ *
+ * @param task 过期预约任务,首次执行时售后单 ID 为空
+ * @return 创建或复用的系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task) {
+ return completeExpiredReserveRefund(task, true);
+ }
+
+ /**
+ * 过期预约任务的系统整单退款实现。自动调度需要校验当前开关,管理端逐单业务退款不受商户关闭开关影响。
+ * 订单、售后和库存仍统一由订单锁与本地事务保护,避免人工操作绕过支付、核销和重复退款校验。
+ *
+ * @param task 过期预约任务,首次执行时售后单 ID 为空
+ * @param enforceAutoSwitch 自动调度/重试为 {@code true},管理端逐单退款为 {@code false}
+ * @return 创建或复用的系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task, boolean enforceAutoSwitch) {
+ if (task == null || task.getOrderId() == null) {
+ throw new RuntimeException("过期预约任务数据异常");
+ }
+ if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) {
+ throw new RuntimeException("过期预约退款尚未到达触发时间");
+ }
+ HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId());
+ if (order == null) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
+ || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
+ throw new RuntimeException("订单不满足过期预约退款条件");
+ }
+ List afterSales = listOrderAfterSales(order.getId());
+ if (afterSales.stream().anyMatch(item -> !Objects.equals(item.getId(), task.getAfterSaleId())
+ && Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
+ throw new RuntimeException("订单已有处理中售后");
+ }
+ int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus());
+ if (completedRefundPrice > 0 || safePrice(order.getPayPrice()) <= 0) {
+ throw new RuntimeException("订单已退款或金额异常");
+ }
+ if (task.getAfterSaleId() == null) {
+ throw new RuntimeException("过期预约退款售后单尚未准备完成");
+ }
+ HardAfterSaleEntity afterSale = baseMapper.selectById(task.getAfterSaleId());
+ if (afterSale == null || !Objects.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) {
+ throw new RuntimeException("过期预约退款售后单状态异常");
+ }
+ if (Objects.equals(task.getRestoreInventory(), YesNoEnum.YES.getCode())) {
+ restoreRefundInventory(order, afterSale.getId());
+ }
+ refundInCurrentTransaction(afterSale.getId(), "hard_expired",
+ enforceAutoSwitch ? task.getConfigId() : null,
+ enforceAutoSwitch ? WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE : null);
+ HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSale.getId())
+ .status(HardSaleStatusEnum.COMPLETE.getStatus()).auditTime(LocalDateTime.now()).refundTime(LocalDateTime.now()).build();
+ if (baseMapper.updateById(completed) != 1) {
+ throw new RuntimeException("更新过期退款售后状态失败");
+ }
+ insertAfterSaleLog(afterSale.getId(), afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(),
+ HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(),
+ HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent());
+ recomputeOrderRefundState(order.getId());
+ return afterSale.getId();
+ }
+
+ /**
+ * 在独立事务中创建过期预约系统售后单和商品快照;不调用钱包,提交后由任务状态机执行资金退款。
+ *
+ * @param task 过期预约任务,订单已由任务状态机按订单锁串行
+ * @return 新建系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long prepareExpiredReserveRefund(HardAutoRefundTaskEntity task) {
+ if (task == null || task.getOrderId() == null || task.getAfterSaleId() != null) {
+ throw new RuntimeException("过期预约退款任务数据异常");
+ }
+ if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) {
+ throw new RuntimeException("过期预约退款尚未到达触发时间");
+ }
+ HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId());
+ if (order == null) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
+ || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
+ throw new RuntimeException("订单不满足过期预约退款条件");
+ }
+ List afterSales = listOrderAfterSales(order.getId());
+ if (afterSales.stream().anyMatch(item -> Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
+ throw new RuntimeException("订单已有处理中售后");
+ }
+ if (sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()) > 0 || safePrice(order.getPayPrice()) <= 0) {
+ throw new RuntimeException("订单已退款或金额异常");
+ }
+ HardAfterSaleEntity afterSale = HardAfterSaleEntity.builder()
+ .status(HardSaleStatusEnum.APPLY.getStatus())
+ .no("hard_expired_" + order.getId())
+ .orderId(order.getId())
+ .orderNo(String.valueOf(order.getId()))
+ .userId(order.getUserId())
+ .way(HardAfterSaleWayEnum.REFUND.getType())
+ .type(HardSaleTypeEnum.IN_SALE.getType())
+ .refundPrice(safePrice(order.getPayPrice()))
+ .applyReason("预约订单过期自动退款")
+ .applyDescription("系统按预约餐段结束时间自动退款")
+ .build();
+ if (baseMapper.insert(afterSale) != 1) {
+ throw new RuntimeException("创建过期预约退款售后单失败");
+ }
+ hardAfterSaleCarService.create(buildRemainingRefundCars(order, getOriginalCars(order)), order, afterSale);
+ insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(),
+ order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent());
+ return afterSale.getId();
+ }
+
@Override
public Boolean agreeAfterSale(Long userId, Long id) {
HardAfterSaleEntity current = baseMapper.selectById(id);
@@ -693,13 +838,14 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(id);
- if (ObjectUtil.isEmpty(refundCars)) {
- hardOrderService.fastenerInventory(order, YesNoEnum.NO);
- } else {
- hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
+ if (!canteenSpuInventoryOrder) {
+ restoreOrderInventory(order, id);
}
recomputeOrderRefundState(order.getId());
return true;
@@ -779,33 +925,90 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId());
+ Map requestedCountByProductId = new LinkedHashMap<>();
for (HardAfterSaleCarEntity refundCar : refundCarList) {
- HardCarEntity orderCar = orderCarList.stream()
- .filter(v -> ObjectUtil.equal(v.getProductId(), refundCar.getProductId())
- || ObjectUtil.equal(v.getId(), refundCar.getId()))
- .findFirst().orElse(null);
+ if (refundCar == null || (refundCar.getProductId() == null && refundCar.getId() == null)) {
+ throw new RuntimeException("退款商品不能为空");
+ }
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
if (orderCar == null) throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">不存在");
- int refundCount = 0;
- if (ObjectUtil.isNotEmpty(refundCar.getProductCount())) {
- refundCount = refundCar.getProductCount();
+ // 后续金额、售后快照和库存恢复都以服务端商品 ID 为准,兼容后台历史提交的购物车行 ID。
+ refundCar.setProductId(orderCar.getProductId());
+ // 按数量商品必须提交有效份数;称重商品历史请求可能只提交重量,数量按零参与数量校验。
+ if (HardProductNatureEnum.DEFAULT.getType().equals(orderCar.getProductNature())
+ && refundCar.getProductCount() == null) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为空");
}
- // 已经退货成功的商品列表
- if (ObjectUtil.isNotEmpty(refundSuccessCarList)) {
- // 售后明细 ID 是退款记录自身 ID,不能与原购物车 ID 比较;使用服务端订单商品 ID 防止重复超额退款。
- HardAfterSaleCarEntity refundSuccessCar = refundSuccessCarList.stream()
- .filter(v -> ObjectUtil.equal(v.getProductId(), orderCar.getProductId()))
- .findFirst()
- .orElse(null);
- if (ObjectUtil.isNotEmpty(refundSuccessCar)) {
- refundCount += refundSuccessCar.getProductCount();
- }
+ if (refundCar.getProductCount() != null && refundCar.getProductCount() < 0) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为负数");
}
- int originalCount = orderCarList.stream().filter(item -> ObjectUtil.equal(item.getProductId(), orderCar.getProductId()))
- .map(HardCarEntity::getProductCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
- if (originalCount < refundCount) {
- throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">数量超出");
+ int requestCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
+ try {
+ requestedCountByProductId.merge(orderCar.getProductId(), requestCount, Math::addExact);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量异常");
}
}
+ Map occupiedCountByProductId = refundSuccessCarList.stream()
+ .filter(item -> item != null && item.getProductId() != null)
+ .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId,
+ item -> item.getProductCount() == null ? 0 : item.getProductCount(),
+ (left, right) -> {
+ try {
+ return Math.addExact(left, right);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("历史退款商品数量异常");
+ }
+ }, LinkedHashMap::new));
+ Map originalCountByProductId = orderCarList.stream()
+ .filter(item -> item.getProductId() != null)
+ .collect(Collectors.toMap(HardCarEntity::getProductId,
+ item -> item.getProductCount() == null ? 0 : item.getProductCount(),
+ (left, right) -> {
+ try {
+ return Math.addExact(left, right);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("订单商品数量异常");
+ }
+ }, LinkedHashMap::new));
+ requestedCountByProductId.forEach((productId, requestedCount) -> {
+ int occupiedCount = occupiedCountByProductId.getOrDefault(productId, 0);
+ int originalCount = originalCountByProductId.getOrDefault(productId, 0);
+ final int totalRefundCount;
+ try {
+ totalRefundCount = Math.addExact(occupiedCount, requestedCount);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品数量异常");
+ }
+ if (totalRefundCount > originalCount) {
+ HardCarEntity orderCar = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), productId))
+ .findFirst().orElse(null);
+ throw new RuntimeException("退款商品<" + (orderCar == null ? productId : orderCar.getProductName()) + ">数量超出");
+ }
+ });
+ }
+
+ /**
+ * 将退款明细解析到原订单购物车行,优先使用商品 ID,兼容后台历史提交的购物车行 ID。
+ *
+ * @param orderCarList 原订单购物车快照
+ * @param refundCar 客户端提交或服务端组装的退款明细
+ * @return 匹配的原订单购物车行;无法匹配时返回 {@code null}
+ */
+ private HardCarEntity resolveRefundOrderCar(List orderCarList, HardAfterSaleCarEntity refundCar) {
+ if (refundCar == null) {
+ return null;
+ }
+ HardCarEntity byProductId = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), refundCar.getProductId()))
+ .findFirst().orElse(null);
+ if (byProductId != null) {
+ return byProductId;
+ }
+ return orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId()))
+ .findFirst().orElse(null);
}
/**
@@ -890,21 +1093,29 @@ public class HardAfterSaleServiceImpl extends ServiceImpl records) {
+ private void fillAutoRefundTaskInfo(List records) {
List afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList();
if (afterSaleIds.isEmpty()) {
return;
}
- Map taskStatusMap = hardAutoRefundTaskService.list(
+ Map taskMap = hardAutoRefundTaskService.list(
new LambdaQueryWrapper()
.in(HardAutoRefundTaskEntity::getAfterSaleId, afterSaleIds))
.stream().collect(Collectors.toMap(HardAutoRefundTaskEntity::getAfterSaleId,
- HardAutoRefundTaskEntity::getStatus, (first, second) -> first));
- records.forEach(item -> item.setAutoRefundTaskStatus(taskStatusMap.get(item.getId())));
+ item -> item, (first, second) -> first));
+ records.forEach(item -> {
+ HardAutoRefundTaskEntity task = taskMap.get(item.getId());
+ if (task != null) {
+ item.setAutoRefundTaskStatus(task.getStatus());
+ item.setAutoRefundTaskType(task.getTaskType());
+ item.setAutoRefundRuleType(task.getRuleType());
+ item.setAutoRefundEligibilityDeadline(task.getEligibilityDeadline());
+ }
+ });
}
/**
@@ -1445,13 +1656,22 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = isDailyMenuOrder(order)
+ ? buildDailyMenuAdminRefundCars(order, dto.getCarList()) : dto.getCarList();
+ hardAfterSaleCarService.createAdmin(refundCars, order, saveObj);
}
// 添加售后进度
HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder()
@@ -1551,8 +1773,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) ->
HardCarEntity.builder()
.productId(item.getProductId())
@@ -1561,10 +1788,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品,
+ * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离,
+ * 使重复回调不会重复增加库存。
+ *
+ * @param order 原每日菜谱预约订单或机器收银订单
+ * @param afterSaleId 本次售后单 ID,不能为空
+ */
+ private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) {
+ if (afterSaleId == null) {
+ throw new RuntimeException("食堂菜品退款库存缺少售后单 ID");
+ }
+ List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
+ if (ObjectUtil.isEmpty(refundCars)) {
+ hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId);
+ return;
+ }
+ hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId);
+ }
+
+ /**
+ * 恢复普通商品库存,保持旧库存服务的调用协议和退款后执行顺序。
+ *
+ * @param order 原订单
+ * @param afterSaleId 售后单 ID,仅用于读取部分退款商品快照
+ */
+ private void restoreOrderInventory(HardOrderEntity order, Long afterSaleId) {
+ List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
+ if (ObjectUtil.isEmpty(refundCars)) {
+ hardOrderService.fastenerInventory(order, YesNoEnum.NO);
+ } else {
+ hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
+ }
+ }
+
/**
* 使用下单时保存的购物车成交价快照计算每日菜谱退款金额,退款时不再依赖可能已下架或改价的商品主数据。
* 请求仅提供退款数量,商品和价格均以服务端订单购物车为准。
@@ -1599,26 +1885,116 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCountByProductId = new LinkedHashMap<>();
try {
for (HardAfterSaleCarEntity refundCar : refundCarList) {
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
+ if (orderCar == null || orderCar.getProductId() == null) {
+ throw new RuntimeException("退款商品不存在");
+ }
int refundCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
- if (refundCount <= 0) {
+ if (refundCount < 0) {
+ throw new RuntimeException("退款商品数量不能为负数");
+ }
+ if (refundCount == 0) {
continue;
}
- HardCarEntity orderCar = orderCarList.stream()
- .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId()))
- .findFirst()
- .orElseThrow(() -> new RuntimeException("退款商品不存在"));
- if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
- throw new RuntimeException("退款商品价格异常");
- }
- refundPrice = Math.addExact(refundPrice, Math.multiplyExact(orderCar.getProductPrice(), refundCount));
+ refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact);
}
+
+ int refundPrice = 0;
+ for (Map.Entry entry : refundCountByProductId.entrySet()) {
+ int remainingCount = entry.getValue();
+ List productCars = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), entry.getKey()))
+ .toList();
+ for (HardCarEntity orderCar : productCars) {
+ if (remainingCount <= 0) {
+ break;
+ }
+ int originalCount = orderCar.getProductCount() == null ? 0 : orderCar.getProductCount();
+ int currentCount = Math.min(remainingCount, Math.max(originalCount, 0));
+ if (currentCount <= 0) {
+ continue;
+ }
+ if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
+ throw new RuntimeException("退款商品价格异常");
+ }
+ refundPrice = Math.addExact(refundPrice,
+ Math.multiplyExact(orderCar.getProductPrice(), currentCount));
+ remainingCount -= currentCount;
+ }
+ if (remainingCount > 0) {
+ throw new RuntimeException("退款商品数量超出订单数量");
+ }
+ }
+ return refundPrice;
} catch (ArithmeticException exception) {
throw new RuntimeException("退款金额超出计算范围", exception);
}
- return refundPrice;
+ }
+
+ /**
+ * 按原订单购物车生成后台每日菜谱退款快照,丢弃客户端携带的名称、价格和总价,避免后续整单退款读取不可信金额。
+ * 同一商品多行时先聚合数量,库存恢复和历史退款占用保持同一商品维度。
+ *
+ * @param order 每日菜谱预约订单
+ * @param refundCarList 后台提交的退款商品及数量
+ * @return 使用原订单商品资料和成交价计算的可信售后明细
+ */
+ private List buildDailyMenuAdminRefundCars(HardOrderEntity order,
+ List refundCarList) {
+ List orderCarList = hardCarService.getByParentCarId(order.getParentCarId());
+ if (ObjectUtil.isEmpty(orderCarList)) {
+ throw new RuntimeException("订单购物车不存在");
+ }
+ Map orderCarMap = orderCarList.stream()
+ .filter(item -> item.getProductId() != null)
+ .collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left,
+ LinkedHashMap::new));
+ Map refundCountByProductId = new LinkedHashMap<>();
+ for (HardAfterSaleCarEntity refundCar : refundCarList) {
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
+ int refundCount = refundCar == null || refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
+ if (orderCar == null || orderCar.getProductId() == null) {
+ throw new RuntimeException("退款商品不存在");
+ }
+ if (refundCount < 0) {
+ throw new RuntimeException("退款商品数量不能为负数");
+ }
+ if (refundCount == 0) {
+ continue;
+ }
+ try {
+ refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品数量异常", exception);
+ }
+ }
+ List result = new ArrayList<>(refundCountByProductId.size());
+ for (Map.Entry entry : refundCountByProductId.entrySet()) {
+ HardCarEntity orderCar = orderCarMap.get(entry.getKey());
+ if (orderCar == null || orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
+ throw new RuntimeException("退款商品价格异常");
+ }
+ long totalPrice;
+ try {
+ totalPrice = Math.multiplyExact((long) orderCar.getProductPrice(), entry.getValue());
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款金额超出计算范围", exception);
+ }
+ HardAfterSaleCarEntity trustedCar = new HardAfterSaleCarEntity();
+ trustedCar.setProductId(orderCar.getProductId());
+ trustedCar.setProductName(orderCar.getProductName());
+ trustedCar.setProductPic(orderCar.getProductPic());
+ trustedCar.setProductPrice(orderCar.getProductPrice().longValue());
+ trustedCar.setProductNature(orderCar.getProductNature());
+ trustedCar.setProductCount(entry.getValue());
+ trustedCar.setProductWeight(orderCar.getProductWeight());
+ trustedCar.setTotalPrice(totalPrice);
+ result.add(trustedCar);
+ }
+ return result;
}
/**
@@ -1719,10 +2095,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl()
+ .eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
+ .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
+ if (existing != null) {
+ return existing;
+ }
+ HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
+ .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .orderId(order.getId()).mchId(order.getMerchantId())
+ .configId(order.getExpiredRefundConfigId())
+ .triggerTime(order.getExpiredRefundDeadline())
+ .restoreInventory(order.getExpiredRefundRestoreInventory())
+ .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .autoAttemptCount(0).totalAttemptCount(0)
+ .nextRetryTime(order.getExpiredRefundDeadline())
+ .build();
+ if (baseMapper.insert(task) != 1) {
+ throw new RuntimeException("创建过期预约退款任务失败");
+ }
+ return task;
+ }
+
/**
* 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。
*
@@ -240,6 +284,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) {
+ if (List.of(HardAutoRefundTriggerTypeEnum.MANUAL, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL).contains(triggerType)
+ || autoAttempts >= MAX_AUTO_ATTEMPTS) {
task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
task.setNextRetryTime(null);
} else {
@@ -595,12 +729,22 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page,
+ HardAutoRefundTaskPageDto dto) {
+ LocalDateTime now = LocalDateTime.now();
+ Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
+ List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
+ fillAfterSaleData(records);
+ fillOrderData(records);
+ fillUserData(records);
+ fillMerchantData(records);
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(records);
+ return result;
+ }
+
+ /** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
+ private void fillOrderData(List records) {
+ List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
+ if (orderIds.isEmpty()) return;
+ Map orders = hardOrderMapper.selectBatchIds(orderIds).stream()
+ .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (first, second) -> first));
+ records.forEach(item -> {
+ HardOrderEntity order = orders.get(item.getOrderId());
+ if (order != null) {
+ item.setUserId(order.getUserId());
+ item.setRefundPrice(order.getPayPrice());
+ item.setApptUseDate(order.getApptUseDate());
+ item.setHourType(order.getHourType());
+ }
+ });
+ }
+
/**
* 按售后唯一键查询任务。
*
@@ -870,9 +1081,19 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl()
.and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .le(HardAutoRefundTaskEntity::getTriggerTime, now)))
.or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
- .le(HardAutoRefundTaskEntity::getNextRetryTime, now)))
+ .le(HardAutoRefundTaskEntity::getNextRetryTime, now)
+ .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
.last("limit " + limit)));
@@ -899,6 +1120,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过
+ * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。
+ */
+@Slf4j
+@Validated
+@Service
+@RequiredArgsConstructor
+public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTotalExportService {
+
+ /** 用户预约导出任务的 Redis Key 命名空间。 */
+ private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export";
+
+ /** 用户预约导出任务名称。 */
+ private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出";
+
+ /** 用户预约导出创建时的占位总数。 */
+ private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1;
+
+ /** 用户预约导出每批读取的权限用户数。 */
+ private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200;
+
+ /** 文件上传和任务收尾占用的额外进度步数。 */
+ private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2;
+
+ /** 用户预约导出表头及合计行占用的固定行数。 */
+ private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2;
+ private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1;
+
+ /** 用户预约导出工作表允许写入的最大明细行数。 */
+ private static final int MAX_USER_RESERVATION_EXPORT_ROWS =
+ BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT,
+ USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT);
+
+ /** 用户预约导出工作表名称。 */
+ private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询";
+
+ /** 用户预约导出文件目录。 */
+ private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export";
+
+ /** 用户预约导出文件类型编码。 */
+ private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT";
+
+ /** 用户预约导出文件 MIME 类型。 */
+ private static final String USER_EXPORT_EXCEL_CONTENT_TYPE =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ /** 用户预约导出文件名时间格式。 */
+ private static final DateTimeFormatter USER_EXPORT_FILE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /** 用户预约明细中核销时间的 Excel 展示格式。 */
+ 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;
+
+ /** 页面协议中的已订餐状态。 */
+ private static final String RESERVATION_STATUS_RESERVED = "RESERVED";
+
+ /** 页面协议中的未订餐状态。 */
+ private static final String RESERVATION_STATUS_NOT_RESERVED = "NOT_RESERVED";
+
+ /** 页面协议中的无需核销状态。 */
+ private static final String VERIFICATION_STATUS_NONE = "NONE";
+
+ /** 页面协议中的全部待核销状态。 */
+ private static final String VERIFICATION_STATUS_UNVERIFIED = "UNVERIFIED";
+
+ /** 页面协议中的部分预约已核销状态。 */
+ private static final String VERIFICATION_STATUS_PARTIAL = "PARTIAL";
+
+ /** 页面协议中的全部已核销状态。 */
+ private static final String VERIFICATION_STATUS_VERIFIED = "VERIFIED";
+
+ /** 允许的订餐状态筛选值。 */
+ private static final Set RESERVATION_STATUS_VALUES = Set.of(
+ RESERVATION_STATUS_RESERVED, RESERVATION_STATUS_NOT_RESERVED);
+
+ /** 允许的核销状态筛选值。 */
+ private static final Set VERIFICATION_STATUS_VALUES = Set.of(
+ VERIFICATION_STATUS_NONE, VERIFICATION_STATUS_UNVERIFIED,
+ VERIFICATION_STATUS_PARTIAL, VERIFICATION_STATUS_VERIFIED);
+
+ /** 有效预约订单状态,只统计已支付和已完成核销的订单。 */
+ private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes();
+
+ /** 用户预约导出和订单关联的单批用户数量。 */
+ private static final long USER_BATCH_SIZE = 500L;
+
+ /** 单次订单查询允许的用户主键数量。 */
+ private static final int ORDER_USER_BATCH_SIZE = 500;
+
+ /** 用户远程服务,负责导出权限范围内的完整用户资料。 */
+ private final RemoteUserService remoteUserService;
+
+ /** 预约订单查询 Mapper,供用户预约导出直接读取预约订单。 */
+ private final HardOrderMapper hardOrderMapper;
+
+ /** 菜品预约聚合 Mapper。 */
+ private final HardCarMapper hardCarMapper;
+
+ /** 每日菜谱远程服务,用于展开套餐组成。 */
+ private final RemoteDailyMenuService remoteDailyMenuService;
+
+ /** 部门远程服务,用于补齐预约人所属部门名称。 */
+ private final RemoteDeptService remoteDeptService;
+
+ /** 餐段字典远程服务,用于输出餐段名称。 */
+ private final RemoteDictService remoteDictService;
+
+ /** 商户远程服务,用于补齐食堂名称。 */
+ private final RemoteMchService remoteMchService;
+
+ /** 文件中心远程服务。 */
+ private final RemoteFileService remoteFileService;
+
+ /** 通用异步任务执行器。 */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 提交用户每日预约异步导出任务。
+ *
+ * 请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并
+ * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。
+ *
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 已创建任务的初始进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) {
+ validateQuery(query);
+ HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query);
+ return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY,
+ USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL,
+ reporter -> exportUserReservations(querySnapshot, reporter));
+ }
+
+ /**
+ * 查询用户预约 Excel 数据。
+ *
+ * @param query 用户、日期范围和预约状态筛选条件
+ * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计
+ */
+ @Override
+ public List userReservationExport(HardCanteenUserApptQueryDto query) {
+ validateQuery(query);
+ HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query);
+ if (!narrowQueryToReservationCandidates(exportQuery)) {
+ return List.of();
+ }
+ Map mealSegmentLabelMap = fetchMealSegmentLabelMap();
+ List reservationRows =
+ listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery));
+ if (reservationRows.isEmpty()) {
+ return List.of();
+ }
+ UserReservationExportSummary summary =
+ new UserReservationExportSummary(exportQuery, mealSegmentLabelMap);
+ summary.addRows(reservationRows);
+ List excelRows = new ArrayList<>(reservationRows.size() + 1);
+ reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap))
+ .forEach(excelRows::add);
+ excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap));
+ return excelRows;
+ }
+
+ /**
+ * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。
+ *
+ * @param query 后台线程独占的用户预约筛选条件
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心下载地址和业务文件名
+ */
+ private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query,
+ AsyncTaskProgressReporter reporter) {
+ if (!narrowQueryToReservationCandidates(query)) {
+ throw new IllegalStateException("没有符合当前筛选条件的用户预约记录");
+ }
+ Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query);
+ long userTotal = firstUserPage.getTotal();
+ if (userTotal <= 0L) {
+ throw new IllegalStateException("没有符合当前条件的用户预约数据");
+ }
+ validateUserReservationExportRowCapacity(userTotal, query);
+ int progressTotal = calculateUserReservationExportProgressTotal(userTotal);
+ reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件");
+
+ String fileName = "用户预约查询_" + LocalDateTime.now().format(USER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
+ Path outputPath = null;
+ try {
+ outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx");
+ long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal,
+ reporter);
+ String fileUrl = uploadUserReservationWorkbook(outputPath, fileName);
+ reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条");
+ return AsyncTaskResult.file(fileUrl, fileName);
+ } catch (IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception);
+ throw new IllegalStateException("生成用户预约导出文件失败", exception);
+ } finally {
+ deleteTemporaryExportFile(outputPath);
+ }
+ }
+
+ /**
+ * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。
+ *
+ * 订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放,
+ * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。
+ *
+ * @param outputPath 最终上传前使用的临时 XLSX 文件
+ * @param query 已冻结的筛选条件
+ * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用
+ * @param userTotal 任务开始时取得的权限用户总数
+ * @param reporter 当前异步任务进度报告器
+ * @return 实际写入 Excel 的用户日期行数
+ */
+ private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query,
+ Page firstUserPage, long userTotal,
+ AsyncTaskProgressReporter reporter) {
+ List queryDates = getQueryDates(query);
+ Map mealSegmentLabelMap = fetchMealSegmentLabelMap();
+ long processedUserTotal = 0L;
+ long exportedRowTotal = 0L;
+ long current = 1L;
+ UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap);
+
+ try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) {
+ WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build();
+ Page userPage = firstUserPage;
+ while (processedUserTotal < userTotal) {
+ List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords();
+ if (pageRecords.isEmpty()) {
+ break;
+ }
+ int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal));
+ List batchUsers = pageRecords.subList(0, remainingUserCount);
+ List batchReservationRows =
+ filterRows(buildRows(batchUsers, query, queryDates), query);
+ long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size();
+ if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) {
+ throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 "
+ + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出");
+ }
+ List batchRows = batchReservationRows.stream()
+ .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList();
+ if (!batchRows.isEmpty()) {
+ excelWriter.write(batchRows, writeSheet);
+ summary.addRows(batchReservationRows);
+ }
+ exportedRowTotal = nextExportedRowTotal;
+ processedUserTotal += batchUsers.size();
+ reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 "
+ + exportedRowTotal + " 条预约记录");
+ if (processedUserTotal >= userTotal) {
+ break;
+ }
+ userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query);
+ }
+ if (processedUserTotal != userTotal) {
+ throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal
+ + " 位,实际处理 " + processedUserTotal + " 位");
+ }
+ if (exportedRowTotal == 0L) {
+ throw new IllegalStateException("没有符合当前筛选条件的用户预约记录");
+ }
+ excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet);
+ }
+ return exportedRowTotal;
+ }
+
+ /**
+ * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。
+ *
+ * @param userTotal 当前筛选条件下的权限用户总数
+ * @return 统一进度组件使用的总步数
+ */
+ private int calculateUserReservationExportProgressTotal(long userTotal) {
+ return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE,
+ USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS);
+ }
+
+ /**
+ * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。
+ *
+ * 存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。
+ *
+ * @param userTotal 当前用户筛选和数据权限范围内的用户总数
+ * @param query 已冻结的日期和预约状态筛选条件
+ */
+ private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) {
+ if (hasJoinedStatusFilter(query)) {
+ return;
+ }
+ long rowTotal;
+ try {
+ rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size());
+ } catch (ArithmeticException exception) {
+ throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception);
+ }
+ if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) {
+ throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 "
+ + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出");
+ }
+ }
+
+ /**
+ * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。
+ *
+ * @param outputPath 已完成写入的临时文件
+ * @param fileName 用户下载时使用的业务文件名
+ * @return 文件中心提供的下载地址
+ * @throws Exception 临时文件读取或远程上传失败时抛出
+ */
+ private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception {
+ MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
+ USER_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath));
+ R> uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null,
+ USER_RESERVATION_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 source 已通过基础校验的请求筛选条件
+ * @return 后台线程独占的完整筛选条件副本
+ */
+ private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) {
+ HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto();
+ target.setApptUseDateList(source.getApptUseDateList() == null
+ ? null : source.getApptUseDateList().clone());
+ target.setNameOrPhone(source.getNameOrPhone());
+ target.setIdentityTypes(copyList(source.getIdentityTypes()));
+ target.setStudentInfo(copyStudentInfo(source.getStudentInfo()));
+ target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo()));
+ target.setHasFace(source.getHasFace());
+ target.setDeptIds(copyList(source.getDeptIds()));
+ target.setIsInclude(source.getIsInclude());
+ target.setMerchantId(source.getMerchantId());
+ target.setHourType(source.getHourType());
+ target.setReservationStatus(source.getReservationStatus());
+ target.setVerificationStatus(source.getVerificationStatus());
+ target.setUserIds(copyList(source.getUserIds()));
+ return target;
+ }
+
+ /**
+ * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。
+ *
+ * @param source 请求对象中的可变列表
+ * @param 列表元素类型
+ * @return 空值保持为空,非空列表返回独立副本
+ */
+ private List copyList(List source) {
+ return source == null ? null : new ArrayList<>(source);
+ }
+
+ /**
+ * 复制学生身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。
+ *
+ * @param source 原始学生身份查询条件
+ * @return 独立的学生身份查询条件;源值为空时返回空值
+ */
+ private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) {
+ if (source == null) return null;
+ SysUserStudentDto target = new SysUserStudentDto();
+ target.setStudentNo(source.getStudentNo());
+ target.setSchoolName(source.getSchoolName());
+ target.setClassName(source.getClassName());
+ target.setGuardianPhones(source.getGuardianPhones());
+ target.setExtJson(source.getExtJson());
+ return target;
+ }
+
+ /**
+ * 复制老师身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。
+ *
+ * @param source 原始老师身份查询条件
+ * @return 独立的老师身份查询条件;源值为空时返回空值
+ */
+ private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) {
+ if (source == null) return null;
+ SysUserTeacherDto target = new SysUserTeacherDto();
+ target.setTeacherNo(source.getTeacherNo());
+ return target;
+ }
+
+ /**
+ * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。
+ *
+ * @param fileName 用户看到的 UTF-8 文件名
+ * @return 文件中心上传接口使用的原始文件名
+ */
+ private String encodeUploadOriginalFilename(String fileName) {
+ return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+ }
+
+ /**
+ * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。
+ *
+ * @param outputPath 待删除的临时文件路径,允许为空
+ */
+ private void deleteTemporaryExportFile(Path outputPath) {
+ if (outputPath == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(outputPath);
+ } catch (Exception exception) {
+ log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception);
+ }
+ }
+
+ /**
+ * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。
+ *
+ * @param query 待校验的查询条件
+ */
+ 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("用餐日期范围必须包含开始和结束日期");
+ }
+ LocalDate startDate = query.getApptUseDateList()[0];
+ LocalDate endDate = query.getApptUseDateList()[1];
+ if (endDate.isBefore(startDate)) {
+ throw new IllegalArgumentException("用餐结束日期不能早于开始日期");
+ }
+ if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) {
+ throw new IllegalArgumentException("用餐日期范围最多查询两个月");
+ }
+ if (StrUtil.isNotBlank(query.getReservationStatus())
+ && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) {
+ throw new IllegalArgumentException("订餐状态不正确");
+ }
+ if (StrUtil.isNotBlank(query.getVerificationStatus())
+ && !VERIFICATION_STATUS_VALUES.contains(query.getVerificationStatus())) {
+ throw new IllegalArgumentException("核销状态不正确");
+ }
+ }
+
+ /**
+ * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。
+ *
+ * @param query 已通过日期范围校验的查询条件
+ * @return 按日期升序排列的查询日期
+ */
+ private List getQueryDates(HardCanteenUserApptQueryDto query) {
+ LocalDate endDate = query.getApptUseDateList()[1];
+ List dates = new ArrayList<>();
+ LocalDate currentDate = query.getApptUseDateList()[0];
+ while (!currentDate.isAfter(endDate)) {
+ dates.add(currentDate);
+ if (currentDate.equals(endDate)) {
+ break;
+ }
+ currentDate = currentDate.plusDays(1L);
+ }
+ return dates;
+ }
+
+ /**
+ * 判断查询是否需要在用户与预约订单关联后再进行筛选。
+ *
+ * @param query 查询条件
+ * @return 存在订餐或核销状态条件时返回 true
+ */
+ private boolean hasJoinedStatusFilter(HardCanteenUserApptQueryDto query) {
+ return StrUtil.isNotBlank(query.getReservationStatus()) || StrUtil.isNotBlank(query.getVerificationStatus());
+ }
+
+ /**
+ * 判断当前筛选结果是否必然存在有效预约订单。
+ *
+ * 已订餐以及待核销、部分核销、已核销都可以先从订单表反查候选用户;未订餐和无需核销属于
+ * “订单不存在”条件,仍需以全部权限用户为主表执行左关联。
+ *
+ * @param query 预约状态和核销状态筛选条件
+ * @return 可以先按预约订单收窄用户范围时返回 true
+ */
+ private boolean requiresExistingReservation(HardCanteenUserApptQueryDto query) {
+ return Objects.equals(query.getReservationStatus(), RESERVATION_STATUS_RESERVED)
+ || (StrUtil.isNotBlank(query.getVerificationStatus())
+ && !Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_NONE));
+ }
+
+ /**
+ * 对必然存在预约的列表或导出条件先查询候选用户,并写回独立查询快照。
+ *
+ * 候选 ID 只负责缩小 UPMS 查询范围,姓名、部门和当前操作者的数据权限仍由 UPMS 最终过滤;
+ * 原查询已指定用户时,订单查询也会先应用该范围,因此不会扩大导出对象。
+ *
+ * @param query 当前列表或导出流程独占、允许写入候选用户主键的查询快照
+ * @return 无需预筛或存在候选用户时返回 true,确定没有匹配预约时返回 false
+ */
+ private boolean narrowQueryToReservationCandidates(HardCanteenUserApptQueryDto query) {
+ if (!requiresExistingReservation(query)) {
+ return true;
+ }
+ List candidateUserIds = hardOrderMapper.listReservationUserIds(
+ buildReservationOrderQuery(query, copyList(query.getUserIds())));
+ if (candidateUserIds == null || candidateUserIds.isEmpty()) {
+ return false;
+ }
+ query.setUserIds(candidateUserIds.stream().filter(Objects::nonNull).distinct().toList());
+ return !query.getUserIds().isEmpty();
+ }
+
+ /**
+ * 获取完整用户导出资料并在关联预约订单后执行状态筛选。
+ *
+ * 与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。
+ *
+ * @param query 查询条件
+ * @param queryDates 查询范围内按升序排列的全部日期
+ * @return 携带完整用户主资料和身份资料的预约导出行
+ */
+ private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query,
+ List queryDates) {
+ List users = fetchAllExportUsers(query);
+ return filterRows(buildRows(users, query, queryDates), query);
+ }
+
+ /**
+ * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。
+ *
+ * @param current 当前页码
+ * @param size 每页数量
+ * @param query 用户筛选条件
+ * @return 含准确总数和完整导出资料的用户分页
+ */
+ private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) {
+ R> response = remoteUserService.getScopeExportUserPage(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);
+ }
+ return response.getData();
+ }
+
+ /**
+ * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。
+ *
+ * @param query 用户预约查询条件
+ * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象
+ */
+ private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) {
+ UserDTO userQuery = new UserDTO();
+ userQuery.setNameOrPhone(query.getNameOrPhone());
+ userQuery.setIdentityTypes(copyList(query.getIdentityTypes()));
+ userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo()));
+ userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo()));
+ userQuery.setHasFace(query.getHasFace());
+ userQuery.setDeptIds(copyList(query.getDeptIds()));
+ userQuery.setIsInclude(query.getIsInclude());
+ userQuery.setUserIds(copyList(query.getUserIds()));
+ return userQuery;
+ }
+
+ /**
+ * 分页遍历完整用户导出资料,供兼容同步导出使用。
+ *
+ * 管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。
+ *
+ * @param query 用户筛选条件
+ * @return 权限范围内携带角色岗位和身份资料的全部用户
+ */
+ private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) {
+ List users = new ArrayList<>();
+ long current = 1L;
+ long total;
+ do {
+ Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query);
+ List records = page.getRecords() == null ? List.of() : page.getRecords();
+ users.addAll(records);
+ total = page.getTotal();
+ if (records.isEmpty()) {
+ break;
+ }
+ current++;
+ } while (users.size() < total);
+ return users;
+ }
+
+ /**
+ * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。
+ *
+ * @param users 权限用户列表
+ * @param query 日期范围、食堂和餐段筛选条件
+ * @param queryDates 查询范围内按升序排列的全部日期
+ * @return 按用户顺序、日期升序排列的预约行
+ */
+ private List buildRows(List users, HardCanteenUserApptQueryDto query,
+ List queryDates) {
+ if (users == null || users.isEmpty()) {
+ return List.of();
+ }
+ List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList();
+ List orders = fetchReservationOrders(userIds, query);
+ return buildRows(users, query, queryDates, orders);
+ }
+
+ /**
+ * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。
+ *
+ * @param users 权限范围内的用户
+ * @param query 日期、食堂和餐段筛选条件
+ * @param queryDates 查询范围内的自然日
+ * @param orders 已按相同筛选条件加载的有效预约订单
+ * @return 按用户顺序和日期顺序排列的预约行
+ */
+ private List buildRows(List users, HardCanteenUserApptQueryDto query,
+ List queryDates, List orders) {
+ if (users == null || users.isEmpty()) {
+ return List.of();
+ }
+ Map merchantNameMap = fetchMerchantNameMap(orders);
+ Map>> orderMap = orders.stream()
+ .filter(order -> order.getUserId() != null && order.getApptUseDate() != null)
+ .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new,
+ Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new,
+ Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList()))));
+
+ return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> {
+ Map> userOrderMap =
+ orderMap.getOrDefault(user.getUserId(), Map.of());
+ List userOrders = userOrderMap.getOrDefault(date, List.of());
+ HardCanteenUserApptVo row = new HardCanteenUserApptVo();
+ row.setUserId(user.getUserId());
+ row.setUserName(resolveUserName(user));
+ row.setUsername(user.getUsername());
+ row.setPhone(user.getPhone());
+ row.setAvatar(user.getAvatar());
+ row.setDeptId(user.getDeptId());
+ row.setDeptName(user.getDeptName());
+ row.setNickname(user.getNickname());
+ row.setGender(user.getGender());
+ row.setEmail(user.getEmail());
+ row.setCreateTime(user.getCreateTime());
+ row.setPostList(user.getPostList());
+ row.setRoleList(user.getRoleList());
+ row.setStaffingType(user.getStaffingType());
+ row.setLockFlag(user.getLockFlag());
+ // UPMS 的权限用户分页已批量补齐身份资料,这里直接复制,避免按用户再次远程查询。
+ row.setIdentityTypes(user.getIdentityTypes());
+ row.setStudentInfo(user.getStudentInfo());
+ row.setTeacherInfo(user.getTeacherInfo());
+ row.setApptUseDate(date);
+ row.setReservationStatus(userOrders.isEmpty()
+ ? RESERVATION_STATUS_NOT_RESERVED : RESERVATION_STATUS_RESERVED);
+ row.setVerificationStatus(resolveVerificationStatus(userOrders));
+ row.setTotalPrice(calculateTotalPrice(userOrders));
+ row.setOrders(userOrders);
+ return row;
+ })).toList();
+ }
+
+ /**
+ * 按用户主键分批查询指定日期范围内的有效食堂预约订单。
+ *
+ * @param userIds 权限范围内的用户主键
+ * @param query 日期范围、食堂和餐段筛选条件
+ * @return 当前租户内符合条件的有效预约订单
+ */
+ private List fetchReservationOrders(List userIds, HardCanteenUserApptQueryDto query) {
+ if (userIds.isEmpty()) {
+ return List.of();
+ }
+ List orders = 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 orderQuery = buildReservationOrderQuery(query,
+ new ArrayList<>(userIds.subList(start, end)));
+ orders.addAll(hardOrderMapper.scopeList(orderQuery, null));
+ }
+ return orders;
+ }
+
+ /**
+ * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。
+ *
+ * @param orders 当前筛选口径下的预约订单
+ * @return 父购物车主键到商品快照的映射
+ */
+ private Map> fetchReservationCars(List orders) {
+ List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
+ .filter(Objects::nonNull).distinct().toList();
+ if (parentCarIds.isEmpty()) {
+ return Map.of();
+ }
+ Map> result = new LinkedHashMap<>();
+ for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) {
+ int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size());
+ hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end))
+ .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car));
+ }
+ return result;
+ }
+
+ /**
+ * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。
+ *
+ * 统一复用可保证预查询命中的用户与最终写入 Excel 的有效预约口径一致,避免日期、食堂、餐段、
+ * 订单状态或预约关联条件在两次查询间发生偏差。
+ *
+ * @param query 用户预约页面提交的日期、食堂和餐段筛选条件
+ * @param userIds 需要限制的用户主键;为空时查询当前租户内全部候选预约用户
+ * @return 可直接交给订单 Mapper 的完整预约条件
+ */
+ private HardOrderDto buildReservationOrderQuery(HardCanteenUserApptQueryDto query, List userIds) {
+ HardOrderDto orderQuery = new HardOrderDto();
+ orderQuery.setUserIds(userIds);
+ orderQuery.setMerchantType(MerchantTypeEnum.CANTEEN.getType());
+ 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)
+ });
+ return orderQuery;
+ }
+
+ /**
+ * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。
+ *
+ * @param orders 有效预约订单
+ * @return 商户主键到食堂名称的映射
+ */
+ private Map fetchMerchantNameMap(List orders) {
+ List merchantIds = orders.stream().map(HardOrderVo::getMerchantId)
+ .filter(Objects::nonNull).distinct().toList();
+ if (merchantIds.isEmpty()) {
+ return Map.of();
+ }
+ try {
+ R> response = remoteMchService.getByIds(merchantIds);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ log.warn("查询用户预约食堂名称失败,保留预约订单并使用空名称");
+ return Map.of();
+ }
+ return response.getData().stream().filter(merchant ->
+ merchant.getId() != null && StrUtil.isNotBlank(merchant.getName()))
+ .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName,
+ (left, right) -> left, LinkedHashMap::new));
+ } catch (Exception exception) {
+ log.warn("查询用户预约食堂名称异常,保留预约订单并使用空名称,原因:{}", exception.getMessage());
+ return Map.of();
+ }
+ }
+
+ /**
+ * 将订单查询对象转换为页面所需的最小预约明细。
+ *
+ * @param order 有效预约订单
+ * @param merchantNameMap 食堂名称映射
+ * @return 预约明细
+ */
+ private HardCanteenUserApptOrderVo toOrderVo(HardOrderVo order, Map merchantNameMap) {
+ HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo();
+ detail.setId(order.getId());
+ detail.setHourType(order.getHourType());
+ detail.setProductDesc(order.getProductDesc());
+ detail.setMerchantName(merchantNameMap.get(order.getMerchantId()));
+ detail.setTotalPrice(order.getTotalPrice());
+ detail.setOrderStatus(order.getOrderStatus());
+ detail.setUpdateTime(order.getVerificationTime() != null ? order.getVerificationTime() : order.getUpdateTime());
+ detail.setUpdateBy(order.getUpdateBy());
+ return detail;
+ }
+
+ /**
+ * 汇总预约明细的商品总价,历史空金额按零分计算。
+ *
+ * @param orders 同一用户、同一用餐日期和当前查询口径下的有效预约明细
+ * @return 商品总价,单位为分;没有预约时返回零
+ */
+ private long calculateTotalPrice(List orders) {
+ return orders.stream().map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull)
+ .mapToLong(Integer::longValue).sum();
+ }
+
+ /**
+ * 依次按姓名、昵称和登录账号确定页面展示名。
+ *
+ * @param user 用户信息
+ * @return 非空展示名;用户资料均未维护时返回“未命名用户”
+ */
+ private String resolveUserName(UserVO user) {
+ if (StrUtil.isNotBlank(user.getName())) {
+ return user.getName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return StrUtil.isBlank(user.getUsername()) ? "未命名用户" : user.getUsername();
+ }
+
+ /**
+ * 根据有效预约明细计算用户整体核销状态。
+ *
+ * @param orders 用户在当前筛选口径下的有效预约明细
+ * @return NONE、UNVERIFIED、PARTIAL 或 VERIFIED
+ */
+ private String resolveVerificationStatus(List orders) {
+ if (orders.isEmpty()) {
+ return VERIFICATION_STATUS_NONE;
+ }
+ long verifiedCount = orders.stream().filter(order ->
+ Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).count();
+ if (verifiedCount == 0L) {
+ return VERIFICATION_STATUS_UNVERIFIED;
+ }
+ return verifiedCount == orders.size() ? VERIFICATION_STATUS_VERIFIED : VERIFICATION_STATUS_PARTIAL;
+ }
+
+ /**
+ * 按订餐和核销状态筛选已完成左关联的用户行。
+ *
+ * @param rows 用户预约行
+ * @param query 状态筛选条件
+ * @return 符合状态条件的行
+ */
+ private List filterRows(List rows,
+ HardCanteenUserApptQueryDto query) {
+ return rows.stream().filter(row ->
+ (StrUtil.isBlank(query.getReservationStatus())
+ || Objects.equals(query.getReservationStatus(), row.getReservationStatus()))
+ && (StrUtil.isBlank(query.getVerificationStatus())
+ || Objects.equals(query.getVerificationStatus(), row.getVerificationStatus())))
+ .toList();
+ }
+
+ /**
+ * 对关联状态筛选后的用户行执行内存分页。
+ *
+ * @param current 当前页码
+ * @param size 每页数量
+ * @param rows 全部筛选结果
+ * @return 标准 MyBatis-Plus 分页结果
+ */
+ private Page paginateRows(long current, long size,
+ List rows) {
+ long offset = (current - 1L) * size;
+ int fromIndex = offset >= rows.size() ? rows.size() : Math.toIntExact(offset);
+ long endOffset = Math.min(offset + size, rows.size());
+ int toIndex = Math.max(fromIndex, Math.toIntExact(endOffset));
+ Page result = new Page<>(current, size, rows.size());
+ result.setRecords(rows.subList(fromIndex, toIndex));
+ return result;
+ }
+
+ /**
+ * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。
+ *
+ * @param row 用户预约汇总
+ * @param query 原始查询条件,用于无订单时保留所选餐段
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return Excel 导出行
+ */
+ private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ List orders = row.getOrders();
+ List verifiedOrders = orders.stream().filter(order ->
+ Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList();
+ HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo();
+ excel.setUserId(row.getUserId());
+ excel.setUserName(row.getUserName());
+ excel.setEmail(row.getEmail());
+ excel.setPhone(row.getPhone());
+ excel.setDeptName(row.getDeptName());
+ excel.setPostNames(joinPostNames(row.getPostList()));
+ excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes()));
+ if (row.getStudentInfo() != null) {
+ excel.setStudentNo(row.getStudentInfo().getStudentNo());
+ excel.setSchoolName(row.getStudentInfo().getSchoolName());
+ excel.setClassName(row.getStudentInfo().getClassName());
+ excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones());
+ }
+ if (row.getTeacherInfo() != null) {
+ excel.setTeacherNo(row.getTeacherInfo().getTeacherNo());
+ }
+ excel.setApptUseDate(row.getApptUseDate());
+ excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED)
+ ? "已订餐" : "未订餐");
+ excel.setHourTypes(orders.isEmpty()
+ ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段"
+ : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap))
+ : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(),
+ mealSegmentLabelMap)));
+ excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING));
+ excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING));
+ excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT));
+ excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT));
+ excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap));
+ excel.setTotalPrice(centsToYuan(row.getTotalPrice()));
+ excel.setProductDescriptions(joinDistinct(orders, order ->
+ StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc()));
+ excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName));
+ excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus()));
+ excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null
+ ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime())));
+ return excel;
+ }
+
+ /**
+ * 生成用户预约导出文件末尾的金额合计行。
+ *
+ * 合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列,
+ * 其余人员及预约字段保持为空。
+ *
+ * @param summary 已累计全部导出分片的金额汇总
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 可直接追加到 EasyExcel 工作表末尾的合计行
+ */
+ private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary,
+ Map mealSegmentLabelMap) {
+ HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo();
+ excel.setUserName("合计");
+ excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary,
+ mealSegmentLabelMap));
+ excel.setTotalPrice(centsToYuan(summary.totalPrice));
+ return excel;
+ }
+
+ /**
+ * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。
+ *
+ * @return 餐段编码到展示标签的有序映射
+ */
+ private Map fetchMealSegmentLabelMap() {
+ try {
+ R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码");
+ return Map.of();
+ }
+ return response.getData().stream().filter(item ->
+ StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel()))
+ .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel,
+ (left, right) -> left, LinkedHashMap::new));
+ } catch (Exception exception) {
+ log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage());
+ return Map.of();
+ }
+ }
+
+ /**
+ * 将餐段协议编码转换为导出文案,并兼容历史空编码。
+ *
+ * @param hourType 订单或查询条件中的餐段协议编码
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段”
+ */
+ private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) {
+ if (StrUtil.isBlank(hourType)) {
+ return "未标记餐段";
+ }
+ return mealSegmentLabelMap.getOrDefault(hourType, hourType);
+ }
+
+ /**
+ * 生成一个固定餐段的预约状态。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段
+ * @param hourType 固定餐段协议枚举
+ * @return “已订N单”“未订餐”或“未查询”
+ */
+ private String buildMealSegmentReservation(List orders,
+ HardCanteenUserApptQueryDto query,
+ HardHourTypeEnum hourType) {
+ if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) {
+ return "未查询";
+ }
+ long orderCount = orders.stream().filter(order ->
+ Objects.equals(hourType.getType(), order.getHourType())).count();
+ return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单";
+ }
+
+ /**
+ * 生成各餐段商品总价摘要,未订餐段明确输出零元。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件,选择餐段时只生成该餐段金额
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 例如“早餐:0.00元;午餐:18.50元”的摘要
+ */
+ private String buildMealSegmentTotalPrices(List orders,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap);
+ if (segments.isEmpty()) {
+ return "全部餐段:0.00元";
+ }
+ return segments.entrySet().stream().map(entry -> {
+ long totalPrice = orders.stream().filter(order ->
+ Objects.equals(entry.getKey(), order.getHourType()))
+ .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull)
+ .mapToLong(Integer::longValue).sum();
+ return entry.getValue() + ":" + formatYuan(totalPrice);
+ }).collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 格式化全部导出明细累计后的各餐段金额。
+ *
+ * @param summary 跨分片累计的金额汇总
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计
+ */
+ private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary,
+ Map mealSegmentLabelMap) {
+ if (summary.mealSegmentTotalPrices.isEmpty()) {
+ return "全部餐段:0.00元";
+ }
+ return summary.mealSegmentTotalPrices.entrySet().stream()
+ .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap)
+ + ":" + formatYuan(entry.getValue()))
+ .collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 保持字典顺序并补齐历史编码的餐段映射
+ */
+ private Map resolveMealSegments(List orders,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ Map segments = new LinkedHashMap<>();
+ if (StrUtil.isNotBlank(query.getHourType())) {
+ segments.put(query.getHourType(),
+ mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType()));
+ } else {
+ segments.putAll(mealSegmentLabelMap);
+ orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank)
+ .forEach(hourType -> segments.putIfAbsent(hourType, hourType));
+ }
+ return segments;
+ }
+
+ /**
+ * 提取、去空、去重并连接预约明细字段。
+ *
+ * @param orders 预约明细列表
+ * @param mapper 字段提取函数
+ * @return 使用中文分号连接的字段值;没有有效值时返回空字符串
+ */
+ private String joinDistinct(List orders,
+ Function mapper) {
+ return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct()
+ .collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 将整数分转换为精确的元金额。
+ *
+ * @param price 商品总价,单位为分;为空时按零计算
+ * @return 以元为单位且固定两位小数精度的金额
+ */
+ private BigDecimal centsToYuan(Long price) {
+ return BigDecimal.valueOf(price == null ? 0L : price, 2);
+ }
+
+ /**
+ * 将整数分格式化为带元单位的两位小数文案。
+ *
+ * @param price 商品总价,单位为分
+ * @return 例如“18.50元”的金额文案
+ */
+ private String formatYuan(long price) {
+ return centsToYuan(price).toPlainString() + "元";
+ }
+
+ /**
+ * 拼接用户岗位名称,空岗位列表输出空字符串。
+ *
+ * @param posts 完整用户导出分页返回的岗位列表
+ * @return 以中文逗号分隔的岗位名称
+ */
+ private String joinPostNames(List posts) {
+ if (posts == null || posts.isEmpty()) {
+ return "";
+ }
+ return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName)
+ .filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
+ }
+
+ /**
+ * 按用户身份协议顺序拼接身份中文名称。
+ *
+ * @param identityTypes UPMS 返回的用户身份协议编码
+ * @return 以中文逗号分隔的身份名称;未知历史编码保留原值
+ */
+ private String joinIdentityNames(List identityTypes) {
+ if (identityTypes == null || identityTypes.isEmpty()) {
+ return "";
+ }
+ return identityTypes.stream().map(identityType -> {
+ UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
+ return identity == null ? identityType : identity.getDescription();
+ }).filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
+ }
+
+ /**
+ * 将核销状态协议值转换为导出文案。
+ *
+ * @param status 核销状态协议值
+ * @return 中文核销状态
+ */
+ private String verificationStatusText(String status) {
+ if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) {
+ return "已核销";
+ }
+ if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) {
+ return "部分核销";
+ }
+ if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) {
+ return "待核销";
+ }
+ return "无需核销";
+ }
+
+ /**
+ * 用户预约导出跨分片金额汇总器。
+ *
+ * 所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。
+ */
+ private static final class UserReservationExportSummary {
+
+ /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */
+ private final Map mealSegmentTotalPrices = new LinkedHashMap<>();
+
+ /** 全部导出明细的当日金额累计值,单位为分。 */
+ private long totalPrice;
+
+ /**
+ * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。
+ *
+ * @param query 本次导出的餐段筛选条件
+ * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射
+ */
+ private UserReservationExportSummary(HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ if (StrUtil.isNotBlank(query.getHourType())) {
+ mealSegmentTotalPrices.put(query.getHourType(), 0L);
+ } else {
+ mealSegmentLabelMap.keySet().forEach(hourType ->
+ mealSegmentTotalPrices.put(hourType, 0L));
+ }
+ }
+
+ /**
+ * 累计一批已经通过全部查询条件筛选的用户日期明细。
+ *
+ * @param rows 当前分片即将写入 Excel 的用户日期明细
+ */
+ private void addRows(List rows) {
+ rows.forEach(this::addRow);
+ }
+
+ /**
+ * 累计单个用户日期明细的当日总金额和各餐段金额。
+ *
+ * @param row 当前导出的一条用户日期明细
+ */
+ private void addRow(HardCanteenUserApptVo row) {
+ totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice());
+ if (row.getOrders() == null || row.getOrders().isEmpty()) {
+ return;
+ }
+ row.getOrders().stream().filter(Objects::nonNull)
+ .filter(order -> StrUtil.isNotBlank(order.getHourType()))
+ .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(),
+ order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(),
+ Math::addExact));
+ }
+ }
+
+ /**
+ * 提交菜品预约异步导出任务。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 初始异步任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) {
+ HardOrderDto querySnapshot = copyProductQuery(query);
+ return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME,
+ INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter));
+ }
+
+ /**
+ * 同步查询菜品预约自定义 Excel 行。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 三级层级导出行
+ */
+ @Override
+ public List productReservationExport(HardOrderDto query) {
+ HardOrderDto querySnapshot = copyProductQuery(query);
+ List rows = new ArrayList<>();
+ for (LocalDate date : getQueryDates(querySnapshot)) {
+ HardOrderDto dateQuery = copyProductQuery(querySnapshot);
+ dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
+ rows.addAll(buildProductRows(dateQuery, date));
+ }
+ return rows;
+ }
+
+ /**
+ * 后台按日期批次生成菜品预约工作簿并上传文件中心。
+ *
+ * @param query 后台线程独占的查询快照
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心结果
+ */
+ private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) {
+ List dates = getQueryDates(query);
+ reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出");
+ Path outputPath = null;
+ String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx";
+ try {
+ outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx");
+ long rowCount;
+ try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class)
+ .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) {
+ WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build();
+ rowCount = 0L;
+ for (LocalDate date : dates) {
+ HardOrderDto dateQuery = copyProductQuery(query);
+ dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
+ List batchRows = buildProductRows(dateQuery, date);
+ if (!batchRows.isEmpty()) {
+ writer.write(batchRows, sheet);
+ rowCount += batchRows.size();
+ }
+ reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行");
+ }
+ if (rowCount == 0L) {
+ throw new IllegalStateException("没有符合当前条件的菜品预约数据");
+ }
+ }
+ String fileUrl = upload(outputPath, fileName);
+ reporter.advance("菜品预约导出文件已生成");
+ return AsyncTaskResult.file(fileUrl, fileName);
+ } catch (IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("菜品预约统计导出失败,fileName:{}", fileName, exception);
+ throw new IllegalStateException("生成菜品预约导出文件失败", exception);
+ } finally {
+ if (outputPath != null) {
+ try {
+ Files.deleteIfExists(outputPath);
+ } catch (Exception exception) {
+ log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception);
+ }
+ }
+ }
+ }
+
+ /**
+ * 查询一个日期批次的三层数据并转换为 Excel 行。
+ *
+ * @param query 日期批次查询条件
+ * @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取
+ * @return 当前批次的汇总、商户部门、商品明细和商品统计行
+ */
+ private List buildProductRows(HardOrderDto query, LocalDate fixedDate) {
+ List summaries = hardCarMapper.listApptExportSummary(query);
+ List merchants = hardCarMapper.listApptExportMerchant(query);
+ List products = hardCarMapper.listApptExportProduct(query);
+ if (summaries == null || summaries.isEmpty()) {
+ return List.of();
+ }
+ Map merchantNames = fetchMerchantNames(merchants);
+ Map departmentNames = fetchDepartmentNames(merchants);
+ Map hourNames = fetchHourNames();
+ Map menuMap = fetchDailyMenus(products, fixedDate);
+ Map> merchantGroups = merchants.stream()
+ .collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList()));
+ Map> productGroups = products.stream()
+ .collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList()));
+ List rows = new ArrayList<>();
+ for (HardCanteenProductTotalVo summary : summaries) {
+ LocalDate date = fixedDate != null ? fixedDate
+ : summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate();
+ if (date == null) {
+ log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType());
+ continue;
+ }
+ String hourType = summary.getHourType();
+ String summaryKey = date + "|" + hourType;
+ List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
+ Map> detailRowsByMerchant = new LinkedHashMap<>();
+ Map detailCountsByMerchant = new HashMap<>();
+ Map detailPricesByMerchant = new HashMap<>();
+ long summaryProductCount = 0L;
+ long summaryTotalPrice = 0L;
+ for (HardCanteenProductApptExportRecord merchant : merchantRows) {
+ String merchantKey = groupKey(merchant);
+ List detailRows = productGroups
+ .getOrDefault(merchantKey, List.of()).stream()
+ .flatMap(product -> toProductRows(product,
+ menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
+ .toList();
+ List statisticRows = buildProductStatistics(detailRows);
+ long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum();
+ long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum();
+ // 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。
+ if (detailRows.isEmpty()) {
+ detailCount = valueOrZero(merchant.getPayCount());
+ detailPrice = valueOrZero(merchant.getTotalPrice());
+ }
+ List merchantProductRows = new ArrayList<>(
+ detailRows.size() + statisticRows.size());
+ merchantProductRows.addAll(detailRows);
+ merchantProductRows.addAll(statisticRows);
+ detailRowsByMerchant.put(merchantKey, merchantProductRows);
+ detailCountsByMerchant.put(merchantKey, detailCount);
+ detailPricesByMerchant.put(merchantKey, detailPrice);
+ summaryProductCount += detailCount;
+ summaryTotalPrice += detailPrice;
+ }
+ if (merchantRows.isEmpty()) {
+ summaryProductCount = valueOrZero(summary.getPayCount());
+ summaryTotalPrice = valueOrZero(summary.getTotalPrice());
+ }
+ HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo();
+ summaryRow.setSummaryDate(date);
+ summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames));
+ summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount()));
+ summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount()));
+ // 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。
+ summaryRow.setSummaryPayCount(summaryProductCount);
+ summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice));
+ rows.add(summaryRow);
+ for (HardCanteenProductApptExportRecord merchant : merchantRows) {
+ HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo();
+ merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录"));
+ merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录"));
+ merchantRow.setMerchantDate(date);
+ merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames));
+ merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount()));
+ merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount()));
+ String merchantKey = groupKey(merchant);
+ merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey,
+ valueOrZero(merchant.getPayCount())));
+ merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey,
+ valueOrZero(merchant.getTotalPrice()))));
+ rows.add(merchantRow);
+ rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of()));
+ }
+ }
+ return rows;
+ }
+
+ /**
+ * 将商品聚合行按预约当天每日菜谱中的套餐组成展开。
+ *
+ * 组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊,
+ * 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。
+ *
+ * @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径
+ * @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行
+ * @return 单品原始行,或按套餐组成顺序生成的商品明细行
+ */
+ private List toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) {
+ List items = menu != null && menu.getComboItems() != null
+ && !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of();
+ if (items.isEmpty()) {
+ return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()),
+ product.getTotalPrice()));
+ }
+ long packageCount = valueOrZero(product.getPayCount());
+ long totalCents = valueOrZero(product.getTotalPrice());
+ long sign = totalCents < 0 ? -1L : 1L;
+ long absoluteTotalCents = Math.abs(totalCents);
+ long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice()))
+ * Math.max(1, valueOrZero(item.getQuantity()))).sum();
+ long[] itemPrices = new long[items.size()];
+ long[] itemWeights = new long[items.size()];
+ long allocated = 0L;
+ List remainderIndexes = new ArrayList<>();
+ List result = new ArrayList<>();
+ for (int index = 0; index < items.size(); index++) {
+ OnlineSpuComboItemVO item = items.get(index);
+ long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity()));
+ itemWeights[index] = itemWeight;
+ if (baseAmount > 0) {
+ BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
+ .multiply(BigDecimal.valueOf(itemWeight))
+ .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
+ itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue();
+ allocated += itemPrices[index];
+ remainderIndexes.add(index);
+ }
+ }
+ if (baseAmount > 0) {
+ long remainder = absoluteTotalCents - allocated;
+ // 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。
+ remainderIndexes.sort(Comparator.comparing((Integer index) -> {
+ BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
+ .multiply(BigDecimal.valueOf(itemWeights[index]))
+ .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
+ return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index]));
+ }).reversed());
+ for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) {
+ itemPrices[remainderIndexes.get(index)]++;
+ }
+ } else if (!items.isEmpty()) {
+ itemPrices[items.size() - 1] = absoluteTotalCents;
+ }
+ for (int index = 0; index < items.size(); index++) {
+ OnlineSpuComboItemVO item = items.get(index);
+ long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity()));
+ long itemPrice = itemPrices[index] * sign;
+ result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice));
+ }
+ return result;
+ }
+
+ /** 创建单个商品明细行。 */
+ private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) {
+ HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
+ row.setProductName(name);
+ row.setProductCount(count);
+ row.setProductTotalPrice(centsToYuan(totalPrice));
+ return row;
+ }
+
+ /**
+ * 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。
+ *
+ * 套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键,
+ * 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。
+ *
+ * @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细
+ * @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致
+ */
+ private List buildProductStatistics(
+ List detailRows) {
+ Map statistics = new LinkedHashMap<>();
+ for (HardCanteenProductApptExcelVo detailRow : detailRows) {
+ String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim();
+ String statisticKey = normalizeProductName(displayName);
+ HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> {
+ HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
+ row.setStatisticProductName(displayName);
+ row.setStatisticProductCount(0L);
+ row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
+ return row;
+ });
+ statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount())
+ + valueOrZero(detailRow.getProductCount()));
+ long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice())
+ + yuanToCents(detailRow.getProductTotalPrice());
+ statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice));
+ }
+ return new ArrayList<>(statistics.values());
+ }
+
+ /**
+ * 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。
+ *
+ * @param productName 首次出现的商品展示名称
+ * @return 去除首尾空白并把连续空白压缩为单个空格的统计键
+ */
+ private String normalizeProductName(String productName) {
+ return productName.replaceAll("\\s+", " ").trim();
+ }
+
+ /** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */
+ private Map fetchDailyMenus(List products,
+ LocalDate fixedDate) {
+ Map result = new HashMap<>();
+ LocalDate date = fixedDate;
+ if (date == null) return result;
+ List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId)
+ .filter(Objects::nonNull).distinct().toList();
+ for (Long merchantId : merchantIds) {
+ for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId))
+ .map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) {
+ try {
+ Integer hour = Integer.valueOf(hourType);
+ R> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId);
+ if (response != null && response.isOk() && response.getData() != null) {
+ response.getData().forEach(menu -> {
+ if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu);
+ });
+ }
+ } catch (Exception exception) {
+ log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}",
+ merchantId, date, hourType, exception.getMessage());
+ }
+ }
+ }
+ return result;
+ }
+
+ /** 批量查询商户名称。 */
+ private Map fetchMerchantNames(List rows) {
+ List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) return Map.of();
+ try {
+ R> response = remoteMchService.getByIds(ids);
+ if (response == null || !response.isOk() || response.getData() == null) return Map.of();
+ return response.getData().stream().filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
+ } catch (Exception exception) {
+ log.warn("查询导出商户名称失败,使用空名称", exception);
+ return Map.of();
+ }
+ }
+
+ /** 批量查询部门名称。 */
+ private Map fetchDepartmentNames(List rows) {
+ List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) return Map.of();
+ try {
+ R