Merge remote-tracking branch 'origin/master'

This commit is contained in:
zjf committed 2026-08-25 12:03:39 +08:00
commit 82e3fc02db
208 files changed
+19685 -1530

No files matched your search

@@ -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 '稳定钱包退款业务号,过期任务按订单生成';
@@ -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 = '硬件订单识别证据图片';
@@ -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;
+153
View File
@@ -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。
@@ -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;
+79
View File
@@ -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;
@@ -0,0 +1,3 @@
-- 为每笔钱包流水增加可选业务备注;历史流水保持为空,前后端统一限制为 256 个字符。
ALTER TABLE wallet_transaction
ADD COLUMN remark varchar(256) DEFAULT NULL COMMENT '流水备注,最多256个字符' AFTER tags;
+44 -1
View File
@@ -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);
+1
View File
@@ -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 '修改人',
@@ -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<List<CanteenSpuEntity>> getSpuListByIds(@RequestBody List<Long> ids);
/**
* 供 Hardware 定时任务等无用户上下文的服务间调用批量查询菜品。
*
* @param ids 菜品 SPU 主键列表
* @return 当前租户下命中的菜品列表
*/
@PostMapping("/canteenSpu/inner/getSpuListByIds")
@NoToken
R<List<CanteenSpuEntity>> getInnerSpuListByIds(@RequestBody List<Long> ids);
/**
* 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。
*
@@ -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;
/**
@@ -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<List<CanteenSpuEntity>> getByIds(@RequestBody List<Long> ids) {
List<CanteenSpuEntity> list = canteenSpuService.list(new LambdaQueryWrapper<CanteenSpuEntity>().in(CanteenSpuEntity::getId, ids));
return querySpuByIds(ids);
}
/**
* 为无用户令牌的 Hardware 服务间调用提供菜品批量查询,保持原管理端接口的鉴权行为不变。
*
* @param ids 菜品 SPU 主键列表
* @return 当前租户下命中的菜品列表
*/
@Inner
@PostMapping("/inner/getSpuListByIds")
public R<List<CanteenSpuEntity>> getInnerByIds(@RequestBody List<Long> ids) {
return querySpuByIds(ids);
}
/**
* 统一执行菜品 SPU 批量查询,避免管理端和内部端点产生不同的数据筛选口径。
*
* @param ids 菜品 SPU 主键列表
* @return 当前租户下命中的菜品列表
*/
private R<List<CanteenSpuEntity>> querySpuByIds(List<Long> ids) {
List<CanteenSpuEntity> list = canteenSpuService.list(new LambdaQueryWrapper<CanteenSpuEntity>()
.in(CanteenSpuEntity::getId, ids));
return R.ok(list);
}
@@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper<CanteenSpuEntity> {
* 更新商品 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<CanteenSpuEntity> {
// 执行更新
LambdaUpdateWrapper<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
.setSql(sql)
.eq(CanteenSpuEntity::getId, id);
.eq(CanteenSpuEntity::getId, id)
// 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
}
/**
* 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 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<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
.setSql(sql)
.eq(CanteenSpuEntity::getId, id)
.eq(CanteenSpuEntity::getStock, expectedStock)
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
}
@@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
*/
R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
/**
* 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。
*
* @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型
* @return 成功、幂等命中或库存校验失败的统一响应
*/
R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
/**
* 根据菜品分类id获取菜品信息
@@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
* 批量加载套餐组成(App/每日菜谱展示)
*/
Map<Long, List<OnlineSpuComboItemVO>> loadComboItemVos(List<Long> comboSpuIds);
}
}
@@ -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<CanteenSpuMapper, CanteenSpuEntity> 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<CanteenSpuMapper, Canteen
}
@Override
@Transactional
@Transactional(rollbackFor = Exception.class)
public R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO) {
// 更新 SKU 库存
updateStockReqDTO.getItems().forEach(item -> {
// 查询当前库存
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<CanteenSpuEntity> canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper<CanteenSpuEntity>()
@@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return list;
}
}
}
@@ -0,0 +1,129 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 每日菜谱基础 SPU 库存幂等状态机测试,重点覆盖退款任务本地回滚后的再次执行。
*/
class CanteenSpuServiceImplTest {
/** 被测库存服务。 */
private CanteenSpuService service;
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations<String, String> 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;
}
}
@@ -0,0 +1,44 @@
package com.bsd.pjcloud.hard.api.constant.order;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 食堂销售统计展示层级协议。
*
* <p>该编码仅用于接口返回和管理端组织多维统计行,不对应数据库字段;调用方不得将名称作为稳定判断条件。</p>
*/
@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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,57 @@
package com.bsd.pjcloud.hard.api.constant.order;
import lombok.Getter;
import java.util.Arrays;
import java.util.List;
/**
* 食堂销售统计来源。
*
* <p>编码只用于统计查询和管理端展示,不写入订单表;来源由订单类型、预约关联、订单状态以及对应业务时间计算得到。</p>
*/
@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<String> 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);
}
}
@@ -33,6 +33,9 @@ public enum HardAfterSaleOperateTypeEnum {
/** 系统根据订单快照和商户配置免审完成退款。 */
SYSTEM_AUTO_REFUND("31", "系统自动退款"),
/** 系统按每日菜谱预约过期快照创建整单退款。 */
SYSTEM_EXPIRED_RESERVE_REFUND("32", "系统过期预约退款"),
/** 会员取消尚未审核的退款申请。 */
MEMBER_CANCEL("40", "会员取消退款"),
;
@@ -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;
}
}
@@ -21,7 +21,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端技术人员对耗尽任务执行的一次额外尝试。
*/
MANUAL("30", "人工技术重试", false);
MANUAL("30", "人工技术重试", false),
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
/**
* 数据库存储的稳定触发编码。
@@ -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<HardOrderEvidenceMetadataDto> evidenceMetadataList;
}
@@ -43,6 +43,10 @@ public class HardAutoRefundTaskPageDto {
@Schema(description = "任务状态")
private String status;
/** 任务场景编码;候选列表固定筛选过期预约任务。 */
@Schema(description = "任务场景")
private String taskType;
/**
* 任务创建时间闭区间,数组长度应为 2。
*/
@@ -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<String> 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<Long> deptIds;
@@ -26,11 +26,23 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
private Long merchantId;
/** 订单类型协议编码;详情查询传预约编码时仅统计预约订单,其他场景为空表示不限定。 */
private String orderType;
/** 是否只保留关联预约规则或预约记录的订单;为空或 false 时不增加预约关联限制。 */
private Boolean apptIdOrApptRuleIdNotNull;
private List<Long> mchIds;
/** 预计使用日期范围。 */
private LocalDateTime[] apptUseDateTimeList;
/** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */
private LocalDateTime[] statisticsDateTimeList;
/** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */
private List<String> saleSourceCodes;
/**
* 前端树搜索
*/
@@ -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<String> saleSourceCodes;
/**
* 商品名称模糊查询条件,仅供购物车商品统计 SQL 筛选使用,不对应订单表持久化字段。
*/
private String productName;
private LocalDateTime[] refundTimeList;
/** 预计使用日期范围。 */
@@ -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;
}
@@ -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;
/**
* 终端订单识别证据的订单定位参数。
*
* <p>消费机现场下单继续使用终端订单号对账;预约核销订单不是当前终端创建的,核销成功后改用
* 服务端订单 ID 定位。两种方式都必须结合请求头中的设备 ID 校验订单归属。</p>
*/
@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;
}
}
@@ -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;
}
@@ -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;
}
@@ -18,7 +18,8 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。
* Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定,
* 失败后由该记录持续驱动恢复。
*/
@Data
@Builder
@@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model<HardAutoRefundTaskEntity> {
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<HardAutoRefundTaskEntity> {
@Schema(description = "免审资格截止时间")
private LocalDateTime eligibilityDeadline;
/** 过期预约任务的订单快照触发时间;旧售后任务为空。 */
@Schema(description = "过期退款触发时间")
private LocalDateTime triggerTime;
/** 任务创建时快照的退库存开关,1恢复、0不恢复。 */
@Schema(description = "退库存快照")
private String restoreInventory;
/**
* 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。
*/
@@ -159,6 +159,22 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@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<HardOrderEntity> {
@Schema(description = "退款状态")
private String refundStatus;
/**
* 订单核销完成时间;仅在订单实际完成核销时写入,避免被后续退款等订单更新覆盖。
*/
@Schema(description = "订单核销时间")
private LocalDateTime verificationTime;
/**
* 订单取消时间(退款订单)
*/
@@ -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<HardOrderEvidenceImageEntity> {
/**
* 证据记录自增主键。
*/
@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;
}
@@ -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;
/**
* 用户相关
*/
@@ -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;
}
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.hard.api.vo.order;
import lombok.Data;
/**
* 食堂菜品销售三级导出的聚合记录。
*
* <p>同一模型承载日期餐段、商户部门和商品三种聚合查询结果,商品聚合时额外填充商品字段;
* 统计时间已按销售来源统一转换为业务发生日期,金额统一使用分。</p>
*/
@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;
}
@@ -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<HardCanteenProductTotalVo> sourceRows;
@Schema(description = "用餐部门总数")
private Integer deptCount;
/** 菜品统计中承载餐段合计行,部门统计中承载部门餐段行;空值表示无下一级统计。 */
private List<HardCanteenProductTotalVo> children;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -36,7 +36,7 @@ public class HardCanteenUserApptOrderVo {
@Schema(description = "订单状态协议编码")
private String orderStatus;
/** 订单最近更新时间;已核销订单中作为核销时间展示。 */
/** 核销时间兼容字段;优先来源于订单独立核销时间,历史空值回退到订单最近更新时间。 */
@Schema(description = "核销时间")
private LocalDateTime updateTime;
@@ -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;
/**
* 用户预约统计的通用汇总数据。
*
* <p>金额字段统一使用分;用户数量按“用户-用餐日期”记录累计,避免多日查询把同一用户重复隐藏。</p>
*/
@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<HardCanteenUserApptMealStatisticsVo> meals = new ArrayList<>();
/** 按餐段和菜品名称聚合的菜品份数。 */
@Schema(description = "菜品统计")
private List<HardCanteenUserApptDishStatisticsVo> dishes = new ArrayList<>();
}
@@ -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<HardCanteenUserApptDailyStatisticsVo> daily = new ArrayList<>();
}
@@ -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;
@@ -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;
}
@@ -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<HardOrderEvidenceImageVo> evidenceImages;
}
@@ -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;
}
@@ -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<HardOrderEvidenceImageVo> evidenceImages;
}
@@ -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;
}
@@ -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;
/**
* 食堂菜品销售统计自定义导出行。
*
* <p>一个日期、餐段和来源口径按汇总行、商户部门行、商品明细行组织;来源口径包含筛选范围合计及各销售来源。
* 商品统计在同一商户部门块内按名称合并套餐展开后的明细,不同层级只填写所属列,配合自定义合并处理器形成树形报表。</p>
*/
@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;
}
@@ -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;
}
@@ -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<HardOrderEvidenceResolveVo> 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<HardOrderEvidenceUploadResultVo> 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<HardOrderEvidenceUploadResultVo> upload(@RequestHeader("DEVICE-ID") Long hardId,
@Valid @ModelAttribute HardOrderEvidenceUploadDto dto,
@RequestPart("file") MultipartFile file) {
return R.ok(hardOrderEvidenceImageService.upload(hardId, dto, file));
}
}
@@ -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<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page,
@ParameterObject HardAutoRefundTaskPageDto dto) {
List<Long> scopedMchIds = listScopedMerchantIds();
if (scopedMchIds.isEmpty()) {
Page<HardAutoRefundTaskVo> empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
empty.setRecords(List.of());
return R.ok(empty);
}
dto.setMchIds(scopedMchIds);
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<Boolean> expiredRefund(@PathVariable Long id) {
validateTaskScope(id);
taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId());
return R.ok(true);
}
/**
* 查询指定任务全部只读尝试日志。
*
@@ -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<HardCanteenUserApptStatisticsVo> 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<AsyncTaskProgressVO> 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<HardCanteenUserApptExcelVo> 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<AsyncTaskProgressVO> submitProductReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
}
/**
* 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
*/
@Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
@SysLog("菜品预约统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
@ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/product-reserv-export")
public List<HardCanteenProductApptExcelVo> productReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return canteenApptTotalExportService.productReservationExport(query);
}
/**
* 为后台菜品预约导出补充当前操作者可见的食堂范围。
*
* <p>分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
* 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。</p>
*
* @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
*/
private void applyCanteenMerchantScope(HardOrderDto query) {
if (query == null) {
return;
}
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
R<List<MchManageVo>> scopeResponse = remoteMchService.scopeList(mchManageDto);
List<MchManageVo> mchList = scopeResponse == null ? null : scopeResponse.getData();
List<Long> visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
: mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isEmpty(visibleMerchantIds)) {
query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
return;
}
if (ObjectUtil.isEmpty(query.getMchIds())) {
query.setMchIds(visibleMerchantIds);
return;
}
// 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
List<Long> allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
@@ -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<HardCanteenDeptTotalExcelVo> exportDept(@RequestBody HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
return canteenTotalService.exportDept(dto, user, null);
}
}
@@ -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<HardCanteenProductTotalExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
return canteenTotalService.exportProduct(dto, null);
}
/**
* 查询菜品销售明细;新版来源统计补充当前用户可见食堂范围,旧查询仍沿用原接口行为。
*
* @param page 分页参数
* @param dto 商品名称、统计日期、来源、食堂、餐段和部门筛选
* @return 按商品聚合的销售明细分页结果
*/
@Operation(summary = "菜品出售统计", description = "菜品出售统计")
@PostMapping("/proudct-query")
public R<Page<HardCarVo>> 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<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList)) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
}
return R.ok(canteenTotalService.proudctQuery(page, dto));
}
@@ -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<Boolean> 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 = "离线订单手动支付")
@@ -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;
/**
* 食堂菜品销售统计三级导出合并处理器。
*
* <p>数据行通过各层级专属列识别:日期餐段来源行填写第 0 列,商户部门行填写第 7 列,商品行填写第 15 列;
* 第 18 至 20 列展示同一商户部门内按名称合并后的商品统计。
* Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。</p>
*/
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<Short, CellStyle> centeredStyles = new HashMap<>();
/** 原样式、日期颜色和边框位置到彩色边框样式的缓存,避免重复创建工作簿样式。 */
private final Map<String, CellStyle> 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;
/**
* 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。
*
* <p>Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性,
* 不能直接替换成全新的样式,否则金额会失去两位小数格式。</p>
*
* @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);
}
}
/**
* 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
*
* <p>流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
* 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。</p>
*
* @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();
}
}
@@ -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<HardAutoRefu
.eq(dto.getOrderId() != null, HardAutoRefundTaskEntity::getOrderId, dto.getOrderId())
.eq(dto.getMchId() != null, HardAutoRefundTaskEntity::getMchId, dto.getMchId())
.eq(dto.getStatus() != null && !dto.getStatus().isBlank(), HardAutoRefundTaskEntity::getStatus, dto.getStatus())
.eq(dto.getTaskType() != null && !dto.getTaskType().isBlank(), HardAutoRefundTaskEntity::getTaskType, dto.getTaskType())
.in(dto.getMchIds() != null && !dto.getMchIds().isEmpty(), HardAutoRefundTaskEntity::getMchId, dto.getMchIds())
.between(dto.getCreateTimeList() != null && dto.getCreateTimeList().length == 2,
HardAutoRefundTaskEntity::getCreateTime,
@@ -48,4 +52,16 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper<HardAutoRefu
.orderByDesc(HardAutoRefundTaskEntity::getId);
return selectPage(page, wrapper);
}
/**
* 按订单、父购物车和售后状态联合过滤可整单退款的过期预约候选,分页总数由数据库直接计算。
*
* @param page 分页参数
* @param dto 管理端筛选及服务端注入的商户范围
* @param now 当前服务端时间,候选必须已到期
* @return 仅包含未尝试、未关联售后且仍满足业务资格的任务
*/
Page<HardAutoRefundTaskEntity> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
}
@@ -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<HardCarEntity> {
Page<HardCanteenProductTotalVo> productTotal(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按业务发生日期、餐段和来源分页统计菜品销售。
*
* @param page 分页参数
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 来源独立分组的菜品销售统计页
*/
Page<HardCanteenProductTotalVo> productTotalStats(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询菜品多维统计行且不应用数据库分页。
*
* <p>同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
* 服务层按完整自然日分页,导出仅选择餐段来源层。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 按日期、餐段和来源层级排序的多维统计行
*/
List<HardCanteenProductTotalVo> listProductTotalStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按当前主表行口径查询部门汇总,仅供新版菜品统计展开行使用。
*
* @param dto 单日、餐段、来源、食堂和用户部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 按用户部门拆分且在当前来源范围内独立去重的销售统计行
*/
List<HardCanteenProductTotalVo> productTotalByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询菜品销售三级导出的日期餐段汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
*/
List<HardCanteenProductApptExportRecord> listSalesExportSummary(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商户部门汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段下商户部门的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportMerchant(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商品汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个商户部门块下商品的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportProduct(@Param("dto") HardOrderDto dto);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
/**
* 按来源和业务发生时间分页查询菜品汇总明细。
*
* @param page 分页参数
* @param dto 单日、来源、食堂、餐段、部门和商品名称筛选
* @return 按商品聚合的来源明细页
*/
Page<HardCarVo> proudctQueryStats(Page page, @Param("dto") HardCarDto dto);
/**
* 菜品出售商户统计
*/
@@ -46,8 +111,41 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
List<HardCanteenProductTotalVo> listByDept(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按日期和食堂查询部门统计合计行及其来源拆分行。
*
* @param dto 统计日期、来源、食堂、餐段和用户部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 日期食堂合计行和日期食堂来源行,服务层据此装配主从层级
*/
List<HardCanteenProductTotalVo> listByDeptStats(@Param("dto") HardOrderDto dto, DataScope dataScope);
List<HardCanteenProductTotalVo> listByHourType(@Param("dto") HardOrderDto dto);
/**
* 按日期、部门和餐段查询展开合计行及其来源拆分行。
*
* @param dto 单日、食堂、来源、餐段和用户部门筛选
* @return 部门餐段合计行和部门餐段来源行,服务层据此装配主从层级
*/
List<HardCanteenProductTotalVo> listByHourTypeStats(@Param("dto") HardOrderDto dto);
/**
* 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。
*
* @param parentCarIds 预约订单关联的父购物车主键集合
* @return 未删除的购物车商品快照;传入空集合时返回空列表
*/
default List<HardCarEntity> listByParentCarIds(List<Long> parentCarIds) {
if (parentCarIds == null || parentCarIds.isEmpty()) {
return List.of();
}
return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HardCarEntity>()
.in(HardCarEntity::getParentCarId, parentCarIds)
.orderByAsc(HardCarEntity::getParentCarId)
.orderByAsc(HardCarEntity::getId));
}
/**
* 菜品销量按照分类查询
*/
@@ -62,4 +160,4 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
* 个人菜品喜好排行
*/
List<HardAppProductLikeTopVo> productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
}
}
@@ -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<HardOrderEvidenceImageEntity> {
/**
* 按订单读取未逻辑删除的证据,稳定按类型、角色、序号和主键排序。
*
* @param orderId 服务端订单主键
* @return 当前租户下的订单证据列表
*/
default List<HardOrderEvidenceImageEntity> listByOrderId(Long orderId) {
return selectList(new LambdaQueryWrapper<HardOrderEvidenceImageEntity>()
.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<HardOrderEvidenceImageEntity>()
.eq(HardOrderEvidenceImageEntity::getOrderId, orderId)
.eq(HardOrderEvidenceImageEntity::getEvidenceType, evidenceType)
.eq(HardOrderEvidenceImageEntity::getEvidenceRole, evidenceRole)
.eq(HardOrderEvidenceImageEntity::getSequenceNo, sequenceNo));
}
}
@@ -130,8 +130,43 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
return selectListByScope(commonWrapper(dto), DataScope.of());
}
/**
* 按订单筛选条件和数据范围分页查询订单详情,并保持创建时间或更新时间倒序。
*
* @param page 分页参数;App 可见性查询会由服务层指定对应的轻量统计语句
* @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
* @param dataScope 后台数据范围;App 查询传 {@code null}
* @return 当前页订单详情及符合相同筛选条件的总数
*/
Page<HardOrderVo> scopePageList(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 先按 App 可见范围和全部筛选条件分页读取订单主键,避免展示表关联参与全量排序。
*
* @param page 主键分页参数,排序规则与订单列表保持一致
* @param dto 订单、商户、状态、时间及 App 可见范围筛选条件
* @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
* @return 当前页有序订单主键及符合条件的总数
*/
Page<Long> appVisibilityPageIds(Page<Long> page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按已分页的订单主键批量读取展示详情,仅关联当前页设备、最新售后单和付款人。
*
* @param orderIds 当前页订单主键集合,调用方保证非空且已完成租户和可见范围过滤
* @return 当前租户内存在的订单详情;SQL 不承诺返回顺序,由服务层按主键页顺序重排
*/
List<HardOrderVo> listAppPageDetails(@Param("orderIds") List<Long> orderIds);
/**
* 统计 App 当前访问者可见的订单数量,不执行列表展示字段所需的多表关联。
*
* @param dto 订单基础筛选、设备类型、最新售后单号及 App 可见范围条件
* @param dataScope 保留的数据范围参数;当前 App 调用传 {@code null}
* @return 当前租户内符合全部筛选条件的未删除订单数量
*/
Long appVisibilityCount(@Param("dto") HardOrderDto dto, DataScope dataScope);
List<HardOrderVo> scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
@@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
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);
/**
* 同意售后
*/
@@ -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) {
}
}
@@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale);
/**
* 在每日菜谱订单支付成功事务内按订单过期快照创建任务;同一订单和场景保持幂等。
*
* @param order 已更新为支付成功且带过期退款快照的订单
* @return 已存在或新建的任务;订单不具备快照时返回空
*/
HardAutoRefundTaskEntity createExpiredReserveTask(HardOrderEntity order);
/**
* 注册事务提交后的同步首次执行;回调内部吞掉资金异常并持久化重试状态。
*
@@ -52,6 +60,9 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
Page<HardAutoRefundTaskVo> pageTasks(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
void manualRetry(Long taskId, Long operatorUserId);
/**
* 管理端有业务退款权限时逐单执行过期预约整单退款,不依赖技术重试权限或当前自动开关。
*
* @param taskId 过期预约任务 ID
* @param operatorUserId 当前后台操作用户 ID
*/
void manualExpiredRefund(Long taskId, Long operatorUserId);
/**
* 按售后单查询自动退款任务。
*
@@ -24,8 +24,15 @@ public interface HardBroadcastService extends IService<HardBroadcastEntity> {
*/
void doneBroadcast(HardOrderEntity orderdetail, UserInfo userInfo);
/**
* 将误核销预约订单的取餐广播恢复为待处理,并通知当前商户的取餐设备重新展示。
*
* @param orderdetail 已由订单服务校验为线上预订且正在回退的订单
*/
void rollbackDoneBroadcast(HardOrderEntity orderdetail);
/**
* android完成一轮广播
*/
void androidDoneBroadcast(HardAdBroadcastDto dto);
}
}
@@ -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;
/**
* 食堂预约统计导出服务。
*
* <p>导出属于独立的长耗时任务边界,负责异步任务、分批读取、Easy Excel 写入和文件中心上传;
* 普通统计服务只负责页面查询和统计,不持有导出任务状态。</p>
*/
public interface HardCanteenApptTotalExportService {
/**
* 提交用户预约异步导出任务。
*
* @param query 用户预约日期、部门、食堂、餐段和状态筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
/**
* 兼容旧同步用户预约导出接口。
*
* @param query 用户预约筛选条件
* @return 用户预约导出行
*/
List<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
/**
* 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门、商品三级结构排列的导出行
*/
List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query);
}
@@ -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<HardCanteenUserApptOrderVo> 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<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
}
@@ -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<HardCanteenProductTotalVo> productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope);
/**
* 食堂菜品购买统计
* 分页查询食堂菜品购买统计。
*
* @param page 分页参数;来源统计模式下每页数量表示自然日数量
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单部门数据范围;为空时不追加数据范围条件
* @return 当日合计和餐段合计行,来源统计模式下每行包含对应来源明细
*/
Page<HardCanteenProductTotalVo> productTotal(Page page, HardOrderDto dto, DataScope dataScope);
@@ -60,4 +66,16 @@ public interface HardCanteenTotalService {
* 食堂菜品购买部门统计
*/
List<HardCanteenProductTotalVo> listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope);
/**
* 按页面筛选条件导出统一的菜品销售三级统计。
*
* <p>每个日期餐段同时生成当前来源范围的合计块以及各来源块,每个块继续按商户部门和商品展开;
* 菜品统计页与部门统计页共用该方法,避免两套导出口径再次分叉。</p>
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @param dataScope 部门数据权限;为空时沿用控制器既有范围
* @return 日期餐段、商户部门、商品三级排列的导出行
*/
List<HardCanteenProductApptExcelVo> exportSales(HardOrderDto dto, DataScope dataScope);
}
@@ -25,6 +25,14 @@ public interface HardCarService extends IService<HardCarEntity> {
List<HardCarEntity> getByParentCarId(Long parentCarId);
/**
* 按父购物车主键批量查询未删除的购物车商品快照,供订单分页结果一次性装配商品列表。
*
* @param parentCarIds 父购物车主键集合,空集合不发起数据库查询
* @return 按父购物车主键和商品主键排序的商品快照
*/
List<HardCarEntity> getByParentCarIds(List<Long> parentCarIds);
List<HardCarVo> mchDetail(Long merchantId, Long userId, HardTypeEnum hardType);
Integer computerCarTotalPrice(@NotNull HardTypeEnum sysType, @NotNull HardOrderTypeEnum orderType, @NotEmpty List<HardCreateCarDto> list);
@@ -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<HardOrderEvidenceImageEntity> {
/**
* 按终端订单号或服务端订单 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<HardOrderEvidenceMetadataDto> metadataList, String appVersion);
/**
* 查询订单详情所需的证据列表并补齐中文展示名称。
*
* @param orderId 服务端订单主键
* @return 排序后的证据列表
*/
List<HardOrderEvidenceImageVo> listByOrderId(Long orderId);
}
@@ -52,6 +52,14 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
Page<HardOrderVo> appMchPageJoin(Page page, HardOrderDto dto, PjcloudUser user);
/**
* 按筛选条件和数据范围分页查询订单,并装配列表展示所需的关联信息。
*
* @param page 订单分页参数
* @param dto 订单筛选与关联信息加载选项
* @param dataScope 后台数据范围;App 查询传 {@code null}
* @return 订单分页结果
*/
Page<HardOrderVo> scopePageList(Page page, HardOrderDto dto, DataScope dataScope);
/**
@@ -68,6 +76,14 @@ public interface HardOrderService extends IService<HardOrderEntity> {
HardOrderVo join(Long id);
/**
* 查询后台管理端订单详情并填充长期识别证据;调用入口负责沿用订单查看权限。
*
* @param id 订单主键
* @return 订单详情及识别证据;订单不存在时返回 {@code null}
*/
HardOrderVo joinWithEvidence(Long id);
/**
* 查询并校验 App 用户可查看的订单详情。
*/
@@ -268,11 +284,29 @@ public interface HardOrderService extends IService<HardOrderEntity> {
void fastenerInventory(HardOrderEntity order, List<HardCarEntity> refundCarList, YesNoEnum isReduce);
/**
* 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。
*
* @param order 原订单
* @param refundCarList 本次退款商品;为空表示整单商品
* @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存
* @param afterSaleId 本次售后单 ID,非退款调用可为空
*/
void fastenerInventory(HardOrderEntity order, List<HardCarEntity> refundCarList, YesNoEnum isReduce, Long afterSaleId);
/**
* 核销硬件二维码
*/
Boolean handCancelCode(HandCancelCodeDto dto);
/**
* 将误核销的线上预订订单恢复到待核销状态,并同步恢复对应预约二维码。
*
* @param orderId 已核销的线上预订订单主键
* @return 订单及二维码均恢复成功时返回 {@code true}
*/
Boolean rollbackReserveVerification(Long orderId);
/**
* 核销二维码
*/
@@ -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<HardApptEntity> candidates = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getHourType, hourType)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.orderByAsc(HardApptEntity::getId));
if (candidates.size() != 1) {
return null;
}
HardApptEntity appt = candidates.get(0);
HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
if (endType == null || appt.getEndTime() == null) {
return null;
}
return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
@@ -214,6 +214,73 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
}
}
/**
* 恢复食堂预约订单的取餐广播。数据库状态必须先恢复,使暂时离线的设备后续仍能读取待广播记录;
* MQTT 只承担实时刷新,单台设备推送失败不会阻断订单与二维码的核销回退主流程。
*
* @param orderdetail 已由订单服务校验为线上预订且正在回退的订单
*/
@Override
public void rollbackDoneBroadcast(HardOrderEntity orderdetail) {
if (ObjectUtil.isEmpty(orderdetail)
|| ObjectUtil.notEqual(HardTypeEnum.valueOfByType(orderdetail.getSysType()), HardTypeEnum.CANTEEN)
|| ObjectUtil.notEqual(HardOrderTypeEnum.valueOfByType(orderdetail.getOrderType()), HardOrderTypeEnum.RESERVE)) {
return;
}
HardBroadcastEntity broadcastDetail = baseMapper.selectOne(new LambdaQueryWrapper<HardBroadcastEntity>()
.eq(HardBroadcastEntity::getBizId, orderdetail.getId())
.orderByDesc(HardBroadcastEntity::getCreateTime)
.orderByDesc(HardBroadcastEntity::getId)
.last("limit 1"));
if (ObjectUtil.isEmpty(broadcastDetail)) return;
baseMapper.update(null, new LambdaUpdateWrapper<HardBroadcastEntity>()
.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<UserInfo> 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<HardManagerEntity> hardList = hardManagerMapper.selectList(new LambdaQueryWrapper<HardManagerEntity>()
.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完成一轮广播
*/
@@ -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<VerificationRecordVo> 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();
@@ -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);
@@ -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<HardAfterSaleMapper, H
List<HardOrderEntity> 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<HardAfterSaleMapper, H
DataUtil.convertDeptList(list.getRecords(), deptList.getData());
}
}
fillAutoRefundTaskStatus(list.getRecords());
fillAutoRefundTaskInfo(list.getRecords());
}
return list;
}
@@ -474,7 +476,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
DataUtil.convertDept(detail, dept.getData());
}
}
fillAutoRefundTaskStatus(List.of(detail));
fillAutoRefundTaskInfo(List.of(detail));
}
return detail;
}
@@ -519,6 +521,20 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
* @return 已锁定并完成渠道退款的原订单
*/
private HardOrderEntity refundInCurrentTransaction(Long afterSaleId, String prefixStr, Long autoRefundConfigId) {
return refundInCurrentTransaction(afterSaleId, prefixStr, autoRefundConfigId, null);
}
/**
* 在当前事务中调用原支付渠道退款,并携带自动退款任务场景供 Wallet 终审独立开关。
*
* @param afterSaleId 待执行的售后单 ID
* @param prefixStr 退款业务号前缀
* @param autoRefundConfigId 自动退款配置 ID;人工退款为空
* @param autoRefundTaskType 自动退款任务场景;过期预约为 20
* @return 已锁定并完成渠道退款的原订单
*/
private HardOrderEntity refundInCurrentTransaction(Long afterSaleId, String prefixStr, Long autoRefundConfigId,
String autoRefundTaskType) {
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
if (ObjectUtil.isEmpty(afterSale)) throw new RuntimeException("售后订单不存在");
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
@@ -528,7 +544,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
// 不能用它判断渠道退款是否执行过;重复调用由售后状态、订单锁和售后单 ID 下游幂等共同兜底。
// 生成退款单号
if (ObjectUtil.isEmpty(prefixStr)) prefixStr = "hard";
String refundCode = prefixStr + "_" + order.getId() + "_" + afterSale.getId();
String refundCode = "hard_expired".equals(prefixStr)
? "hard_expired_" + order.getId()
: prefixStr + "_" + order.getId() + "_" + afterSale.getId();
// 查询总的退款金额
Integer refundPrice = afterSale.getRefundPrice();
if (ObjectUtil.isEmpty(refundPrice)) {
@@ -563,7 +581,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
if (isWalletPay) {
WalletRechargeRefundDto hardRefundDto = WalletRechargeRefundDto.builder().walletId(order.getWalletId())
.orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款")
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).build();
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).autoRefundTaskType(autoRefundTaskType).build();
R<WalletRechargeVo> 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<HardAfterSaleMapper, H
throw new RuntimeException("订单自动退款快照不匹配");
}
// 食堂基础 SPU 库存跨服务变更具备幂等反向补偿能力,必须先恢复库存。
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
restoreRefundInventory(order, afterSaleId);
}
refundInCurrentTransaction(afterSaleId, "hard", autoRefundConfigId);
if (!canteenSpuInventoryOrder) {
restoreOrderInventory(order, afterSaleId);
}
LocalDateTime completedTime = LocalDateTime.now();
HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSaleId)
.status(HardSaleStatusEnum.COMPLETE.getStatus())
@@ -653,15 +679,134 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
insertAfterSaleLog(afterSaleId, afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType(),
HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(),
HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getContent());
List<HardAfterSaleCarEntity> 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<HardAfterSaleEntity> 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<HardAfterSaleEntity> 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<HardAfterSaleMapper, H
baseMapper.updateById(updateRefundObj);
insertAfterSaleLog(afterSale.getId(), userId, HardAfterSaleOperateTypeEnum.ADMIN_AGREE_APPLY.getType(),
order.getOrderStatus(), order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getContent());
// 执行退款
// 食堂基础 SPU 库存恢复失败时禁止发起退款;渠道退款失败由库存幂等回调撤销本次恢复。
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
restoreRefundInventory(order, id);
}
getSelf().refund(updateRefundObj.getId());
List<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
if (ObjectUtil.isEmpty(orderCarList)) throw new RuntimeException("订单购物车不存在");
// 查询已退款商品
List<HardAfterSaleCarEntity> refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId());
Map<Long, Integer> 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<Long, Integer> 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<Long, Integer> 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<HardCarEntity> 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<HardAfterSaleMapper, H
}
/**
* 批量补充售后单关联的自动退款任务状态,普通人工售后保持为空。
* 批量补充售后单关联的自动退款任务状态、场景及免审规则快照,普通人工售后保持为空。
*
* @param records 当前售后分页或详情响应
*/
private void fillAutoRefundTaskStatus(List<HardAfterSaleVo> records) {
private void fillAutoRefundTaskInfo(List<HardAfterSaleVo> records) {
List<Long> afterSaleIds = records.stream().map(HardAfterSaleVo::getId).filter(Objects::nonNull).distinct().toList();
if (afterSaleIds.isEmpty()) {
return;
}
Map<Long, String> taskStatusMap = hardAutoRefundTaskService.list(
Map<Long, HardAutoRefundTaskEntity> taskMap = hardAutoRefundTaskService.list(
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.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<HardAfterSaleMapper, H
hardAfterSaleLogMapper.insert(saveLogObj);
if (!simplePay) {
hardAfterSaleCarService.create(remainingCars, lockedOrder, saveObj);
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
} else if (lockedOrder.getParentCarId() != null) {
// 简单支付通常没有购物车;历史异常数据若带有父购物车,仍沿用原整单库存恢复能力。
hardOrderService.fastenerInventory(order, YesNoEnum.NO);
}
// 执行退款
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(lockedOrder);
if (canteenSpuInventoryOrder) {
// 食堂基础 SPU 库存接口具备本地事务回滚和业务号幂等补偿,必须先恢复库存再调用支付渠道。
restoreRefundInventory(lockedOrder, saveObj.getId());
}
// 普通商品库存接口不具备同等跨服务补偿能力,保持历史“先退款、后恢复库存”顺序。
getSelf().refund(saveObj.getId());
if (!canteenSpuInventoryOrder) {
if (!simplePay) {
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
} else if (lockedOrder.getParentCarId() != null) {
// 简单支付通常没有购物车;历史异常数据若带有父购物车,仍沿用原整单库存恢复能力。
hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, saveObj.getId());
}
}
recomputeOrderRefundState(order.getId());
return true;
}
@@ -1539,7 +1759,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
baseMapper.insert(saveObj);
// 添加退款购物车
if (!isSimplePayList) {
hardAfterSaleCarService.createAdmin(dto.getCarList(), order, saveObj);
List<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
.content(HardSaleStatusEnum.COMPLETE.getContent())
.build();
hardAfterSaleLogMapper.insert(saveLogObj);
// 退库存
if (!isSimplePayList) {
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
// 食堂基础 SPU 先恢复库存;库存接口失败时不发起渠道退款。
if (canteenSpuInventoryOrder) {
restoreRefundInventory(order, saveObj.getId());
}
// 普通商品库存恢复沿用原接口,放在渠道退款成功之后。
if (!canteenSpuInventoryOrder && !isSimplePayList) {
List<HardCarEntity> carList = dto.getCarList().stream().map((item) ->
HardCarEntity.builder()
.productId(item.getProductId())
@@ -1561,10 +1788,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
.productNature(item.getProductNature())
.build()
).toList();
// 延后到渠道退款成功后执行,避免普通库存服务缺少补偿导致库存虚增。
getSelf().refund(saveObj.getId());
hardOrderService.fastenerInventory(order, carList, YesNoEnum.NO);
} else {
getSelf().refund(saveObj.getId());
}
// 执行退款
getSelf().refund(saveObj.getId());
recomputeOrderRefundState(order.getId());
return true;
}
@@ -1586,6 +1815,63 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
&& ObjectUtil.equal(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE);
}
/**
* 判断订单是否使用食堂基础 SPU 幂等库存协议。机器收银订单依据服务端固化的
* {@code CANTEEN_SPU} 来源识别,旧每日菜谱订单继续兼容扩展 JSON 场景标记。
*
* @param order 待退款的 Hardware 订单
* @return {@code true} 表示库存恢复必须使用售后单幂等号并先于渠道退款执行
*/
private boolean usesCanteenSpuInventory(HardOrderEntity order) {
if (order == null || !ObjectUtil.equal(order.getSysType(), HardTypeEnum.CANTEEN.getType())) {
return false;
}
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
if (parentCar == null) return false;
HardProductSourceEnum productSource = HardProductSourceEnum.valueOfByType(parentCar.getProductSource());
return ObjectUtil.equal(productSource, HardProductSourceEnum.CANTEEN_DAILY_MENU)
|| ObjectUtil.equal(productSource, HardProductSourceEnum.CANTEEN_SPU)
|| (parentCar.getTakeOutSettingJson() != null
&& ObjectUtil.equal(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE));
}
/**
* 恢复每日菜谱或机器收银本次售后占用的基础 SPU 库存。
*
* <p>售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品,
* 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离,
* 使重复回调不会重复增加库存。</p>
*
* @param order 原每日菜谱预约订单或机器收银订单
* @param afterSaleId 本次售后单 ID,不能为空
*/
private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) {
if (afterSaleId == null) {
throw new RuntimeException("食堂菜品退款库存缺少售后单 ID");
}
List<HardAfterSaleCarEntity> 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<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
if (ObjectUtil.isEmpty(orderCarList)) {
throw new RuntimeException("订单购物车不存在");
}
int refundPrice = 0;
Map<Long, Integer> 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<Long, Integer> entry : refundCountByProductId.entrySet()) {
int remainingCount = entry.getValue();
List<HardCarEntity> 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<HardAfterSaleCarEntity> buildDailyMenuAdminRefundCars(HardOrderEntity order,
List<HardAfterSaleCarEntity> refundCarList) {
List<HardCarEntity> orderCarList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(orderCarList)) {
throw new RuntimeException("订单购物车不存在");
}
Map<Long, HardCarEntity> orderCarMap = orderCarList.stream()
.filter(item -> item.getProductId() != null)
.collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left,
LinkedHashMap::new));
Map<Long, Integer> 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<HardAfterSaleCarEntity> result = new ArrayList<>(refundCountByProductId.size());
for (Map.Entry<Long, Integer> 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<HardAfterSaleMapper, H
}
insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType(),
order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.COMPLETE.getContent());
if (!simplePay) {
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
boolean canteenSpuInventoryOrder = usesCanteenSpuInventory(order);
if (canteenSpuInventoryOrder) {
// 食堂基础 SPU 先恢复库存,库存失败时不发起渠道退款。
restoreRefundInventory(order, afterSale.getId());
}
getSelf().refund(afterSale.getId(), "hard|ad");
if (!canteenSpuInventoryOrder && !simplePay) {
// 普通商品保持先渠道退款后恢复库存,避免旧库存接口失败时出现库存虚增。
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
}
recomputeOrderRefundState(order.getId());
return true;
}
@@ -13,9 +13,11 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
@@ -116,6 +118,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final HardAfterSaleMapper afterSaleMapper;
/** 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。 */
private final com.bsd.pjcloud.hard.mapper.order.HardOrderMapper hardOrderMapper;
/**
* 售后服务延迟提供器,用于显式打破售后申请创建任务与任务完成售后的循环依赖。
*/
@@ -173,6 +178,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.afterSaleId(afterSale.getId())
.taskType(HardAutoRefundTaskTypeEnum.AFTER_SALE.getType())
.orderId(order.getId())
.mchId(order.getMerchantId())
.configId(order.getAutoRefundConfigId())
@@ -189,6 +195,44 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return task;
}
/**
* 支付成功事务内创建每日菜谱过期任务。任务的触发时间、配置 ID 和退库存值均来自订单快照,
* 配置关闭只会在执行阶段暂停,不会删除或改写已支付订单的时间口径。
*
* @param order 已支付的每日菜谱预约订单
* @return 新建或已存在的过期任务;快照不完整时返回空
*/
@Override
public HardAutoRefundTaskEntity createExpiredReserveTask(HardOrderEntity order) {
if (order == null || order.getId() == null || order.getExpiredRefundConfigId() == null
|| order.getExpiredRefundDeadline() == null
|| !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
return null;
}
HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.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<HardAutoRefundTas
}
try {
try {
if (Objects.equals(current.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& current.getAfterSaleId() == null) {
if (!getSelf().canPrepareExpiredReserveTask(taskId, triggerType, operatorUserId)) {
return;
}
getSelf().prepareExpiredReserveTask(taskId);
current = getById(taskId);
if (current == null) {
return;
}
}
getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId);
} catch (CurrentConfigLookupException exception) {
if (!tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) {
@@ -253,6 +308,57 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
}
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
return false;
}
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
return true;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
return false;
}
if (!Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())) {
pauseForDisabledSwitch(task, triggerType, operatorUserId);
return false;
}
return true;
}
/**
* 在资金退款尝试前独立提交过期预约售后单,并把售后主键写回任务;该方法不调用钱包。
*
* @param taskId 过期预约任务主键
*/
@Transactional(rollbackFor = Exception.class)
public void prepareExpiredReserveTask(Long taskId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (task == null || !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
|| task.getAfterSaleId() != null
|| Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
return;
}
Long afterSaleId = hardAfterSaleServiceProvider.getObject().prepareExpiredReserveRefund(task);
task.setAfterSaleId(afterSaleId);
if (baseMapper.updateById(task) != 1) {
throw new RuntimeException("关联过期预约退款售后单失败");
}
}
/**
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
@@ -267,7 +373,24 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
if (!canExecute(task, triggerType)) {
return;
}
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
completeAttempt(task, triggerType, operatorUserId, false);
return;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())) {
pauseForDisabledSwitch(task, triggerType, operatorUserId);
return;
}
completeAttempt(task, triggerType, operatorUserId, true);
return;
}
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
@@ -328,7 +451,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
throw new RuntimeException("更新自动退款尝试次数失败");
}
hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
boolean enforceAutoSwitch = triggerType != HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL;
Long afterSaleId = hardAfterSaleServiceProvider.getObject()
.completeExpiredReserveRefund(task, enforceAutoSwitch);
task.setAfterSaleId(afterSaleId);
} else {
hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
}
LocalDateTime finishedTime = LocalDateTime.now();
attempt.setResultStatus(HardAutoRefundAttemptResultEnum.SUCCESS.getStatus());
@@ -365,11 +495,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
try {
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
boolean expiredTask = Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType());
if ((!expiredTask && !matchesSnapshot(task.getConfigId(), task.getRuleType(), current))
|| (expiredTask && !matchesExpiredSnapshot(task, current))) {
getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) {
if ((!expiredTask && !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode()))
|| (expiredTask && !Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()))) {
getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
return;
}
@@ -494,7 +627,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
task.setAutoAttemptCount(autoAttempts);
task.setLastAttemptTime(now);
task.setLastError(errorMessage);
if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL || autoAttempts >= 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<HardAutoRefundTas
if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
return false;
}
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
return Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& task.getTriggerTime() != null && !task.getTriggerTime().isAfter(LocalDateTime.now())
&& List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus());
}
if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL) {
return Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) {
return false;
}
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now()))) {
return false;
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
&& task.getNextRetryTime() != null && task.getNextRetryTime().isAfter(LocalDateTime.now())) {
return false;
@@ -644,6 +788,12 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/** 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。 */
private boolean matchesExpiredSnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/**
* 校验订单是否具备每日菜谱自动退款的完整快照和固定支付协议。
*
@@ -787,6 +937,67 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
execute(taskId, HardAutoRefundTriggerTypeEnum.MANUAL, operatorUserId);
}
/**
* 管理端逐单执行过期预约业务退款,候选任务不受技术重试状态机权限限制。
* 状态机为保留失败日志会吸收资金异常,因此执行后必须复查最终状态,避免管理端把失败提示成成功。
*
* @param taskId 过期预约任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
public void manualExpiredRefund(Long taskId, Long operatorUserId) {
HardAutoRefundTaskEntity task = getById(taskId);
if (task == null || !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
throw new RuntimeException("过期预约退款任务不存在");
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
throw new RuntimeException("订单已退款");
}
if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())
|| !List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus())) {
throw new RuntimeException("当前任务不属于待过期退款候选订单");
}
execute(taskId, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL, operatorUserId);
HardAutoRefundTaskEntity completed = getById(taskId);
if (completed == null || !Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
throw new RuntimeException("订单退款未完成,请刷新列表或前往自动退款任务页处理");
}
}
/** 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。 */
@Override
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
fillAfterSaleData(records);
fillOrderData(records);
fillUserData(records);
fillMerchantData(records);
Page<HardAutoRefundTaskVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
result.setRecords(records);
return result;
}
/** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
private void fillOrderData(List<HardAutoRefundTaskVo> records) {
List<Long> orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return;
Map<Long, HardOrderEntity> 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<HardAutoRefundTas
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.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<HardAutoRefundTas
* @return hard_{orderId}_{afterSaleId}
*/
private String buildRefundCode(HardAutoRefundTaskEntity task) {
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
return "hard_expired_" + task.getOrderId();
}
return "hard_" + task.getOrderId() + "_" + task.getAfterSaleId();
}
@@ -3,29 +3,39 @@ package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.constant.total.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByCanteenDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductByClassifyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppProductLikeTopDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
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 com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
@@ -38,14 +48,21 @@ import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -63,6 +80,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
static private final String TAG = "食堂统计";
/** 当前用户没有可见食堂时使用的不可命中主键,防止空集合被解释为不限制商户。 */
private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
private final HardOrderMapper hardOrderMapper;
private final HardCarMapper hardCarMapper;
@@ -71,6 +91,12 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
private final RemoteDataScopeService dataScopeService;
private final RemoteCanteenCategoryService canteenCategoryService;
/** 每日菜谱远程服务,用于在三级导出中按业务日期展开套餐组成商品。 */
private final RemoteDailyMenuService remoteDailyMenuService;
/** 餐段字典远程服务,仅在生成统计导出文案时读取 daily_menu。 */
private final RemoteDictService remoteDictService;
/**
* 菜品销量按照分类查询
*/
@@ -164,34 +190,139 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
@Override
public List<HardCanteenProductTotalVo> productTotalByDept(Page page, HardOrderDto dto, DataScope dataScope) {
dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
List<HardCanteenProductTotalVo> list = hardCarMapper.productTotalByDept(dto, dataScope);
List<HardCanteenProductTotalVo> list;
if (isSourceStatistics(dto)) {
normalizeSaleSources(dto);
list = hardCarMapper.productTotalByDeptStats(dto, dataScope);
} else {
list = hardCarMapper.productTotalByDept(dto, dataScope);
}
fillSaleSourceFields(list);
hardCanteenProductTotalVoSetDeptData(list);
return list;
}
/**
* 食堂菜品购买统计
* 分页查询食堂菜品购买统计。
*
* <p>新版来源统计先读取日期、餐段和来源的完整多维结果,再按自然日分页装配为“当日合计 -> 餐段合计”,
* 每条合计行携带对应来源明细,避免来源行占据主表分组后无法查看整体数据。旧调用方未传统计日期时仍使用原 SQL。</p>
*
* @param page 分页参数;新版统计的每页数量表示自然日数量
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单部门数据范围;为空时不追加数据权限条件
* @return 按自然日分页且包含餐段和来源明细的统计结果
*/
@Override
public Page<HardCanteenProductTotalVo> productTotal(Page page, HardOrderDto dto, DataScope dataScope) {
dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
Page<HardCanteenProductTotalVo> pageList = hardCarMapper.productTotal(page, dto, null);
Page<HardCanteenProductTotalVo> pageList;
if (isSourceStatistics(dto)) {
normalizeSaleSources(dto);
List<HardCanteenProductTotalVo> rows = hardCarMapper.listProductTotalStats(dto, dataScope);
boolean includeSourceRows = dto.getSaleSourceCodes().size() > 1;
pageList = assembleProductStatisticsPage(page, rows, ObjectUtil.isNotEmpty(dto.getHourType()), includeSourceRows);
} else {
pageList = hardCarMapper.productTotal(page, dto, dataScope);
}
if (ObjectUtil.isNotEmpty(pageList)) {
for (HardCanteenProductTotalVo v : pageList.getRecords()) {
if (ObjectUtil.isEmpty(v.getCreateTime())) continue;
LocalDate localDate = v.getCreateTime().toLocalDate();
v.setUniqueCode(localDate + "_" + v.getHourType());
fillSaleSourceFields(v);
if (ObjectUtil.isNotEmpty(v.getCreateTime())) {
LocalDate localDate = v.getCreateTime().toLocalDate();
v.setUniqueCode(localDate + "_" + ObjectUtil.defaultIfNull(v.getStatisticsLevel(), "LEGACY") + "_"
+ ObjectUtil.defaultIfNull(v.getHourType(), "ALL"));
}
}
}
return pageList;
}
/**
* 将 Mapper 的多维统计行装配为按自然日分页的主表结果。
*
* <p>交易人数等合计值直接使用对应数据库分组结果,禁止通过来源行相加生成;来源行只作为横向对比明细挂到合计行。
* 选择具体餐段时隐藏内容相同的当日合计,避免页面出现两条重复统计。</p>
*
* @param requestedPage 调用方请求的页码和每页自然日数量
* @param rows 数据库返回的当日合计、当日来源、餐段合计和餐段来源行
* @param hourTypeFiltered 是否已经按单一餐段筛选
* @param includeSourceRows 是否挂载来源拆分行;仅“全部”来源视图需要展示,单来源视图禁用以避免重复行
* @return 仅包含当前页自然日的合计行,每行附带来源对比数据
*/
private Page<HardCanteenProductTotalVo> assembleProductStatisticsPage(Page requestedPage,
List<HardCanteenProductTotalVo> rows,
boolean hourTypeFiltered,
boolean includeSourceRows) {
Page<HardCanteenProductTotalVo> result = new Page<>(requestedPage.getCurrent(), requestedPage.getSize());
if (ObjectUtil.isEmpty(rows)) {
result.setTotal(0);
result.setRecords(List.of());
return result;
}
List<HardCanteenProductTotalVo> sourceRows = rows.stream()
.filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource()))
.toList();
fillSaleSourceFields(sourceRows);
sourceRows.forEach(this::fillStatisticsLevelName);
Map<String, List<HardCanteenProductTotalVo>> sourceRowsByScope = sourceRows.stream()
.collect(Collectors.groupingBy(this::statisticsScopeKey, LinkedHashMap::new, Collectors.toList()));
List<HardCanteenProductTotalVo> totalRows = rows.stream()
.filter(row -> ObjectUtil.isEmpty(row.getSaleSource()))
.filter(row -> !hourTypeFiltered
|| ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_TOTAL.getCode()))
.toList();
List<LocalDate> statisticsDates = totalRows.stream()
.map(HardCanteenProductTotalVo::getCreateTime)
.filter(ObjectUtil::isNotEmpty)
.map(LocalDateTime::toLocalDate)
.distinct()
.toList();
long fromIndex = Math.min((requestedPage.getCurrent() - 1) * requestedPage.getSize(), statisticsDates.size());
long toIndex = Math.min(fromIndex + requestedPage.getSize(), statisticsDates.size());
Set<LocalDate> currentDates = new HashSet<>(statisticsDates.subList((int) fromIndex, (int) toIndex));
List<HardCanteenProductTotalVo> currentRows = totalRows.stream()
.filter(row -> row.getCreateTime() != null && currentDates.contains(row.getCreateTime().toLocalDate()))
.peek(row -> {
HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel());
row.setStatisticsLevelName(level == null ? null : level.getDescription());
row.setSourceRows(includeSourceRows
? sourceRowsByScope.getOrDefault(statisticsScopeKey(row), List.of())
: List.of());
})
.toList();
result.setTotal(statisticsDates.size());
result.setRecords(currentRows);
return result;
}
/**
* 构造当日或餐段统计的来源挂载键。
*
* @param row 合计行或来源行;日期为空时返回稳定空日期片段
* @return “日期_餐段”键,当日层级的餐段片段固定为 ALL
*/
private String statisticsScopeKey(HardCanteenProductTotalVo row) {
String statisticsDate = row == null || row.getCreateTime() == null
? "UNKNOWN" : row.getCreateTime().toLocalDate().toString();
return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL");
}
/**
* 菜品出售统计
*/
@Override
public Page<HardCarVo> proudctQuery(Page page, HardCarDto dto) {
dto.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
if (isSourceStatistics(dto)) {
normalizeSaleSources(dto);
Page<HardCarVo> result = hardCarMapper.proudctQueryStats(page, dto);
fillSaleSourceFields(result == null ? null : result.getRecords());
return result;
}
return hardCarMapper.proudctQuery(page, dto);
}
@@ -219,7 +350,16 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
}
/**
* 食堂菜品购买部门统计
* 查询食堂菜品部门统计。
*
* <p>新版来源统计以“日期 + 食堂”的跨来源合计作为主行,并将来源拆分挂载到主行;展开后再以
* “部门 + 餐段”的跨来源合计为主行并挂载来源拆分。所有合计均来自数据库独立聚合,避免通过
* 来源行相加导致交易人数重复。未传统计日期范围时继续使用旧查询结构。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选;totalIsFindChildren 决定是否查询展开明细
* @param user 当前登录用户,保留给既有商户范围契约
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 食堂部门统计主行;新版主行附带来源拆分和部门餐段明细
*/
@Override
public List<HardCanteenProductTotalVo> listByDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) {
@@ -231,30 +371,56 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
List<MchManageVo> mchList = remoteMchService.scopeList(mchListDto).getData();
if (ObjectUtil.isEmpty(mchList)) return null;
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
List<HardCanteenProductTotalVo> list = hardCarMapper.listByDept(dto, dataScope);
boolean sourceStatistics = isSourceStatistics(dto);
if (sourceStatistics) normalizeSaleSources(dto);
List<HardCanteenProductTotalVo> mapperRows = sourceStatistics
? hardCarMapper.listByDeptStats(dto, dataScope)
: hardCarMapper.listByDept(dto, dataScope);
boolean includeSourceRows = sourceStatistics && dto.getSaleSourceCodes().size() > 1;
List<HardCanteenProductTotalVo> list = sourceStatistics
? assembleDepartmentMainRows(mapperRows, includeSourceRows)
: mapperRows;
fillSaleSourceFields(list);
if (ObjectUtil.isNotEmpty(list) && ObjectUtil.isNotEmpty(dto.getTotalIsFindChildren()) && dto.getTotalIsFindChildren()) {
// 限制最大线程数为10
int threadCount = Math.min(list.size(), 10);
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
ExecutorService executorService = threadCount > 0 ? Executors.newFixedThreadPool(threadCount) : null;
try {
List<CompletableFuture<Void>> futures = list.stream()
.map(v -> CompletableFuture.runAsync(() -> {
// 查询时间段
HardOrderDto deptQuery = BeanUtil.toBean(dto, HardOrderDto.class);
deptQuery.setMerchantId(v.getMchId());
List<HardCanteenProductTotalVo> deptList = hardCarMapper.listByHourType(deptQuery);
if (sourceStatistics) {
deptQuery.setCreateTimeStr(v.getCreateTime() == null ? null : v.getCreateTime().toLocalDate().toString());
deptQuery.setOrderApptUseDateStr(null);
}
List<HardCanteenProductTotalVo> deptMapperRows = sourceStatistics
? hardCarMapper.listByHourTypeStats(deptQuery)
: hardCarMapper.listByHourType(deptQuery);
List<HardCanteenProductTotalVo> deptList = sourceStatistics
? assembleDepartmentChildRows(deptMapperRows, v.getMchId(), includeSourceRows)
: deptMapperRows;
for (HardCanteenProductTotalVo j : deptList) {
if (ObjectUtil.isNotEmpty(v.getMchId())) j.setMchId(v.getMchId());
fillSaleSourceFields(j);
}
v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size());
if (!sourceStatistics) {
v.setDeptCount(deptList.stream().map(HardCanteenProductTotalVo::getUserDeptId).distinct().toList().size());
}
v.setChildren(deptList);
if (sourceStatistics && includeSourceRows) {
for (HardCanteenProductTotalVo sourceRow : v.getSourceRows()) {
sourceRow.setChildren(departmentSourceChildren(deptList, sourceRow.getSaleSource()));
}
}
}, executorService))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
} finally {
executorService.shutdown();
if (executorService != null) executorService.shutdown();
}
}
@@ -262,9 +428,381 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
hardCanteenProductTotalVoSetMchData(list);
// 添加部门信息
hardCanteenProductTotalVoSetDeptData(list);
if (sourceStatistics) inheritDepartmentRowContext(list);
return list;
}
/**
* 将部门统计 Mapper 主行装配为“日期 + 食堂合计 -> 来源拆分”的展示结构。
*
* @param rows Mapper 返回的日期食堂合计行和日期食堂来源行
* @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载
* @return 日期食堂合计行列表,顺序沿用 Mapper 的日期和食堂排序
*/
private List<HardCanteenProductTotalVo> assembleDepartmentMainRows(List<HardCanteenProductTotalVo> rows,
boolean includeSourceRows) {
if (ObjectUtil.isEmpty(rows)) return List.of();
List<HardCanteenProductTotalVo> sourceRows = rows.stream()
.filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource()))
.toList();
fillSaleSourceFields(sourceRows);
sourceRows.forEach(this::fillStatisticsLevelName);
Map<String, List<HardCanteenProductTotalVo>> sourceRowsByScope = sourceRows.stream()
.collect(Collectors.groupingBy(this::departmentMainScopeKey, LinkedHashMap::new, Collectors.toList()));
return rows.stream()
.filter(row -> ObjectUtil.isEmpty(row.getSaleSource()))
.peek(row -> {
fillStatisticsLevelName(row);
row.setSourceRows(includeSourceRows
? sourceRowsByScope.getOrDefault(departmentMainScopeKey(row), List.of())
: List.of());
})
.toList();
}
/**
* 将部门展开 Mapper 行装配为“部门 + 餐段合计 -> 来源拆分”的展示结构。
*
* @param rows Mapper 返回的部门餐段合计行和部门餐段来源行
* @param merchantId 当前展开食堂主键,用于明细按钮继续继承食堂范围
* @param includeSourceRows 是否挂载来源拆分;仅多来源视图需要挂载
* @return 部门餐段合计行列表
*/
private List<HardCanteenProductTotalVo> assembleDepartmentChildRows(List<HardCanteenProductTotalVo> rows,
Long merchantId,
boolean includeSourceRows) {
if (ObjectUtil.isEmpty(rows)) return List.of();
rows.forEach(row -> row.setMchId(merchantId));
List<HardCanteenProductTotalVo> sourceRows = rows.stream()
.filter(row -> ObjectUtil.isNotEmpty(row.getSaleSource()))
.toList();
fillSaleSourceFields(sourceRows);
Map<String, List<HardCanteenProductTotalVo>> sourceRowsByScope = sourceRows.stream()
.collect(Collectors.groupingBy(this::departmentChildScopeKey, LinkedHashMap::new, Collectors.toList()));
return rows.stream()
.filter(row -> ObjectUtil.isEmpty(row.getSaleSource()))
.peek(row -> {
fillStatisticsLevelName(row);
row.setSourceRows(includeSourceRows
? sourceRowsByScope.getOrDefault(departmentChildScopeKey(row), List.of())
: List.of());
})
.toList();
}
/**
* 从餐段合计行中提取指定来源的部门餐段行,供主表来源行展开时复用同一批查询结果。
*
* @param totalRows 部门餐段合计行,每行可能挂载来源拆分
* @param saleSource 主表来源行对应的稳定来源编码
* @return 指定来源的部门餐段行;没有匹配数据时返回空列表
*/
private List<HardCanteenProductTotalVo> departmentSourceChildren(List<HardCanteenProductTotalVo> totalRows,
String saleSource) {
if (ObjectUtil.isEmpty(totalRows) || ObjectUtil.isEmpty(saleSource)) return List.of();
return totalRows.stream()
.flatMap(row -> row.getSourceRows().stream())
.filter(row -> ObjectUtil.equals(row.getSaleSource(), saleSource))
.toList();
}
/**
* 构造部门主表合计行和来源行的挂载键。
*
* @param row 日期食堂统计行
* @return “日期_食堂”稳定键;空值使用占位片段避免空指针
*/
private String departmentMainScopeKey(HardCanteenProductTotalVo row) {
String statisticsDate = row == null || row.getCreateTime() == null
? "UNKNOWN" : row.getCreateTime().toLocalDate().toString();
return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getMchId(), -1L);
}
/**
* 构造部门展开合计行和来源行的挂载键。
*
* @param row 部门餐段统计行
* @return “日期_部门_餐段”稳定键;空值使用占位片段避免空指针
*/
private String departmentChildScopeKey(HardCanteenProductTotalVo row) {
String statisticsDate = row == null || row.getCreateTime() == null
? "UNKNOWN" : row.getCreateTime().toLocalDate().toString();
return statisticsDate + "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getUserDeptId(), -1L)
+ "_" + ObjectUtil.defaultIfNull(row == null ? null : row.getHourType(), "ALL");
}
/**
* 补充统计层级中文名称,来源编码为空时仍保留合计层级语义。
*
* @param row Mapper 返回的统计行;层级为空时保持兼容旧接口
*/
private void fillStatisticsLevelName(HardCanteenProductTotalVo row) {
if (row == null || ObjectUtil.isEmpty(row.getStatisticsLevel())) return;
HardCanteenStatisticsLevelEnum level = HardCanteenStatisticsLevelEnum.valueOfCode(row.getStatisticsLevel());
if (level != null) row.setStatisticsLevelName(level.getDescription());
}
/**
* 递归将合计行的食堂、部门、日期和餐段上下文补充到来源拆分行。
*
* <p>来源行来自独立聚合 SQL,只携带分组键;页面把它展开为可直接操作的普通行,因此必须继承名称和
* 查询范围,保证购买人员与菜品明细继续使用当前食堂、部门和餐段。</p>
*
* @param rows 已完成食堂和部门名称填充的合计行列表
*/
private void inheritDepartmentRowContext(List<HardCanteenProductTotalVo> rows) {
if (ObjectUtil.isEmpty(rows)) return;
for (HardCanteenProductTotalVo row : rows) {
inheritDepartmentRowContext(row, row.getChildren());
inheritDepartmentRowContext(row, row.getSourceRows());
}
}
/**
* 将父级业务维度复制到缺少对应值的子级统计行,并继续处理其下级数据。
*
* @param parent 提供食堂、部门、日期和餐段上下文的父级统计行
* @param children 待补充上下文的合计行或来源拆分行
*/
private void inheritDepartmentRowContext(HardCanteenProductTotalVo parent,
List<HardCanteenProductTotalVo> children) {
if (parent == null || ObjectUtil.isEmpty(children)) return;
for (HardCanteenProductTotalVo child : children) {
if (child.getMchId() == null) child.setMchId(parent.getMchId());
if (ObjectUtil.isEmpty(child.getMchName())) child.setMchName(parent.getMchName());
if (child.getUserDeptId() == null) child.setUserDeptId(parent.getUserDeptId());
if (ObjectUtil.isEmpty(child.getUserDeptName())) child.setUserDeptName(parent.getUserDeptName());
if (child.getCreateTime() == null) child.setCreateTime(parent.getCreateTime());
if (ObjectUtil.isEmpty(child.getHourType())) child.setHourType(parent.getHourType());
inheritDepartmentRowContext(child, child.getChildren());
inheritDepartmentRowContext(child, child.getSourceRows());
}
}
/**
* 导出菜品统计,直接复用来源统计 SQL,确保导出与主表的业务发生时间口径一致。
*
* @param dto 来源统计请求
* @param dataScope 部门数据权限
* @return Excel 导出行
*/
@Override
public List<HardCanteenProductTotalExcelVo> exportProduct(HardOrderDto dto, DataScope dataScope) {
if (!isSourceStatistics(dto)) {
throw new IllegalArgumentException("导出必须选择统计日期范围");
}
normalizeSaleSources(dto);
List<HardCanteenProductTotalVo> rows = hardCarMapper.listProductTotalStats(dto, dataScope).stream()
.filter(row -> ObjectUtil.equals(row.getStatisticsLevel(), HardCanteenStatisticsLevelEnum.HOUR_SOURCE.getCode()))
.toList();
fillSaleSourceFields(rows);
Map<String, String> hourTypeNames = getHourTypeNames();
if (ObjectUtil.isEmpty(rows)) return List.of();
return rows.stream().map(row -> {
HardCanteenProductTotalExcelVo excel = new HardCanteenProductTotalExcelVo();
excel.setStatisticsDate(row.getCreateTime() == null ? null : row.getCreateTime().toLocalDate());
excel.setHourTypeName(hourTypeNames.getOrDefault(row.getHourType(), row.getHourType()));
excel.setSaleSourceName(row.getSaleSourceName());
excel.setPayUserCount(row.getPayUserCount());
excel.setTimeCount(row.getTimeCount());
excel.setPayCount(row.getPayCount());
excel.setTotalPrice(centsToYuan(row.getTotalPrice()));
return excel;
}).toList();
}
/**
* 导出部门统计,复用部门主表及子行装配结果后扁平化为 Excel 行。
*
* @param dto 来源统计请求
* @param user 当前登录用户
* @param dataScope 部门数据权限
* @return Excel 导出行
*/
@Override
public List<HardCanteenDeptTotalExcelVo> exportDept(HardOrderDto dto, PjcloudUser user, DataScope dataScope) {
if (!isSourceStatistics(dto)) {
throw new IllegalArgumentException("导出必须选择统计日期范围");
}
dto.setTotalIsFindChildren(true);
List<HardCanteenProductTotalVo> rows = listByDept(dto, user, dataScope);
if (ObjectUtil.isEmpty(rows)) return List.of();
Map<String, String> hourTypeNames = getHourTypeNames();
List<HardCanteenDeptTotalExcelVo> result = new ArrayList<>();
for (HardCanteenProductTotalVo parent : rows) {
if (ObjectUtil.isEmpty(parent.getChildren())) continue;
for (HardCanteenProductTotalVo child : parent.getChildren()) {
List<HardCanteenProductTotalVo> exportRows = ObjectUtil.isNotEmpty(child.getSourceRows())
? child.getSourceRows() : List.of(child);
for (HardCanteenProductTotalVo exportRow : exportRows) {
HardCanteenDeptTotalExcelVo excel = new HardCanteenDeptTotalExcelVo();
excel.setStatisticsDate(parent.getCreateTime() == null ? null : parent.getCreateTime().toLocalDate());
excel.setMerchantName(parent.getMchName());
excel.setSaleSourceName(resolveDepartmentExportSourceName(exportRow, dto));
excel.setUserDeptName(exportRow.getUserDeptName());
excel.setHourTypeName(hourTypeNames.getOrDefault(exportRow.getHourType(), exportRow.getHourType()));
excel.setPayUserCount(exportRow.getPayUserCount());
excel.setTimeCount(exportRow.getTimeCount());
excel.setPayCount(exportRow.getPayCount());
excel.setTotalPrice(centsToYuan(exportRow.getTotalPrice()));
result.add(excel);
}
}
}
return result;
}
/**
* 解析部门导出的具体销售来源名称。
*
* <p>多来源导出使用来源拆分行自身名称;单来源查询为避免页面重复行不会挂载来源拆分,
* 此时使用已经校验过的请求来源补全名称。</p>
*
* @param row 待导出的部门餐段统计行
* @param dto 已完成来源标准化的统计请求
* @return 可追溯的具体来源中文名称;来源异常时返回空值
*/
private String resolveDepartmentExportSourceName(HardCanteenProductTotalVo row, HardOrderDto dto) {
if (row != null && ObjectUtil.isNotEmpty(row.getSaleSourceName())) return row.getSaleSourceName();
if (dto == null || ObjectUtil.isEmpty(dto.getSaleSourceCodes()) || dto.getSaleSourceCodes().size() != 1) return null;
HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(dto.getSaleSourceCodes().get(0));
return source == null ? null : source.getDescription();
}
/**
* 读取餐段字典并生成编码到名称的映射;远程数据为空时导出保留原编码。
*
* @return 餐段编码与中文名称映射
*/
private Map<String, String> getHourTypeNames() {
try {
List<SysDictItem> items = remoteDictService.getDictByType("daily_menu").getData();
if (ObjectUtil.isEmpty(items)) return Map.of();
return items.stream().filter(item -> ObjectUtil.isNotEmpty(item.getItemValue()) && ObjectUtil.isNotEmpty(item.getLabel()))
.collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left));
} catch (Exception exception) {
log.warn("{}:读取餐段字典失败,导出将保留餐段编码", TAG, exception);
return Map.of();
}
}
/**
* 将持久化使用的分转换为导出展示使用的元,空金额按零处理。
*
* @param cents 金额,单位为分
* @return 保留两位小数的元金额
*/
private BigDecimal centsToYuan(Long cents) {
return BigDecimal.valueOf(ObjectUtil.defaultIfNull(cents, 0L)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/**
* 判断是否启用来源统计查询。未传统计日期时保留旧 SQL,兼容历史页面和其他服务调用方。
*
* @param dto 统计请求
* @return 日期范围完整时启用新版来源统计
*/
private boolean isSourceStatistics(HardOrderDto dto) {
return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList());
}
/**
* 判断菜品明细请求是否启用来源统计查询。
*
* @param dto 菜品明细请求
* @return 日期范围存在且有效时返回 true
*/
private boolean isSourceStatistics(HardCarDto dto) {
return validateStatisticsDateRange(dto == null ? null : dto.getStatisticsDateTimeList());
}
/**
* 校验统计日期闭区间;未传时表示兼容旧查询,传入后必须包含有效的开始和结束时间。
*
* @param range 统计日期时间范围
* @return 未传返回 false,合法范围返回 true
*/
private boolean validateStatisticsDateRange(LocalDateTime[] range) {
if (range == null) return false;
if (range.length != 2 || range[0] == null || range[1] == null) {
throw new IllegalArgumentException("统计日期范围必须包含开始和结束时间");
}
if (range[1].isBefore(range[0])) {
throw new IllegalArgumentException("统计结束时间不能早于开始时间");
}
return true;
}
/**
* 校验来源编码并补全空来源为全部来源,避免未知编码导致动态 UNION 生成空 SQL。
*
* @param dto 统计请求
*/
private void normalizeSaleSources(HardOrderDto dto) {
dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes()));
}
/**
* 校验菜品明细来源并将空来源补全为全部来源。
*
* @param dto 菜品明细请求
*/
private void normalizeSaleSources(HardCarDto dto) {
dto.setSaleSourceCodes(normalizeSaleSources(dto.getSaleSourceCodes()));
}
/**
* 统一校验来源编码并去重,保证 Mapper 只接收稳定的来源协议值。
*
* @param sources 调用方传入的来源编码
* @return 非空且去重后的来源编码
*/
private List<String> normalizeSaleSources(List<String> sources) {
if (ObjectUtil.isEmpty(sources)) {
return HardSaleSourceEnum.getCodes();
}
Set<String> supported = new HashSet<>(HardSaleSourceEnum.getCodes());
if (sources.stream().anyMatch(source -> !supported.contains(source))) {
throw new IllegalArgumentException("销售来源编码不正确");
}
return sources.stream().distinct().toList();
}
/**
* 为统计主表、展开行或菜品明细批量补充来源中文名称。
*
* @param rows Mapper 返回的来源统计行;为空时不处理
*/
private void fillSaleSourceFields(List<? extends Object> rows) {
if (ObjectUtil.isEmpty(rows)) return;
for (Object row : rows) {
if (row instanceof HardCanteenProductTotalVo total) fillSaleSourceFields(total);
if (row instanceof HardCarVo car) fillSaleSourceFields(car);
}
}
/**
* 为菜品统计主表或展开行补充来源名称。
*
* @param row 单条菜品统计行;来源为空时保留兼容旧查询的空值
*/
private void fillSaleSourceFields(HardCanteenProductTotalVo row) {
if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return;
HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource());
if (source != null) row.setSaleSourceName(source.getDescription());
}
/**
* 为菜品汇总明细补充来源名称。
*
* @param row 单条菜品明细;来源为空时保留兼容旧查询的空值
*/
private void fillSaleSourceFields(HardCarVo row) {
if (row == null || ObjectUtil.isEmpty(row.getSaleSource())) return;
HardSaleSourceEnum source = HardSaleSourceEnum.valueOfCode(row.getSaleSource());
if (source != null) row.setSaleSourceName(source.getDescription());
}
/**
* 为HardCanteenProductTotalVo增加部门信息
*/
@@ -173,6 +173,17 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
);
}
/**
* 批量读取订单页所需的商品快照,避免按订单逐条查询购物车造成 N+1 次数据库访问。
*
* @param parentCarIds 父购物车主键集合
* @return 各父购物车下的商品快照,空集合输入返回空列表
*/
@Override
public List<HardCarEntity> getByParentCarIds(List<Long> parentCarIds) {
return baseMapper.listByParentCarIds(parentCarIds);
}
@Override
public HardParentCarEntity getParentCarById(Long id) {
return hardParentCarMapper.selectById(id);
@@ -325,7 +336,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
*/
private void createDailyMenuCartItems(Long parentCarId, Long merchantId, List<HardCreateCarDto> productList) {
List<Long> productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList();
R<List<CanteenSpuEntity>> response = canteenSpuService.getSpuListByIds(productIds);
R<List<CanteenSpuEntity>> response = canteenSpuService.getInnerSpuListByIds(productIds);
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null || response.getMsg() == null ? "查询每日菜谱商品失败" : response.getMsg();
throw new RuntimeException(message);
@@ -0,0 +1,693 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.util.StrUtil;
import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
import com.bsd.pjcloud.admin.api.vo.SysUserFaceEvidenceSourceVo;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceRoleEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderEvidenceTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
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.manager.HardManagerEntity;
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 com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderEvidenceImageMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardOrderEvidenceImageService;
import feign.Response;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriComponentsBuilder;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 订单识别证据服务实现,证据文件和订单事务相互独立,失败只影响证据状态。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HardOrderEvidenceImageServiceImpl extends ServiceImpl<HardOrderEvidenceImageMapper, HardOrderEvidenceImageEntity>
implements HardOrderEvidenceImageService {
/**
* 证据服务诊断日志标签,日志中不输出图片、Token 或本地完整路径。
*/
private static final String TAG = "订单识别证据";
/**
* 终端现场图最大接收大小,单位字节;终端目标约100KB,服务端留出编码差异余量。
*/
private static final int LIVE_IMAGE_MAX_BYTES = 512 * 1024;
/**
* 后台参考人脸原图最大读取大小,单位字节,防止异常文件耗尽服务内存。
*/
private static final int REFERENCE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
/**
* 终端现场图允许的最大边长,超过时拒绝而不在服务端二次改变证据内容。
*/
private static final int LIVE_IMAGE_MAX_EDGE = 640;
/**
* 单个证据位置分布式锁的最长持有时间,单位毫秒。
*/
private static final long EVIDENCE_LOCK_EXPIRE_MILLIS = 180_000L;
/**
* 并发上传同一证据时等待锁的最长时间,单位毫秒。
*/
private static final long EVIDENCE_LOCK_WAIT_MILLIS = 5_000L;
/**
* 文件中心保存订单证据时使用的稳定目录前缀。
*/
private static final String FILE_DIRECTORY_PREFIX = "order-evidence";
/**
* 文件中心标识订单证据文件的稳定业务类型。
*/
private static final String FILE_TYPE = "order-evidence";
/**
* 服务端创建订单缺少终端订单号时使用的证据内部引用前缀;仅写入证据快照,不回写订单主表。
*/
private static final String SERVER_ORDER_REFERENCE_PREFIX = "SERVER_ORDER_";
/**
* UPMS 人脸图片受信下载接口的路径后缀;其他来源地址一律拒绝。
*/
private static final String TRUSTED_FACE_FILE_PATH = "/sys-file/oss/file";
/**
* 订单主表 Mapper,用于按终端订单号和设备校验订单归属。
*/
private final HardOrderMapper hardOrderMapper;
/**
* 设备 Mapper,用于确认请求头 DEVICE-ID 属于当前租户的有效设备。
*/
private final HardManagerMapper hardManagerMapper;
/**
* UPMS 人脸远程服务,用于校验用户、样本和审核通过原图的一致性。
*/
private final RemoteUserFaceService remoteUserFaceService;
/**
* UPMS 文件中心远程服务,用于读取受信参考图并保存订单专属文件。
*/
private final RemoteFileService remoteFileService;
/**
* 分布式锁模板,用于串行化同一订单证据位置的并发上传。
*/
private final LockTemplate lockTemplate;
/**
* 按终端订单号或服务端订单 ID 解析订单并返回当前证据状态。
*
* @param hardId 当前设备主键
* @param androidOrderCode 终端本地订单号,预约核销证据允许为空
* @param orderId 服务端订单主键,现场下单证据允许为空
* @return 当前订单状态及已登记证据
*/
@Override
public HardOrderEvidenceResolveVo resolve(Long hardId, String androidOrderCode, Long orderId) {
HardOrderEntity order = requireOrder(hardId, androidOrderCode, orderId, false);
return HardOrderEvidenceResolveVo.builder()
.orderId(order.getId())
.orderStatus(order.getOrderStatus())
.evidenceImages(listByOrderId(order.getId()))
.build();
}
/**
* 提交元数据时先建立占位;参考人脸继续执行受信复制,现场图等待 multipart 上传。
*/
@Override
public HardOrderEvidenceUploadResultVo submit(Long hardId, HardOrderEvidenceSubmitDto dto) {
HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true);
validateMetadata(dto, order);
HardOrderEvidenceStatusEnum requestedStatus = HardOrderEvidenceStatusEnum.valueOfCode(dto.getStatus());
if (requestedStatus != HardOrderEvidenceStatusEnum.PENDING
&& requestedStatus != HardOrderEvidenceStatusEnum.UNAVAILABLE) {
throw new RuntimeException("终端只允许提交待补传或永久缺失状态");
}
if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE && StrUtil.isBlank(dto.getFailureReason())) {
throw new RuntimeException("永久缺失时必须说明失败原因");
}
return withEvidenceLock(order, dto, () -> {
HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion());
if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) {
return result(entity, false, "证据已经上传,无需重复处理");
}
if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传");
}
if (requestedStatus == HardOrderEvidenceStatusEnum.UNAVAILABLE) {
entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode());
entity.setFailureReason(sanitizeFailure(dto.getFailureReason()));
updateById(entity);
return result(entity, false, "已记录证据永久缺失");
}
if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) {
return copyReferenceImage(entity, order, dto);
}
return result(entity, false, "证据元数据已登记,等待现场图片上传");
});
}
/**
* 上传现场图片时严格校验哈希、格式、尺寸和唯一位置,文件中心成功后才切换 AVAILABLE。
*/
@Override
public HardOrderEvidenceUploadResultVo upload(Long hardId, HardOrderEvidenceUploadDto dto, MultipartFile file) {
HardOrderEntity order = requireOrder(hardId, dto.getAndroidOrderCode(), dto.getOrderId(), true);
validateMetadata(dto, order);
if (HardOrderEvidenceRoleEnum.FACE_REFERENCE.getCode().equals(dto.getEvidenceRole())) {
throw new RuntimeException("后台参考人脸必须由服务端复制,终端不能直接上传");
}
if (StrUtil.isBlank(dto.getImageHash())) {
throw new RuntimeException("现场图片哈希不能为空");
}
ImageSnapshot image = readImage(file, LIVE_IMAGE_MAX_BYTES, true);
if (!dto.getImageHash().equalsIgnoreCase(image.hash())) {
throw new RuntimeException("现场图片内容与终端哈希不一致");
}
validateReportedImage(dto, image);
return withEvidenceLock(order, dto, () -> {
HardOrderEvidenceImageEntity entity = upsertMetadata(order, dto, dto.getAppVersion());
if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())) {
if (!image.hash().equalsIgnoreCase(entity.getImageHash())) {
throw new RuntimeException("该证据位置已保存其他图片,禁止覆盖");
}
return result(entity, false, "相同证据已经上传");
}
if (HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
return result(entity, false, "该证据位置已记录为永久缺失,不能重新补传");
}
validateHashConflict(entity, image.hash());
storeImage(entity, order, image, evidenceFileName(order, dto, image.extension()));
return result(entity, true, "现场证据上传成功");
});
}
/**
* 订单提交的证据清单只创建 PENDING 占位,不执行远程文件调用,确保订单主链路快速完成。
*/
@Override
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void registerOrderEvidence(HardOrderEntity order, List<HardOrderEvidenceMetadataDto> metadataList, String appVersion) {
if (order == null || metadataList == null || metadataList.isEmpty()) return;
for (HardOrderEvidenceMetadataDto metadata : metadataList) {
validateMetadata(metadata, order);
HardOrderEvidenceImageEntity existing = baseMapper.findPosition(order.getId(), metadata.getEvidenceType(),
metadata.getEvidenceRole(), metadata.getSequenceNo());
if (existing != null) {
validateHashConflict(existing, metadata.getImageHash());
continue;
}
HardOrderEvidenceImageEntity entity = buildEntity(order, metadata, appVersion);
entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
save(entity);
}
}
/**
* 转换数据库实体并补齐稳定编码的中文名称,未知历史编码保持原值便于排障。
*/
@Override
public List<HardOrderEvidenceImageVo> listByOrderId(Long orderId) {
if (orderId == null) return List.of();
return baseMapper.listByOrderId(orderId).stream().map(entity -> {
HardOrderEvidenceImageVo vo = BeanUtils.toBean(entity, HardOrderEvidenceImageVo.class);
HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(entity.getEvidenceType());
HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(entity.getEvidenceRole());
HardOrderEvidenceStatusEnum status = HardOrderEvidenceStatusEnum.valueOfCode(entity.getStatus());
vo.setEvidenceTypeName(type == null ? entity.getEvidenceType() : type.getDescription());
vo.setEvidenceRoleName(role == null ? entity.getEvidenceRole() : role.getDescription());
vo.setStatusName(status == null ? entity.getStatus() : status.getDescription());
return vo;
}).toList();
}
/**
* 校验设备存在,并按设备和至少一种订单标识限定订单,避免跨设备探测或写入证据。
*
* <p>预约订单只有在核销成功后才会把 {@code hard_id} 更新为当前设备,因此终端即使提前知道
* 服务端订单 ID,也不能在核销前或其他设备上写入证据。</p>
*
* @param hardId 当前设备主键
* @param androidOrderCode 终端本地订单号,允许为空
* @param orderId 服务端订单主键,允许为空
* @param requirePaid 是否要求订单处于已支付、已退款或已核销状态
* @return 属于当前设备的订单
*/
private HardOrderEntity requireOrder(Long hardId, String androidOrderCode, Long orderId, boolean requirePaid) {
if (hardId == null) throw new RuntimeException("DEVICE-ID不能为空");
if (StrUtil.isBlank(androidOrderCode) && orderId == null) {
throw new RuntimeException("终端订单号和服务端订单ID不能同时为空");
}
HardManagerEntity hard = hardManagerMapper.selectById(hardId);
if (hard == null) throw new RuntimeException("当前租户下设备不存在");
LambdaQueryWrapper<HardOrderEntity> query = new LambdaQueryWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getHardId, hardId)
.eq(orderId != null, HardOrderEntity::getId, orderId)
.eq(StrUtil.isNotBlank(androidOrderCode), HardOrderEntity::getAndroidOrderCode, androidOrderCode);
HardOrderEntity order = hardOrderMapper.selectOne(query);
if (order == null) throw new RuntimeException("订单不存在或不属于当前设备");
if (requirePaid && !HardOrderStatusEnum.getPayPrice().contains(order.getOrderStatus())) {
throw new RuntimeException("订单尚未确认支付成功,暂不能保存识别证据");
}
return order;
}
/**
* 校验类型、角色、序号、用户和人脸样本约束,阻止终端构造不一致的证据位置。
*/
private void validateMetadata(HardOrderEvidenceMetadataDto dto, HardOrderEntity order) {
if (dto == null) throw new RuntimeException("证据元数据不能为空");
HardOrderEvidenceTypeEnum type = HardOrderEvidenceTypeEnum.valueOfCode(dto.getEvidenceType());
HardOrderEvidenceRoleEnum role = HardOrderEvidenceRoleEnum.valueOfCode(dto.getEvidenceRole());
if (type == null || role == null) throw new RuntimeException("证据类型或图片角色不合法");
if (dto.getSequenceNo() == null || dto.getSequenceNo() < 0 || dto.getSequenceNo() > 99) {
throw new RuntimeException("证据图片序号不合法");
}
if (type == HardOrderEvidenceTypeEnum.FACE_RECOGNITION) {
if (role != HardOrderEvidenceRoleEnum.FACE_REFERENCE && role != HardOrderEvidenceRoleEnum.FACE_LIVE) {
throw new RuntimeException("人脸识别证据角色不合法");
}
if (dto.getSequenceNo() != 0) throw new RuntimeException("人脸识别证据序号必须为0");
if (dto.getUserId() == null || dto.getUserId() <= 0 || StrUtil.isBlank(dto.getFaceSampleId())) {
throw new RuntimeException("人脸识别证据缺少用户或样本编号");
}
if (!Objects.equals(order.getUserId(), dto.getUserId())) {
throw new RuntimeException("识别用户与订单用户不一致");
}
if (role == HardOrderEvidenceRoleEnum.FACE_REFERENCE && StrUtil.isBlank(dto.getReferenceImageUrl())) {
throw new RuntimeException("参考人脸证据缺少识别时图片地址");
}
} else if (role != HardOrderEvidenceRoleEnum.MENU_LIVE) {
throw new RuntimeException("菜谱识别证据角色不合法");
}
}
/**
* 创建或更新 PENDING 元数据,AVAILABLE 和 UNAVAILABLE 终态只做哈希一致性检查并保持不可变。
*/
private HardOrderEvidenceImageEntity upsertMetadata(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) {
HardOrderEvidenceImageEntity entity = baseMapper.findPosition(order.getId(), dto.getEvidenceType(),
dto.getEvidenceRole(), dto.getSequenceNo());
if (entity == null) {
entity = buildEntity(order, dto, appVersion);
entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
save(entity);
return entity;
}
validateHashConflict(entity, dto.getImageHash());
if (HardOrderEvidenceStatusEnum.AVAILABLE.getCode().equals(entity.getStatus())
|| HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode().equals(entity.getStatus())) {
return entity;
}
fillMetadata(entity, dto, appVersion);
entity.setStatus(HardOrderEvidenceStatusEnum.PENDING.getCode());
entity.setFailureReason(null);
updateById(entity);
return entity;
}
/**
* 构造新的证据占位实体并写入订单、设备和租户快照。
*/
private HardOrderEvidenceImageEntity buildEntity(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String appVersion) {
HardOrderEvidenceImageEntity entity = new HardOrderEvidenceImageEntity();
entity.setOrderId(order.getId());
// 预约核销订单不是消费机现场创建的,通常没有 android_order_code;历史证据表该列为非空,
// 因此保存现有服务端订单号或订单主键生成的内部引用。现场订单仍保留真实终端订单号。
entity.setAndroidOrderCode(resolveEvidenceOrderReference(order));
entity.setHardId(order.getHardId());
entity.setTenantId(TenantContextHolder.getTenantId());
fillMetadata(entity, dto, appVersion);
return entity;
}
/**
* 解析证据表必须保存的稳定订单引用,避免预约订单因没有终端订单号而无法写入证据。
*
* @param order 已完成持久化的订单;现场订单应包含终端订单号,服务端订单至少应包含订单号或主键
* @return 真实终端订单号、服务端订单号或基于服务端主键生成的内部引用
*/
private String resolveEvidenceOrderReference(HardOrderEntity order) {
if (order == null) throw new RuntimeException("订单证据缺少订单信息");
if (StrUtil.isNotBlank(order.getAndroidOrderCode())) return order.getAndroidOrderCode();
if (StrUtil.isNotBlank(order.getOrderNo())) return order.getOrderNo();
if (order.getId() == null) throw new RuntimeException("订单证据缺少服务端订单ID");
return SERVER_ORDER_REFERENCE_PREFIX + order.getId();
}
/**
* 更新终端识别快照;文件中心字段只能由 storeImage 写入,不能由终端覆盖。
*/
private void fillMetadata(HardOrderEvidenceImageEntity entity, HardOrderEvidenceMetadataDto dto, String appVersion) {
entity.setEvidenceType(dto.getEvidenceType());
entity.setEvidenceRole(dto.getEvidenceRole());
entity.setSequenceNo(dto.getSequenceNo());
entity.setUserId(dto.getUserId());
entity.setFaceSampleId(dto.getFaceSampleId());
entity.setReferenceImageUrl(dto.getReferenceImageUrl());
entity.setSimilarity(dto.getSimilarity());
entity.setSimilarityThreshold(dto.getSimilarityThreshold());
entity.setCapturedAt(dto.getCapturedAt());
if (StrUtil.isNotBlank(dto.getImageHash())) entity.setImageHash(dto.getImageHash().toLowerCase());
if (dto.getImageSize() != null) entity.setImageSize(dto.getImageSize());
if (dto.getImageWidth() != null) entity.setImageWidth(dto.getImageWidth());
if (dto.getImageHeight() != null) entity.setImageHeight(dto.getImageHeight());
if (StrUtil.isNotBlank(appVersion)) entity.setAppVersion(appVersion);
}
/**
* 通过 UPMS 权威记录校验参考图,来源已变化时标记永久缺失,远程暂时失败则保留 PENDING 重试。
*/
private HardOrderEvidenceUploadResultVo copyReferenceImage(HardOrderEvidenceImageEntity entity,
HardOrderEntity order,
HardOrderEvidenceMetadataDto dto) {
R<SysUserFaceEvidenceSourceVo> sourceResult = remoteUserFaceService.getEvidenceSource(dto.getUserId(), dto.getFaceSampleId());
if (sourceResult == null || !sourceResult.isOk()) {
throw new RuntimeException(sourceResult == null || StrUtil.isBlank(sourceResult.getMsg())
? "查询后台参考人脸失败" : sourceResult.getMsg());
}
SysUserFaceEvidenceSourceVo source = sourceResult.getData();
if (source == null || StrUtil.isBlank(source.getFaceImg())) {
return markUnavailable(entity, "识别命中的后台人脸已不存在或不再可用");
}
if (!Objects.equals(dto.getReferenceImageUrl(), source.getFaceImg())) {
return markUnavailable(entity, "后台参考人脸在识别后已发生变化");
}
if (dto.getReferenceImageVersion() != null
&& !Objects.equals(dto.getReferenceImageVersion(), source.getSourceUpdatedAt())) {
return markUnavailable(entity, "后台参考人脸版本在识别后已发生变化");
}
String sourceFileName;
try {
sourceFileName = trustedFileName(source.getFaceImg());
} catch (RuntimeException exception) {
return markUnavailable(entity, exception.getMessage());
}
ImageSnapshot image;
try (Response response = remoteFileService.getFile(sourceFileName)) {
if (response == null || response.status() < 200 || response.status() >= 300 || response.body() == null) {
throw new RuntimeException("读取后台参考人脸失败");
}
image = readImage(response.body().asInputStream(), sourceFileName, REFERENCE_IMAGE_MAX_BYTES, false);
} catch (IOException exception) {
throw new RuntimeException("读取后台参考人脸失败", exception);
}
if (StrUtil.isNotBlank(dto.getImageHash()) && !dto.getImageHash().equalsIgnoreCase(image.hash())) {
return markUnavailable(entity, "后台参考人脸内容与识别时哈希不一致");
}
validateHashConflict(entity, image.hash());
storeImage(entity, order, image, evidenceFileName(order, dto, image.extension()));
return result(entity, true, "后台参考人脸已复制为订单证据");
}
/**
* 将不可恢复的参考图变化记录为 UNAVAILABLE,保留已有元数据供 PC 追溯。
*/
private HardOrderEvidenceUploadResultVo markUnavailable(HardOrderEvidenceImageEntity entity, String reason) {
entity.setStatus(HardOrderEvidenceStatusEnum.UNAVAILABLE.getCode());
entity.setFailureReason(sanitizeFailure(reason));
updateById(entity);
return result(entity, false, reason);
}
/**
* 写入文件中心并在成功响应后原子更新证据记录,不删除任何历史证据文件。
*/
@SuppressWarnings("unchecked")
private void storeImage(HardOrderEvidenceImageEntity entity, HardOrderEntity order, ImageSnapshot image, String originalName) {
MultipartFile uploadFile = new MockMultipartFile("file", originalName, image.contentType(), image.bytes());
R<?> uploadResult = remoteFileService.innerUpload(uploadFile, FILE_DIRECTORY_PREFIX + "/" + order.getId(), null, FILE_TYPE);
if (uploadResult == null || !uploadResult.isOk() || !(uploadResult.getData() instanceof Map<?, ?> storage)) {
throw new RuntimeException(uploadResult == null || StrUtil.isBlank(uploadResult.getMsg()) ? "订单证据写入文件中心失败" : uploadResult.getMsg());
}
entity.setSysFileId(numberValue(storage.get("id")));
entity.setStorageFileName(requiredString(storage.get("fileName"), "文件中心未返回对象名"));
entity.setImageUrl(requiredString(storage.get("url"), "文件中心未返回访问地址"));
entity.setImageHash(image.hash());
entity.setImageSize((long) image.bytes().length);
entity.setImageWidth(image.width());
entity.setImageHeight(image.height());
entity.setStatus(HardOrderEvidenceStatusEnum.AVAILABLE.getCode());
entity.setFailureReason(null);
updateById(entity);
}
/**
* 读取终端 MultipartFile 并复用统一图片内容校验。
*/
private ImageSnapshot readImage(MultipartFile file, int maxBytes, boolean liveImage) {
if (file == null || file.isEmpty()) throw new RuntimeException("上传图片不能为空");
try {
return readImage(new ByteArrayInputStream(file.getBytes()), file.getOriginalFilename(), maxBytes, liveImage);
} catch (IOException exception) {
throw new RuntimeException("读取上传图片失败", exception);
}
}
/**
* 有上限地读取并解码图片,拒绝伪造后缀、空内容和超大像素尺寸。
*/
private ImageSnapshot readImage(InputStream inputStream, String originalName, int maxBytes, boolean liveImage) {
byte[] bytes = readLimitedBytes(inputStream, maxBytes);
try {
BufferedImage bufferedImage = ImageIO.read(new ByteArrayInputStream(bytes));
if (bufferedImage == null || bufferedImage.getWidth() <= 0 || bufferedImage.getHeight() <= 0) {
throw new RuntimeException("文件不是有效图片");
}
String extension = detectExtension(bytes, originalName);
if (liveImage && Math.max(bufferedImage.getWidth(), bufferedImage.getHeight()) > LIVE_IMAGE_MAX_EDGE) {
throw new RuntimeException("现场图片最长边不能超过640像素");
}
String contentType = "png".equals(extension) ? "image/png" : "image/jpeg";
return new ImageSnapshot(bytes, sha256(bytes), bufferedImage.getWidth(), bufferedImage.getHeight(), extension, contentType);
} catch (IOException exception) {
throw new RuntimeException("图片解码失败", exception);
}
}
/**
* 按字节签名识别 JPEG/PNG,扩展名只作为错误信息补充而不作为信任依据。
*/
private String detectExtension(byte[] bytes, String originalName) {
if (bytes.length >= 3 && (bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8 && (bytes[2] & 0xff) == 0xff) {
return "jpg";
}
if (bytes.length >= 8 && (bytes[0] & 0xff) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) {
return "png";
}
throw new RuntimeException("仅支持真实的JPEG或PNG图片:" + StrUtil.blankToDefault(originalName, "未命名文件"));
}
/**
* 按上限读取远程或上传字节流,超过上限立即拒绝以控制内存占用。
*/
private byte[] readLimitedBytes(InputStream inputStream, int maxBytes) {
try (InputStream source = inputStream; ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int total = 0;
int read;
while ((read = source.read(buffer)) >= 0) {
total += read;
if (total > maxBytes) throw new RuntimeException("图片文件超过允许大小");
output.write(buffer, 0, read);
}
if (total == 0) throw new RuntimeException("图片文件不能为空");
return output.toByteArray();
} catch (IOException exception) {
throw new RuntimeException("读取图片文件失败", exception);
}
}
/**
* 校验终端上报的大小和宽高,防止元数据与实际证据内容不一致。
*/
private void validateReportedImage(HardOrderEvidenceMetadataDto dto, ImageSnapshot image) {
if (dto.getImageSize() != null && dto.getImageSize() != image.bytes().length) {
throw new RuntimeException("现场图片大小与终端上报不一致");
}
if (dto.getImageWidth() != null && !dto.getImageWidth().equals(image.width())) {
throw new RuntimeException("现场图片宽度与终端上报不一致");
}
if (dto.getImageHeight() != null && !dto.getImageHeight().equals(image.height())) {
throw new RuntimeException("现场图片高度与终端上报不一致");
}
}
/**
* 从受信文件接口地址提取对象名,禁止服务端按终端参数访问任意 URL。
*/
private String trustedFileName(String sourceUrl) {
try {
var components = UriComponentsBuilder.fromUriString(sourceUrl).build();
String path = components.getPath();
String fileName = components.getQueryParams().getFirst("fileName");
if (StrUtil.isBlank(path) || !path.endsWith(TRUSTED_FACE_FILE_PATH) || StrUtil.isBlank(fileName)
|| fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new RuntimeException("后台参考人脸来源不受信任");
}
return fileName;
} catch (IllegalArgumentException exception) {
throw new RuntimeException("后台参考人脸地址不合法", exception);
}
}
/**
* 相同证据位置只接受相同哈希;未知哈希的 PENDING 占位可在首次文件上传时补齐。
*/
private void validateHashConflict(HardOrderEvidenceImageEntity existing, String incomingHash) {
if (existing != null && StrUtil.isNotBlank(existing.getImageHash()) && StrUtil.isNotBlank(incomingHash)
&& !existing.getImageHash().equalsIgnoreCase(incomingHash)) {
throw new RuntimeException("该证据位置已登记其他图片哈希,禁止覆盖");
}
}
/**
* 使用不含租户编号的稳定业务 Key 串行化证据位置,租户前缀由 Redis 基础设施自动处理。
*/
private <T> T withEvidenceLock(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, LockedAction<T> action) {
String lockKey = String.format("hard:order-evidence:%s:%s:%s:%s", order.getId(), dto.getEvidenceType(),
dto.getEvidenceRole(), dto.getSequenceNo());
LockInfo lock = lockTemplate.lock(lockKey, EVIDENCE_LOCK_EXPIRE_MILLIS, EVIDENCE_LOCK_WAIT_MILLIS);
if (lock == null) throw new RuntimeException("该订单证据正在处理,请稍后重试");
try {
return action.run();
} finally {
if (!lockTemplate.releaseLock(lock)) log.warn("|{}|证据锁释放失败|orderId:{}|", TAG, order.getId());
}
}
/**
* 生成不包含用户姓名等敏感信息的订单证据文件名。
*/
private String evidenceFileName(HardOrderEntity order, HardOrderEvidenceMetadataDto dto, String extension) {
return String.format("order-%s-%s-%s-%s.%s", order.getId(), dto.getEvidenceType().toLowerCase(),
dto.getEvidenceRole().toLowerCase(), dto.getSequenceNo(), extension);
}
/**
* 将任意失败说明限制为单行500字符,避免终端路径或异常栈进入证据主记录。
*/
private String sanitizeFailure(String source) {
String value = StrUtil.blankToDefault(source, "证据文件不可用").replace('\n', ' ').replace('\r', ' ').trim();
return value.length() <= 500 ? value : value.substring(0, 500);
}
/**
* 计算图片 SHA-256,使用 Java 17 十六进制编码保持终端与服务端一致。
*/
private String sha256(byte[] bytes) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前JVM不支持SHA-256", exception);
}
}
/**
* 将文件中心返回的数值主键安全转换为 Long。
*/
private Long numberValue(Object value) {
if (value instanceof Number number) return number.longValue();
if (value != null && StrUtil.isNotBlank(value.toString())) {
try {
return Long.valueOf(value.toString());
} catch (NumberFormatException ignored) {
// 统一在下方抛出文件中心协议错误。
}
}
throw new RuntimeException("文件中心未返回有效文件主键");
}
/**
* 读取文件中心必需字符串字段,空值作为协议错误处理。
*/
private String requiredString(Object value, String message) {
if (value == null || StrUtil.isBlank(value.toString())) throw new RuntimeException(message);
return value.toString();
}
/**
* 构造终端同步使用的简短幂等结果。
*/
private HardOrderEvidenceUploadResultVo result(HardOrderEvidenceImageEntity entity, boolean uploaded, String message) {
return HardOrderEvidenceUploadResultVo.builder()
.evidenceId(entity.getId())
.orderId(entity.getOrderId())
.status(entity.getStatus())
.imageUrl(entity.getImageUrl())
.uploaded(uploaded)
.message(message)
.build();
}
/**
* 持锁执行单个证据位置动作的函数接口,允许统一保证锁释放。
*/
@FunctionalInterface
private interface LockedAction<T> {
/**
* 执行可能读写证据和文件中心的业务动作。
*
* @return 动作结果
*/
T run();
}
/**
* 已校验图片的不可变内存快照,避免上传前后再次读取可变临时文件。
*
* @param bytes 图片原始字节
* @param hash SHA-256 哈希
* @param width 图片宽度,单位像素
* @param height 图片高度,单位像素
* @param extension 由字节签名确定的扩展名
* @param contentType 与扩展名一致的 MIME 类型
*/
private record ImageSnapshot(byte[] bytes, String hash, int width, int height, String extension,
String contentType) {
}
}
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper">
<!--
候选列表只返回尚未发起任何自动资金尝试的任务。
已核销、已退款、非每日菜谱订单和失败重试任务分别留在任务页或异常处理流程。
开关关闭竞态中若已经创建本任务自己的系统售后,仍允许业务人员从候选列表完成它;其他售后继续排除。
-->
<select id="pageExpiredCandidates"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity">
select t.*
from hard_auto_refund_task t
inner join hard_order o
on o.id = t.order_id
and o.del_flag = '0'
and o.order_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where t.del_flag = '0'
and t.task_type = '20'
and t.status in ('10', '30')
and t.trigger_time &lt;= #{now}
and coalesce(t.auto_attempt_count, 0) = 0
and (o.refund_price is null or o.refund_price = 0)
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status in ('10', '50')
and (t.after_sale_id is null or s.id &lt;&gt; t.after_sale_id)
)
and (t.after_sale_id is null or exists (
select 1
from hard_after_sale own_s
where own_s.id = t.after_sale_id
and own_s.order_id = o.id
and own_s.del_flag = '0'
and own_s.status = '10'
and own_s.no = concat('hard_expired_', o.id)
))
<if test="dto.id != null">
and t.id = #{dto.id}
</if>
<if test="dto.orderId != null">
and t.order_id = #{dto.orderId}
</if>
<if test="dto.mchId != null">
and t.mch_id = #{dto.mchId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and t.mch_id in
<foreach collection="dto.mchIds" item="mchId" open="(" close=")" separator=",">
#{mchId}
</foreach>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and t.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
order by t.trigger_time asc, t.id asc
</select>
</mapper>
@@ -57,6 +57,193 @@
</if>
</sql>
<!--
预约菜品统计统一使用退款扣减后的商品快照:先按父购物车和商品汇总原购买量,再扣除已完成(50)
售后占用的数量、金额。退款申请中(10)仍属于有效预约,部分退款完成后只减少对应份数,取消或拒绝的售后仍正常统计;
同一商品存在多行购物车快照时也不会重复扣减同一笔退款。
-->
<sql id="apptEffectiveCarSource">
(
select original_car.parent_car_id,
original_car.product_id,
original_car.product_name,
original_car.product_pic,
original_car.product_price,
original_car.create_time,
'0' as del_flag,
greatest(original_car.product_count - ifnull(refund_car.product_count, 0), 0) as product_count,
greatest(original_car.total_price - ifnull(refund_car.total_price, 0), 0) as total_price
from (
select c.parent_car_id,
c.product_id,
max(c.product_name) as product_name,
max(c.product_pic) as product_pic,
max(c.product_price) as product_price,
min(c.create_time) as create_time,
sum(ifnull(c.product_count, 0)) as product_count,
sum(ifnull(c.total_price, 0)) as total_price
from hard_car c
where c.del_flag = '0'
group by c.parent_car_id, c.product_id
) original_car
left join (
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
where refund_item.del_flag = '0'
group by refund_item.parent_car_id, refund_item.product_id
) refund_car on refund_car.parent_car_id = original_car.parent_car_id
and refund_car.product_id = original_car.product_id
where original_car.product_count > ifnull(refund_car.product_count, 0)
) a
</sql>
<!-- 通用统计接口被普通购买和预约页面共用,仅预约查询切换到退款扣减后的商品数据源。 -->
<sql id="apptAwareCarSource">
<choose>
<when test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true">
<include refid="apptEffectiveCarSource"/>
</when>
<otherwise>hard_car a</otherwise>
</choose>
</sql>
<!--
新版食堂销售统计按业务发生时间拆分来源:线下订单使用支付时间,预约订单只使用已核销订单的核销时间。
预约分支沿用退款扣减后的商品快照;线下分支保留原始购物车快照,避免改变历史线下统计口径。
-->
<sql id="canteenSaleStatsSource">
(
select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date,
o.pay_time as stat_time,
o.id as order_id,
o.user_id,
o.merchant_id as mch_id,
o.user_dept_id,
o.dept_id,
o.merchant_dept_id,
o.hour_type,
a.product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_count,
a.total_price,
'OFFLINE_PURCHASE' as sale_source
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.merchant_type = '1' and o.order_type = '1'
and o.hour_type is not null and o.hour_type not in ('-1')
where a.del_flag = '0'
and o.pay_time is not null
and o.order_status in ('5', '20')
union all
select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date,
o.verification_time as stat_time,
o.id as order_id,
o.user_id,
o.merchant_id as mch_id,
o.user_dept_id,
o.dept_id,
o.merchant_dept_id,
o.hour_type,
a.product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_count,
a.total_price,
'RESERVATION_VERIFIED' as sale_source
from <include refid="apptEffectiveCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.merchant_type = '1' and o.order_type = '2'
and (o.appt_id is not null or o.appt_rule_id is not null)
and o.order_status = '20'
and o.verification_time is not null
and o.hour_type is not null and o.hour_type not in ('-1')
) sale_stats
</sql>
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和核销时间使用同一日期控件。 -->
<sql id="canteenSaleStatsWhere">
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<if test="dto.saleSourceCodes != null and dto.saleSourceCodes.size() != 0">
and sale_stats.sale_source in
<foreach collection="dto.saleSourceCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.createTimeStr != null and dto.createTimeStr != ''">
and sale_stats.stat_date = #{dto.createTimeStr}
</if>
<if test="dto.orderApptUseDateStr != null and dto.orderApptUseDateStr != ''">
and sale_stats.stat_date = #{dto.orderApptUseDateStr}
</if>
<if test="dto.merchantId != null">
and sale_stats.mch_id = #{dto.merchantId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and sale_stats.mch_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">
and sale_stats.hour_type = #{dto.hourType}
</if>
<if test="dto.deptIds != null and dto.deptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and sale_stats.dept_id in
</when>
<otherwise>
and sale_stats.dept_id not in
</otherwise>
</choose>
<foreach collection="dto.deptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and sale_stats.user_dept_id in
</when>
<otherwise>
and sale_stats.user_dept_id not in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</sql>
<!-- 主统计使用 HardOrderDto,在通用条件之外继续应用数据权限和食堂部门范围。 -->
<sql id="canteenSaleOrderStatsWhere">
<include refid="canteenSaleStatsWhere"/>
<if test="dto.scopeDeptIds != null and dto.scopeDeptIds.size() != 0">
and sale_stats.dept_id in
<foreach collection="dto.scopeDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and sale_stats.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</sql>
<select id="productTotalByDept" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select DATE_FORMAT(a.create_time, '%Y-%m-%d') as createTime,
o.user_dept_id,
@@ -238,6 +425,123 @@
order by DATE_FORMAT(a.create_time, '%Y-%m-%d') desc, o.hour_type;
</select>
<!-- 新版菜品统计:按日期、餐段和销售来源拆行;旧 productTotal 接口保持原查询不变。 -->
<select id="productTotalStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select sale_stats.stat_date as createTime,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount
from <include refid="canteenSaleStatsSource"/>
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
order by sale_stats.stat_date desc, sale_stats.hour_type, sale_stats.sale_source
</select>
<!--
菜品统计一次生成当日合计、当日来源、餐段合计、餐段来源四层数据。
各层交易人数都在数据库独立去重,避免同一用户既线下购买又预约核销时被前端相加为两人。
-->
<select id="listProductTotalStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select statistics_rows.createTime,
statistics_rows.hourType,
statistics_rows.saleSource,
statistics_rows.payCount,
statistics_rows.totalPrice,
statistics_rows.timeCount,
statistics_rows.payUserCount,
statistics_rows.statisticsLevel
from (
select sale_stats.stat_date as createTime,
null as hourType,
null as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'DATE_TOTAL' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date
union all
select sale_stats.stat_date as createTime,
null as hourType,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'DATE_SOURCE' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.sale_source
union all
select sale_stats.stat_date as createTime,
sale_stats.hour_type as hourType,
null as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'HOUR_TOTAL' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type
union all
select sale_stats.stat_date as createTime,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'HOUR_SOURCE' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
) statistics_rows
order by statistics_rows.createTime desc,
case statistics_rows.statisticsLevel
when 'DATE_TOTAL' then 0
when 'DATE_SOURCE' then 1
when 'HOUR_TOTAL' then 2
else 3
end,
statistics_rows.hourType,
statistics_rows.saleSource
</select>
<!-- 部门展开继承被点击主行的日期、餐段和来源范围,在部门层重新精确聚合。 -->
<select id="productTotalByDeptStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select sale_stats.stat_date as createTime,
sale_stats.user_dept_id as userDeptId,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount
from <include refid="canteenSaleStatsSource"/>
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id
order by sale_stats.stat_date, sale_stats.user_dept_id
</select>
<select id="productTotalByApptUseDate" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as createTime,
o.hour_type,
@@ -245,7 +549,7 @@
SUM(a.total_price) as totalPrice,
count(distinct o.id) as timeCount,
count(distinct o.user_id) as payUserCount
from hard_car a
from <include refid="apptEffectiveCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and
o.hour_type not in ('-1')
@@ -336,6 +640,164 @@
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type
</select>
<!--
综合菜品销售导出为每个日期餐段同时生成跨来源合计和来源拆分。
三层 SQL 使用相同的来源、业务时间、食堂和部门筛选,保证页面与导出以及上下层之间口径一致。
-->
<select id="listSalesExportSummary"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end
</select>
<select id="listSalesExportMerchant"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
sale_stats.mch_id, sale_stats.user_dept_id
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
export_rows.merchantId,
export_rows.userDeptId
</select>
<select id="listSalesExportProduct"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productId,
export_rows.productName,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.mch_id, sale_stats.user_dept_id,
sale_stats.product_id, sale_stats.product_name
union all
select sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
sale_stats.mch_id as merchantId,
sale_stats.user_dept_id as userDeptId,
sale_stats.product_id as productId,
sale_stats.product_name as productName,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.hour_type, sale_stats.sale_source,
sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.product_id, sale_stats.product_name
) export_rows
order by export_rows.statisticsDate,
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productName
</select>
<select id="proudctQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select a.product_id,
a.product_name,
@@ -361,6 +823,12 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.productName != null and dto.productName != '' ">
and a.product_name LIKE concat('%',#{dto.productName},'%')
</if>
@@ -369,6 +837,28 @@
order by SUM(a.product_count) desc
</select>
<!-- 新版菜品明细按来源查询,供主表“菜品详情”弹窗使用。 -->
<select id="proudctQueryStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select sale_stats.product_id,
sale_stats.product_name,
sale_stats.product_pic,
sale_stats.product_price,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as count,
SUM(sale_stats.total_price) as totalPrice,
sale_stats.stat_date as create_time
from <include refid="canteenSaleStatsSource"/>
<where>
<include refid="canteenSaleStatsWhere"/>
<if test="dto.productName != null and dto.productName != ''">
and sale_stats.product_name like concat('%', #{dto.productName}, '%')
</if>
</where>
group by sale_stats.stat_date, sale_stats.sale_source, sale_stats.product_id,
sale_stats.product_name, sale_stats.product_pic, sale_stats.product_price
order by SUM(sale_stats.product_count) desc
</select>
<select id="proudctMchQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select
o.merchant_id,
@@ -379,7 +869,7 @@
SUM(a.product_count) as count,
SUM(a.total_price) as totalPrice,
DATE_FORMAT(a.create_time, '%Y-%m-%d') as create_time
from hard_car a
from <include refid="apptAwareCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null
<where>
@@ -396,6 +886,12 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.productName != null and dto.productName != '' ">
and a.product_name LIKE concat('%',#{dto.productName},'%')
</if>
@@ -436,7 +932,7 @@
SUM(a.total_price) as totalPrice,
count(distinct o.id) as timeCount,
count(distinct o.user_id) as payUserCount
from hard_car a
from <include refid="apptAwareCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and
o.hour_type not in ('-1')
@@ -469,6 +965,22 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.user_dept_id in
</when>
<when test="dto.isInclude != null and dto.isInclude == false">
and o.user_dept_id not in
</when>
<otherwise>
and o.user_dept_id in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
@@ -477,6 +989,63 @@
order by o.merchant_id
</select>
<!--
部门统计主行同时返回“日期+食堂合计”和“日期+食堂+来源”两种层级。
合计使用独立的 distinct 聚合,避免同一用户同时线下购买和预约核销时被前端重复相加。
-->
<select id="listByDeptStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select statistics_rows.createTime,
statistics_rows.mchId,
statistics_rows.saleSource,
statistics_rows.payCount,
statistics_rows.totalPrice,
statistics_rows.timeCount,
statistics_rows.payUserCount,
statistics_rows.deptCount,
statistics_rows.statisticsLevel
from (
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
null as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.user_dept_id) as deptCount,
'DATE_TOTAL' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.mch_id
union all
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.user_dept_id) as deptCount,
'DATE_SOURCE' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.mch_id, sale_stats.sale_source
) statistics_rows
order by statistics_rows.createTime desc,
statistics_rows.mchId,
case statistics_rows.statisticsLevel
when 'DATE_TOTAL' then 0
else 1
end,
statistics_rows.saleSource
</select>
<select id="listByHourType" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select
o.user_dept_id,
@@ -485,7 +1054,7 @@
SUM(a.total_price) as totalPrice,
count(distinct o.id) as timeCount,
count(distinct o.user_id) as payUserCount
from hard_car a
from <include refid="apptAwareCarSource"/>
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0' and o.hour_type is not null and
o.hour_type not in ('-1')
@@ -497,6 +1066,28 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != '' ">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true ">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.user_dept_id in
</when>
<when test="dto.isInclude != null and dto.isInclude == false">
and o.user_dept_id not in
</when>
<otherwise>
and o.user_dept_id in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
@@ -511,6 +1102,64 @@
order by o.user_dept_id, o.hour_type
</select>
<!--
部门统计展开行同时返回“部门+餐段合计”和“部门+餐段+来源”两种层级,
日期和食堂条件由服务层传入,来源条件与主行保持一致。
-->
<select id="listByHourTypeStats" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select statistics_rows.createTime,
statistics_rows.userDeptId,
statistics_rows.hourType,
statistics_rows.saleSource,
statistics_rows.payCount,
statistics_rows.totalPrice,
statistics_rows.timeCount,
statistics_rows.payUserCount,
statistics_rows.statisticsLevel
from (
select sale_stats.stat_date as createTime,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
null as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'HOUR_TOTAL' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type
union all
select sale_stats.stat_date as createTime,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
SUM(sale_stats.product_count) as payCount,
SUM(sale_stats.total_price) as totalPrice,
count(distinct sale_stats.order_id) as timeCount,
count(distinct sale_stats.user_id) as payUserCount,
'HOUR_SOURCE' as statisticsLevel
from sale_stats
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type, sale_stats.sale_source
) statistics_rows
order by statistics_rows.createTime,
statistics_rows.userDeptId,
statistics_rows.hourType,
case statistics_rows.statisticsLevel
when 'HOUR_TOTAL' then 0
else 1
end,
statistics_rows.saleSource
</select>
<select id="productByClassify" resultType="com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo">
select s.category_id as classifyId,
SUM(a.product_count) as payCount,
@@ -36,6 +36,10 @@
a.auto_refund_config_id,
a.auto_refund_rule_type,
a.auto_refund_deadline,
a.expired_refund_config_id,
a.expired_refund_usage_end_time,
a.expired_refund_deadline,
a.expired_refund_restore_inventory,
a.sys_type,
a.total_price,
a.extra_price,
@@ -46,6 +50,7 @@
a.refund_price,
a.refund_time,
a.refund_status,
a.verification_time,
a.apply_cancel_time,
a.android_order_code,
a.is_offline_order,
@@ -58,6 +63,14 @@
a.update_time,
a.del_flag,
a.tenant_id,
case when a.order_type = '1' then 'OFFLINE_PURCHASE'
when a.order_type = '2' and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20' then 'RESERVATION_VERIFIED'
else null end as sale_source,
case when a.order_type = '1' then '线下购买'
when a.order_type = '2' and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20' then '预约核销'
else null end as sale_source_name,
</sql>
<sql id="whereA">
@@ -192,6 +205,9 @@
and a.update_time &gt;= #{dto.updateTimeList[0]}
and a.update_time &lt; #{dto.updateTimeList[1]}
</if>
<if test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
and a.verification_time between #{dto.verificationTimeList[0]} and #{dto.verificationTimeList[1]}
</if>
<if test="dto.apptUseDateTimeList != null and dto.apptUseDateTimeList.length == 2">
and a.appt_use_date between #{dto.apptUseDateTimeList[0]} and #{dto.apptUseDateTimeList[1]}
</if>
@@ -208,6 +224,104 @@
</foreach>
or a.refund_status is null)
</if>
<!-- 统计人员明细必须按业务发生时间筛选:线下按支付时间,预约仅按已核销时间。 -->
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and
<choose>
<when test="dto.saleSourceCodes != null and dto.saleSourceCodes.size() == 1 and dto.saleSourceCodes.contains('OFFLINE_PURCHASE')">
(a.order_type = '1'
and a.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
)
</when>
<when test="dto.saleSourceCodes != null and dto.saleSourceCodes.size() == 1 and dto.saleSourceCodes.contains('RESERVATION_VERIFIED')">
(
a.order_type = '2'
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
)
</when>
<otherwise>
(
(a.order_type = '1'
and a.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
or
(a.order_type = '2'
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
)
</otherwise>
</choose>
</if>
</sql>
<!-- App 仅对食堂预约订单扩展付款人和有效被监护人可见范围,其余订单仍只允许订单用户本人查看。 -->
<sql id="appVisibilityWhere">
<if test="dto.appViewerUserId != null">
and (
a.user_id = #{dto.appViewerUserId}
or (
a.merchant_type = '1'
and a.sys_type = '2'
and a.order_type = '2'
and (a.appt_id is not null or a.appt_rule_id is not null)
and (
exists (
select 1
from hard_parent_car visible_parent
where visible_parent.id = a.parent_car_id
and visible_parent.del_flag = '0'
and visible_parent.user_id = #{dto.appViewerUserId}
)
<if test="dto.appWardUserIds != null and dto.appWardUserIds.size() != 0">
or a.user_id in
<foreach collection="dto.appWardUserIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
)
)
)
</if>
</sql>
<!-- App 主键分页和总数统计共用相同的关联筛选,确保页记录与 total 语义一致。 -->
<sql id="appRelationFilterWhere">
<if test="dto.hardTypeCode != null and dto.hardTypeCode != ''">
and exists (
select 1
from hard_manager count_manager
where count_manager.id = a.hard_id
and count_manager.type = #{dto.hardTypeCode}
)
</if>
<if test="dto.hardTypeCodes != null and dto.hardTypeCodes.size() != 0">
and exists (
select 1
from hard_manager count_manager
where count_manager.id = a.hard_id
and count_manager.type in
<foreach collection="dto.hardTypeCodes" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
)
</if>
<if test="dto.afterSaleNo != null and dto.afterSaleNo != ''">
and exists (
select 1
from hard_after_sale count_sale
where count_sale.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
and count_sale.no = #{dto.afterSaleNo}
)
</if>
</sql>
<sql id="pageList">
@@ -230,26 +344,7 @@
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
<where>
<include refid="whereA"/>
<if test="dto.appViewerUserId != null">
and (
a.user_id = #{dto.appViewerUserId}
or (
a.merchant_type = '1'
and a.sys_type = '2'
and a.order_type = '2'
and (a.appt_id is not null or a.appt_rule_id is not null)
and (
p.user_id = #{dto.appViewerUserId}
<if test="dto.appWardUserIds != null and dto.appWardUserIds.size() != 0">
or a.user_id in
<foreach collection="dto.appWardUserIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
)
)
)
</if>
<include refid="appVisibilityWhere"/>
<if test="dto.hardTypeCode != null and dto.hardTypeCode != '' ">
and m.type = #{dto.hardTypeCode}
</if>
@@ -263,8 +358,10 @@
and s.no = #{dto.afterSaleNo}
</if>
</where>
group by a.id
<choose>
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
@@ -278,6 +375,64 @@
<include refid="pageList"/>
</select>
<!-- App 先在订单主表上完成分页,展示字段关联不会再参与全量排序。 -->
<select id="appVisibilityPageIds" resultType="java.lang.Long">
select a.id
from hard_order a
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
<include refid="appRelationFilterWhere"/>
</where>
<choose>
<when test="dto.verificationTimeList != null and dto.verificationTimeList.length == 2">
order by a.verification_time desc, a.id desc
</when>
<when test="dto.updateTimeList != null and dto.updateTimeList.length == 2">
order by a.update_time desc, a.id desc
</when>
<otherwise>
order by a.create_time desc, a.user_id desc
</otherwise>
</choose>
</select>
<!-- 当前页详情最多受全局分页上限约束,最新售后单相关子查询只对本页订单执行。 -->
<select id="listAppPageDetails" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
<include refid="selectA"/>
m.name as hardName,
m.type as hardType,
m.dept_id as hardTypeDeptId,
s.id as afterSaleId,
s.no as afterSaleNo,
coalesce(p.user_id, a.user_id) as payerUserId
from hard_order a
left join hard_manager m on m.id = a.hard_id
left join hard_after_sale s on s.id = (
select max(latest_sale.id)
from hard_after_sale latest_sale
where latest_sale.order_id = a.id
and latest_sale.del_flag = '0'
)
left join hard_parent_car p on p.id = a.parent_car_id and p.del_flag = '0'
where a.id in
<foreach collection="orderIds" item="orderId" open="(" close=")" separator=",">
#{orderId}
</foreach>
and a.del_flag = '0'
</select>
<!-- App 分页总数只验证筛选关系,不加载列表展示字段,避免对全部历史订单执行关联和分组。 -->
<select id="appVisibilityCount" resultType="java.lang.Long">
select count(0)
from hard_order a
<where>
<include refid="whereA"/>
<include refid="appVisibilityWhere"/>
<include refid="appRelationFilterWhere"/>
</where>
</select>
<select id="scopeList" resultType="com.bsd.pjcloud.hard.api.vo.order.HardOrderVo">
<include refid="pageList"/>
</select>
@@ -0,0 +1,176 @@
package com.bsd.pjcloud.hard.excel;
import com.alibaba.excel.EasyExcel;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xssf.usermodel.extensions.XSSFCellBorder.BorderSide;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
*/
class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造超过默认流式窗口的两组数据,验证前后日期餐段及商户部门区域都能完成合并。
*
* @throws Exception 临时文件或工作簿读写失败时抛出
*/
@Test
void shouldMergeAllGroupsAfterEarlierRowsAreFlushed() throws Exception {
Path output = Files.createTempFile("canteen-product-appt-merge-test-", ".xlsx");
try {
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐"));
addProductRows(rows, 130);
rows.add(statisticRow("早餐汇总商品"));
rows.add(summaryRow(LocalDate.of(2026, 8, 23), "午餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 23), "午餐"));
addProductRows(rows, 2);
rows.add(statisticRow("午餐汇总商品"));
rows.add(summaryRow(LocalDate.of(2026, 8, 24), "早餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 24), "早餐"));
addProductRows(rows, 5);
rows.add(statisticRow("次日早餐汇总商品"));
EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler())
.sheet("菜品预约统计").doWrite(rows);
try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) {
Set<String> mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream()
.map(region -> region.formatAsString()).collect(Collectors.toSet());
assertTrue(mergedRegions.contains("A3:A135"));
assertTrue(mergedRegions.contains("F3:F135"));
assertTrue(mergedRegions.contains("G4:G135"));
assertTrue(mergedRegions.contains("N4:N135"));
assertTrue(mergedRegions.contains("A136:A140"));
assertTrue(mergedRegions.contains("G137:G140"));
assertTrue(mergedRegions.contains("A141:A148"));
assertTrue(mergedRegions.contains("G142:G148"));
assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0)
.getCellStyle().getFontIndex()).getFontHeightInPoints());
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(4));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(17));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(18));
XSSFCellStyle firstGroupTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(2)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupSideStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
.getCell(0).getCellStyle();
XSSFCellStyle firstGroupRightStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(3)
.getCell(19).getCellStyle();
XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135)
.getCell(0).getCellStyle();
XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139)
.getCell(0).getCellStyle();
XSSFCellStyle secondDateTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(140)
.getCell(0).getCellStyle();
XSSFCellStyle lastDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(147)
.getCell(0).getCellStyle();
assertEquals(BorderStyle.MEDIUM, firstGroupTopStyle.getBorderTop());
assertEquals(BorderStyle.MEDIUM, firstGroupSideStyle.getBorderLeft());
assertEquals(BorderStyle.MEDIUM, firstGroupRightStyle.getBorderRight());
assertEquals(BorderStyle.NONE, sameDateMealTopStyle.getBorderTop());
assertEquals(BorderStyle.MEDIUM, firstDateBottomStyle.getBorderBottom());
assertEquals(BorderStyle.MEDIUM, secondDateTopStyle.getBorderTop());
assertEquals(BorderStyle.MEDIUM, lastDateBottomStyle.getBorderBottom());
assertArrayEquals(new byte[]{0x5B, (byte) 0x9B, (byte) 0xD5},
firstGroupTopStyle.getBorderColor(BorderSide.TOP).getRGB());
assertArrayEquals(new byte[]{(byte) 0xED, 0x7D, 0x31},
secondDateTopStyle.getBorderColor(BorderSide.TOP).getRGB());
}
} finally {
Files.deleteIfExists(output);
}
}
/**
* 构造日期餐段汇总行。
*
* @param date 预约日期
* @param hourType 餐段名称
* @return 仅填写汇总列的导出行
*/
private HardCanteenProductApptExcelVo summaryRow(LocalDate date, String hourType) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setSummaryDate(date);
row.setSummaryHourType(hourType);
row.setSummaryPayUserCount(1L);
row.setSummaryTimeCount(1L);
row.setSummaryPayCount(1L);
row.setSummaryTotalPrice(BigDecimal.ONE);
return row;
}
/**
* 构造商户部门统计行。
*
* @param date 预约日期
* @param hourType 餐段名称
* @return 仅填写商户部门列的导出行
*/
private HardCanteenProductApptExcelVo merchantRow(LocalDate date, String hourType) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setMerchantName("测试食堂");
row.setDepartmentName("测试部门");
row.setMerchantDate(date);
row.setMerchantHourType(hourType);
row.setMerchantPayUserCount(1L);
row.setMerchantTimeCount(1L);
row.setMerchantPayCount(1L);
row.setMerchantTotalPrice(BigDecimal.ONE);
return row;
}
/**
* 追加指定数量的商品明细行,用于触发流式工作簿刷出早期数据行。
*
* @param rows 待追加的导出行集合
* @param count 商品明细行数量
*/
private void addProductRows(List<HardCanteenProductApptExcelVo> rows, int count) {
for (int index = 0; index < count; index++) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setProductName("测试商品" + index);
row.setProductCount(1L);
row.setProductTotalPrice(BigDecimal.ONE);
rows.add(row);
}
}
/**
* 构造商户部门块末尾的独立商品统计行,用于验证纵向合并范围包含统计区域。
*
* @param productName 商品统计名称
* @return 仅填写商品统计列的导出行
*/
private HardCanteenProductApptExcelVo statisticRow(String productName) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setStatisticProductName(productName);
row.setStatisticProductCount(1L);
row.setStatisticProductTotalPrice(BigDecimal.ONE);
return row;
}
}
@@ -27,6 +27,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -57,6 +58,60 @@ class HardApptReservationServiceImplTest {
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
/** 动态预约直接快照已校验餐段的实际结束时间。 */
@Test
void dynamicReservationUsesValidatedSegmentEndTime() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setHourType("1");
dynamic.setStatus(YesNoEnum.YES.getCode());
var validation = reservationService(dynamic, 1, List.of())
.validateReservation(10L, 1L, null, "1", APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.usageEndDateTime());
}
/** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */
@Test
void weeklyReservationResolvesCrossDaySegmentEndTime() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TOMORROW.getType());
weekly.setEndTime(LocalTime.of(2, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
var validation = reservationService(weekly, 0, List.of(rule))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 29, 2, 0), validation.usageEndDateTime());
}
/** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */
@Test
void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
weekly.setEndTime(LocalTime.of(13, 0));
HardApptEntity duplicate = weeklyAppt(2L, 10L);
duplicate.setHourType("1");
duplicate.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
duplicate.setEndTime(LocalTime.of(14, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
var validation = reservationService(weekly, 0, List.of(rule), List.of(weekly, duplicate))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertNull(validation.usageEndDateTime());
}
@Test
void configurableCycleIncludesBothBoundaryInstants() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
@@ -347,12 +402,28 @@ class HardApptReservationServiceImplTest {
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules) {
return reservationService(appt, mappingCount, rules, List.of(appt));
}
/**
* 构造可控制实际餐段查询结果的预约服务。
*
* @param appt 动态校验按主键读取的餐段,同时决定商户预约模式
* @param mappingCount 动态日期与餐段映射数量
* @param rules 当前商户有效周期规则
* @param enabledAppts 周期模式按商户和餐段查询到的有效 hard_appt 列表
* @return 注入测试替身的预约校验服务
*/
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules,
List<HardApptEntity> enabledAppts) {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
HardApptMerchantModeService merchantModeService = merchantModeService(
HardApptReserveModeEnum.valueOfByType(appt.getReserveMode()));
when(apptService.getById(appt.getId())).thenReturn(appt);
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any())).thenReturn(enabledAppts);
when(timeService.count(any())).thenReturn(mappingCount);
when(cycleRuleService.listEnabledConfigurations(appt.getMchId())).thenReturn(rules);
return new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService, merchantModeService);
@@ -5,12 +5,14 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
@@ -30,8 +32,13 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -68,6 +75,9 @@ class HardAutoRefundTaskServiceImplTest {
/** 自动退款任务 Mapper Mock。 */
@Mock
private HardAutoRefundTaskMapper taskMapper;
/** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */
@Mock
private HardOrderMapper orderMapper;
/** 被测自动退款任务服务。 */
private HardAutoRefundTaskServiceImpl service;
@@ -77,11 +87,11 @@ class HardAutoRefundTaskServiceImplTest {
*/
@BeforeEach
void setUp() {
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, afterSaleServiceProvider,
configService, walletRechargeService, mchService, userService, lockTemplate);
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper,
afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate);
ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
}
/**
@@ -165,6 +175,32 @@ class HardAutoRefundTaskServiceImplTest {
assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus());
}
/** 管理端逐单退款只有在任务最终成功时才允许接口返回成功。 */
@Test
void expiredManualRefundShouldReturnOnlyAfterTaskSuccess() {
HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
HardAutoRefundTaskEntity completed = expiredTask(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus());
HardAutoRefundTaskServiceImpl spyService = spy(service);
when(taskMapper.selectById(1L)).thenReturn(pending, completed);
doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L));
spyService.manualExpiredRefund(1L, 99L);
verify(spyService).execute(1L, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL, 99L);
}
/** 锁竞争或资金失败未把任务推进为成功时,逐单退款接口必须返回失败而不是误报成功。 */
@Test
void expiredManualRefundShouldFailWhenTaskDidNotComplete() {
HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
HardAutoRefundTaskEntity failed = expiredTask(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
HardAutoRefundTaskServiceImpl spyService = spy(service);
when(taskMapper.selectById(1L)).thenReturn(pending, failed);
doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L));
assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L));
}
/**
* 构造固定订单和售后单的自动退款任务。
*
@@ -177,4 +213,17 @@ class HardAutoRefundTaskServiceImplTest {
.configId(5L).ruleType("PAY_TIME_LIMIT").status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
.autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build();
}
/**
* 构造已到期的每日菜谱过期退款任务。
*
* @param status 当前任务状态
* @return 可供管理端逐单退款状态复查使用的任务
*/
private HardAutoRefundTaskEntity expiredTask(String status) {
return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L)
.taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.triggerTime(LocalDateTime.now().minusMinutes(1)).status(status)
.autoAttemptCount(0).totalAttemptCount(0).build();
}
}
@@ -0,0 +1,169 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 菜品预约导出服务测试,验证日期餐段、部门合计及商品统计与套餐展开后的商品明细保持同一口径。
*/
class HardCanteenApptTotalExportServiceImplTest {
/**
* 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,并在部门内合并同名单品。
*/
@Test
void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() {
HardCarMapper hardCarMapper = mock(HardCarMapper.class);
RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class);
RemoteDeptService remoteDeptService = mock(RemoteDeptService.class);
RemoteDictService remoteDictService = mock(RemoteDictService.class);
RemoteMchService remoteMchService = mock(RemoteMchService.class);
HardCanteenApptTotalExportServiceImpl service = mock(HardCanteenApptTotalExportServiceImpl.class,
org.mockito.Answers.CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "hardCarMapper", hardCarMapper);
ReflectionTestUtils.setField(service, "remoteDailyMenuService", remoteDailyMenuService);
ReflectionTestUtils.setField(service, "remoteDeptService", remoteDeptService);
ReflectionTestUtils.setField(service, "remoteDictService", remoteDictService);
ReflectionTestUtils.setField(service, "remoteMchService", remoteMchService);
LocalDate date = LocalDate.of(2026, 8, 24);
HardCanteenProductTotalVo summary = new HardCanteenProductTotalVo();
summary.setCreateTime(date.atStartOfDay());
summary.setHourType("1");
summary.setPayUserCount(2L);
summary.setTimeCount(2L);
summary.setPayCount(5L);
summary.setTotalPrice(800L);
HardCanteenProductApptExportRecord merchant = new HardCanteenProductApptExportRecord();
merchant.setApptDate(date.toString());
merchant.setHourType("1");
merchant.setMerchantId(10L);
merchant.setUserDeptId(20L);
merchant.setPayUserCount(1L);
merchant.setTimeCount(1L);
merchant.setPayCount(3L);
merchant.setTotalPrice(500L);
HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord();
product.setApptDate(date.toString());
product.setHourType("1");
product.setMerchantId(10L);
product.setUserDeptId(20L);
product.setProductId(100L);
product.setProductName("套餐");
product.setPayCount(1L);
product.setTotalPrice(300L);
HardCanteenProductApptExportRecord sameNameProduct = new HardCanteenProductApptExportRecord();
sameNameProduct.setApptDate(date.toString());
sameNameProduct.setHourType("1");
sameNameProduct.setMerchantId(10L);
sameNameProduct.setUserDeptId(20L);
sameNameProduct.setProductId(101L);
sameNameProduct.setProductName("主食");
sameNameProduct.setPayCount(2L);
sameNameProduct.setTotalPrice(200L);
HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord();
secondMerchant.setApptDate(date.toString());
secondMerchant.setHourType("1");
secondMerchant.setMerchantId(10L);
secondMerchant.setUserDeptId(30L);
secondMerchant.setPayUserCount(1L);
secondMerchant.setTimeCount(1L);
secondMerchant.setPayCount(4L);
secondMerchant.setTotalPrice(500L);
HardCanteenProductApptExportRecord secondProduct = new HardCanteenProductApptExportRecord();
secondProduct.setApptDate(date.toString());
secondProduct.setHourType("1");
secondProduct.setMerchantId(10L);
secondProduct.setUserDeptId(30L);
secondProduct.setProductId(200L);
secondProduct.setProductName("单品");
secondProduct.setPayCount(4L);
secondProduct.setTotalPrice(500L);
DailyMenuAppVO menu = new DailyMenuAppVO();
menu.setSpuId(100L);
OnlineSpuComboItemVO first = new OnlineSpuComboItemVO();
first.setName("主食");
first.setQuantity(1);
first.setPrice(100);
OnlineSpuComboItemVO second = new OnlineSpuComboItemVO();
second.setName("配菜");
second.setQuantity(2);
second.setPrice(100);
menu.setComboItems(List.of(first, second));
MchManageVo merchantVo = new MchManageVo();
merchantVo.setId(10L);
merchantVo.setName("测试食堂");
SysDept dept = new SysDept();
dept.setDeptId(20L);
dept.setName("测试部门");
SysDept secondDept = new SysDept();
secondDept.setDeptId(30L);
secondDept.setName("第二部门");
SysDictItem dictItem = new SysDictItem();
dictItem.setItemValue("1");
dictItem.setLabel("早餐");
when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary));
when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant));
when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, sameNameProduct, secondProduct));
when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu)));
when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L)))
.thenReturn(R.ok(java.util.Map.of(20L, dept, 30L, secondDept)));
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(dictItem)));
List<HardCanteenProductApptExcelVo> rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows",
null, date);
assertEquals(10, rows.size());
assertEquals(9L, rows.get(0).getSummaryPayCount());
assertEquals("10.00", rows.get(0).getSummaryTotalPrice().toPlainString());
assertEquals(5L, rows.get(1).getMerchantPayCount());
assertEquals("5.00", rows.get(1).getMerchantTotalPrice().toPlainString());
assertEquals(1L, rows.get(2).getProductCount());
assertEquals(2L, rows.get(3).getProductCount());
assertEquals(2L, rows.get(4).getProductCount());
assertEquals("主食", rows.get(5).getStatisticProductName());
assertEquals(3L, rows.get(5).getStatisticProductCount());
assertEquals("3.00", rows.get(5).getStatisticProductTotalPrice().toPlainString());
assertEquals("配菜", rows.get(6).getStatisticProductName());
assertEquals(2L, rows.get(6).getStatisticProductCount());
assertEquals("2.00", rows.get(6).getStatisticProductTotalPrice().toPlainString());
assertEquals(4L, rows.get(7).getMerchantPayCount());
assertEquals("5.00", rows.get(7).getMerchantTotalPrice().toPlainString());
assertEquals(4L, rows.get(8).getProductCount());
assertEquals("单品", rows.get(9).getStatisticProductName());
assertEquals(4L, rows.get(9).getStatisticProductCount());
assertEquals("5.00", rows.get(9).getStatisticProductTotalPrice().toPlainString());
assertEquals("早餐", rows.get(0).getSummaryHourType());
}
}
@@ -0,0 +1,246 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.feign.RemoteDataScopeService;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 食堂销售来源统计服务测试,验证多维层级装配、来源筛选和协议校验。
*/
class HardCanteenTotalServiceImplTest {
/** 菜品统计 Mapper,用于隔离 SQL 执行并检查服务传入的来源条件。 */
private final HardCarMapper hardCarMapper = mock(HardCarMapper.class);
/** 部门名称远程服务,用于隔离部门统计的名称补全过程。 */
private final RemoteDeptService remoteDeptService = mock(RemoteDeptService.class);
/** 食堂范围远程服务,用于部门统计返回可查询的食堂集合。 */
private final RemoteMchService remoteMchService = mock(RemoteMchService.class);
/** 被测统计服务。 */
private HardCanteenTotalServiceImpl service;
/**
* 使用 Mock 远程依赖构造统计服务,当前测试只执行菜品统计路由和结果装配。
*/
@BeforeEach
void setUp() {
service = new HardCanteenTotalServiceImpl(
mock(HardOrderMapper.class),
hardCarMapper,
remoteDeptService,
remoteMchService,
mock(RemoteDataScopeService.class),
mock(RemoteCanteenCategoryService.class),
mock(RemoteDictService.class));
}
/**
* 验证传入统计日期时启用多维 SQL,并装配当日、餐段及各自来源明细。
*/
@Test
void productTotalBuildsDateAndHourStatisticsWithSourceRows() {
HardOrderDto dto = statisticsDto();
when(hardCarMapper.listProductTotalStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
statisticsRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null),
statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, null),
statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null),
statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1"),
statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1"),
statisticsRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1")));
Page<HardCanteenProductTotalVo> result = service.productTotal(new Page<>(1, 10), dto, null);
assertEquals(HardSaleSourceEnum.getCodes(), dto.getSaleSourceCodes());
assertEquals(2, result.getRecords().size());
assertEquals(1, result.getTotal());
assertEquals("当日合计", result.getRecords().get(0).getStatisticsLevelName());
assertEquals("餐段合计", result.getRecords().get(1).getStatisticsLevelName());
assertEquals(List.of("线下购买", "预约核销"), result.getRecords().get(0).getSourceRows().stream()
.map(HardCanteenProductTotalVo::getSaleSourceName).toList());
assertEquals("2026-08-24_DATE_TOTAL_ALL", result.getRecords().get(0).getUniqueCode());
assertEquals("2026-08-24_HOUR_TOTAL_1", result.getRecords().get(1).getUniqueCode());
verify(hardCarMapper, never()).productTotal(any(Page.class), any(HardOrderDto.class), isNull());
}
/**
* 验证单来源标签页只把对应来源编码传给来源统计 Mapper。
*/
@Test
void productTotalPreservesSingleSourceFilter() {
HardOrderDto dto = statisticsDto();
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
when(hardCarMapper.listProductTotalStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
statisticsRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null),
statisticsRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null)));
Page<HardCanteenProductTotalVo> result = service.productTotal(new Page<>(1, 10), dto, null);
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
assertEquals(1, result.getRecords().size());
assertTrue(result.getRecords().get(0).getSourceRows().isEmpty());
verify(hardCarMapper).listProductTotalStats(any(HardOrderDto.class), isNull());
}
/**
* 验证未知来源在进入 Mapper 前即被拒绝,避免被当作“全部”静默查询。
*/
@Test
void productTotalRejectsUnknownSource() {
HardOrderDto dto = statisticsDto();
dto.setSaleSourceCodes(List.of("UNKNOWN_SOURCE"));
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.productTotal(new Page<>(1, 10), dto, null));
assertEquals("销售来源编码不正确", exception.getMessage());
verify(hardCarMapper, never()).listProductTotalStats(any(HardOrderDto.class), isNull());
}
/**
* 验证部门统计将日期食堂合计和部门餐段合计作为主层级,并分别挂载两个来源的下钻行。
*/
@Test
void departmentTotalBuildsMerchantAndMealStatisticsWithSourceRows() {
HardOrderDto dto = statisticsDto();
dto.setTotalIsFindChildren(true);
mockCanteenScope();
when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null),
departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, null, null),
departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null, null)));
when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
assertEquals(1, result.size());
assertEquals("当日合计", result.get(0).getStatisticsLevelName());
assertEquals(List.of("线下购买", "预约核销"), result.get(0).getSourceRows().stream()
.map(HardCanteenProductTotalVo::getSaleSourceName).toList());
assertEquals(1, result.get(0).getChildren().size());
assertEquals("餐段合计", result.get(0).getChildren().get(0).getStatisticsLevelName());
assertEquals(2, result.get(0).getChildren().get(0).getSourceRows().size());
assertEquals(1, result.get(0).getSourceRows().get(0).getChildren().size());
assertEquals(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode(),
result.get(0).getSourceRows().get(0).getChildren().get(0).getSaleSource());
}
/**
* 验证单来源部门标签页只返回合计行,不再附加与合计值相同的来源行。
*/
@Test
void departmentTotalSingleSourceHidesDuplicateSourceRows() {
HardOrderDto dto = statisticsDto();
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
dto.setTotalIsFindChildren(true);
mockCanteenScope();
when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null),
departmentRow(HardCanteenStatisticsLevelEnum.DATE_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, null, null)));
when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
assertEquals(1, result.size());
assertTrue(result.get(0).getSourceRows().isEmpty());
assertEquals(1, result.get(0).getChildren().size());
assertTrue(result.get(0).getChildren().get(0).getSourceRows().isEmpty());
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
}
/**
* 构造覆盖 2026-08-24 完整自然日的来源统计请求。
*
* @return 未指定来源、由服务补全为全部来源的查询参数
*/
private HardOrderDto statisticsDto() {
HardOrderDto dto = new HardOrderDto();
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 8, 24, 0, 0, 0),
LocalDateTime.of(2026, 8, 24, 23, 59, 59)
});
return dto;
}
/**
* 构造单日指定层级的统计行。
*
* @param level 统计展示层级
* @param source 销售来源枚举;合计行传空
* @param hourType 餐段字典编码;当日层级传空
* @return 尚未装配来源名称、层级名称和唯一键的 Mapper 结果
*/
private HardCanteenProductTotalVo statisticsRow(HardCanteenStatisticsLevelEnum level,
HardSaleSourceEnum source,
String hourType) {
HardCanteenProductTotalVo row = new HardCanteenProductTotalVo();
row.setCreateTime(LocalDateTime.of(2026, 8, 24, 0, 0));
row.setHourType(hourType);
row.setSaleSource(source == null ? null : source.getCode());
row.setStatisticsLevel(level.getCode());
return row;
}
/**
* 模拟当前用户可以查看编号为 10 的食堂,并提供食堂名称填充结果。
*/
private void mockCanteenScope() {
MchManageVo merchant = new MchManageVo();
merchant.setId(10L);
merchant.setName("测试食堂");
when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant)));
when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchant)));
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of()));
}
/**
* 构造部门统计指定展示层级的 Mapper 行。
*
* @param level 统计展示层级
* @param source 销售来源枚举;合计行传空
* @param hourType 餐段字典编码;食堂主行传空
* @param userDeptId 用户部门主键;食堂主行传空
* @return 尚未装配层级名称和来源层级的 Mapper 结果
*/
private HardCanteenProductTotalVo departmentRow(HardCanteenStatisticsLevelEnum level,
HardSaleSourceEnum source,
String hourType,
Long userDeptId) {
HardCanteenProductTotalVo row = statisticsRow(level, source, hourType);
row.setMchId(10L);
row.setUserDeptId(userDeptId);
return row;
}
}
@@ -0,0 +1,206 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity;
import com.bsd.pjcloud.hard.service.HardCarService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 机器收银食堂订单库存分流测试,确保 BELOW 订单使用 {@code canteen_spu} 原子库存接口。
*/
class HardOrderServiceImplInventoryTest {
/** 订单购物车服务,提供服务端固化的商品来源和下单商品快照。 */
private final HardCarService hardCarService = mock(HardCarService.class);
/** 食堂基础菜品远程服务,负责查询权威库存并执行幂等扣减。 */
private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class);
/** 被测订单服务,仅执行真实库存方法,其他依赖不参与本组测试。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
/**
* 注入库存分支依赖,并建立事务同步上下文供跨服务回滚补偿登记。
*/
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "hardCarService", hardCarService);
ReflectionTestUtils.setField(service, "remoteCanteenSpuService", remoteCanteenSpuService);
TransactionSynchronizationManager.initSynchronization();
}
/**
* 清理线程绑定的事务同步器,避免影响同线程执行的后续测试。
*/
@AfterEach
void tearDown() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
/**
* 验证机器收银购买数量超过权威库存时在远程扣减前明确返回商品库存不足。
*/
@Test
void cashierOrderRejectsInsufficientCanteenSpuStock() {
prepareCart(2);
CanteenSpuEntity product = productWithStock(1);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null));
assertEquals("测试菜品库存不足", exception.getMessage());
verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any());
}
/**
* 验证机器收银库存充足时按订单数量生成基础 SPU 的 APPLY 负增量。
*/
@Test
void cashierOrderDeductsCanteenSpuStock() {
prepareCart(2);
CanteenSpuEntity product = productWithStock(5);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.YES, null));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_ORDER:100", request.getBizId());
assertEquals(-2, request.getItems().get(0).getIncrCount());
assertEquals(5, request.getItems().get(0).getBeforeStock());
}
/**
* 验证聚合码创建前的库存预检只读权威库存,库存充足时不提前扣减。
*/
@Test
void aggregatePreflightChecksStockWithoutDeduction() {
prepareCart(2);
CanteenSpuEntity product = productWithStock(5);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(
service, "validateCanteenSpuOrderInventory", cashierOrder()));
verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any());
}
/**
* 验证聚合码创建前就拒绝已知的库存不足,避免继续展示可付款二维码。
*/
@Test
void aggregatePreflightRejectsInsufficientStock() {
prepareCart(2);
CanteenSpuEntity product = productWithStock(1);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
RuntimeException exception = assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(
service, "validateCanteenSpuOrderInventory", cashierOrder()));
assertEquals("测试菜品库存不足", exception.getMessage());
verify(remoteCanteenSpuService, never()).updateSkuStockIdempotent(any());
}
/**
* 验证机器收银退款使用售后单幂等号恢复原购数量,重复回调不会反复增加库存。
*/
@Test
void cashierRefundRestoresCanteenSpuStockIdempotently() {
prepareCart(2);
CanteenSpuEntity product = productWithStock(3);
when(remoteCanteenSpuService.getInnerSpuListByIds(List.of(11L))).thenReturn(R.ok(List.of(product)));
when(remoteCanteenSpuService.updateSkuStockIdempotent(any())).thenReturn(R.ok());
assertDoesNotThrow(() -> service.fastenerInventory(cashierOrder(), null, YesNoEnum.NO, 500L));
ArgumentCaptor<CanteenSpuUpdateStockReqDTO> captor = ArgumentCaptor.forClass(CanteenSpuUpdateStockReqDTO.class);
verify(remoteCanteenSpuService).updateSkuStockIdempotent(captor.capture());
CanteenSpuUpdateStockReqDTO request = captor.getValue();
assertEquals("APPLY", request.getOperation());
assertEquals("HARD_DAILY_MENU_AFTER_SALE:500", request.getBizId());
assertEquals(2, request.getItems().get(0).getIncrCount());
assertEquals(3, request.getItems().get(0).getBeforeStock());
}
/**
* 准备机器收银购物车来源和商品数量。
*
* @param productCount 本次购买份数
*/
private void prepareCart(int productCount) {
HardParentCarEntity parentCar = HardParentCarEntity.builder()
.id(200L)
.productSource(HardProductSourceEnum.CANTEEN_SPU.getType())
.build();
HardCarEntity car = HardCarEntity.builder()
.parentCarId(200L)
.productId(11L)
.productCount(productCount)
.build();
when(hardCarService.getParentCarById(200L)).thenReturn(parentCar);
when(hardCarService.getByParentCarId(200L)).thenReturn(List.of(car));
}
/**
* 构造指定权威库存的当前商户基础菜品。
*
* @param stock 服务端当前可售库存
* @return 用于库存校验的基础菜品
*/
private CanteenSpuEntity productWithStock(int stock) {
CanteenSpuEntity product = new CanteenSpuEntity();
product.setId(11L);
product.setMerchantId(300L);
product.setName("测试菜品");
product.setSpuType(1);
product.setStock(stock);
return product;
}
/**
* 构造机器收银提交的食堂线下订单。
*
* @return 关联固定父购物车和商户的 BELOW 订单
*/
private HardOrderEntity cashierOrder() {
return HardOrderEntity.builder()
.id(100L)
.sysType(HardTypeEnum.CANTEEN.getType())
.orderType(HardOrderTypeEnum.BELOW.getType())
.parentCarId(200L)
.merchantId(300L)
.build();
}
}
@@ -0,0 +1,57 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 商城订单第三方支付成功回调幂等测试。
*/
class HardOrderServiceImplPaymentCallbackTest {
/** 商城订单 Mapper Mock,用于返回已经支付的订单。 */
private final HardOrderMapper hardOrderMapper = mock(HardOrderMapper.class);
/** 被测订单服务,仅调用真实状态推进方法。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
/**
* 注入 MyBatis-Plus 基类 Mapper。
*/
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
}
/**
* 已支付商城订单收到重复成功通知时应直接返回成功,不再次更新订单或触发订单成功副作用。
*/
@Test
void repeatedPaySuccessShouldBeIdempotent() {
HardOrderEntity order = new HardOrderEntity();
order.setId(100L);
order.setOrderType(HardOrderTypeEnum.FARMER.getType());
order.setOrderStatus(HardOrderStatusEnum.SHOP_WAIT_PAY_SUCCESS.getType());
when(hardOrderMapper.selectById(order.getId())).thenReturn(order);
HardUpdateShopStatusDto dto = new HardUpdateShopStatusDto();
dto.setOrderId(order.getId());
dto.setStatus(HardUpdateShopStatusDto.ChangeShopStatus.PAY_SUCCESS);
Assertions.assertTrue(service.updateShopStatus(dto));
verify(hardOrderMapper, never()).updateById(any(HardOrderEntity.class));
}
}
@@ -1,5 +1,7 @@
package com.bsd.pjcloud.hard.service.impl.order;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.core.util.R;
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;
@@ -7,18 +9,29 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.wallet.api.feign.RemoteCodeUseService;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -35,16 +48,28 @@ class HardOrderServiceImplVerificationTest {
/** 订单购物车服务,用于返回退款成功后的剩余份数。 */
private final HardCarService hardCarService = mock(HardCarService.class);
/** 预约二维码远程服务,用于验证订单回退会同步恢复 Wallet 核销码。 */
private final RemoteCodeUseService remoteCodeUseService = mock(RemoteCodeUseService.class);
/** 核销消息 Mapper,用于隔离回退时对尚未投递消息的状态调整。 */
private final HardMessageOutboxMapper hardMessageOutboxMapper = mock(HardMessageOutboxMapper.class);
/** 取餐广播服务,用于验证食堂预约回退会重新启用待取餐提示。 */
private final HardBroadcastService hardBroadcastService = mock(HardBroadcastService.class);
/** 被测订单服务,仅执行真实方法,未涉及的庞大依赖保持空值。 */
private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS);
/**
* 为被测服务注入旧核销拒绝路径实际会访问的两个依赖。
* 为被测服务注入核销校验与回退路径实际访问的依赖。
*/
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "baseMapper", hardOrderMapper);
ReflectionTestUtils.setField(service, "hardCarService", hardCarService);
ReflectionTestUtils.setField(service, "remoteCodeUseService", remoteCodeUseService);
ReflectionTestUtils.setField(service, "hardMessageOutboxMapper", hardMessageOutboxMapper);
ReflectionTestUtils.setField(service, "hardBroadcastService", hardBroadcastService);
}
/**
@@ -78,6 +103,96 @@ class HardOrderServiceImplVerificationTest {
assertEquals("该预约订单商品已全部退款,不能核销", exception.getMessage());
}
/**
* 验证已核销且仍有可履约商品的食堂预约会恢复订单、二维码和取餐广播。
*/
@Test
void rollbackReserveVerificationRestoresPendingState() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL);
HardCarVo remaining = new HardCarVo();
remaining.setEffectiveProductCount(1);
when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
when(hardCarService.getByOrderId(1L)).thenReturn(List.of(remaining));
when(hardOrderMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(remoteCodeUseService.rollbackReservationCode(1L)).thenReturn(R.ok(true));
assertTrue(service.rollbackReserveVerification(1L));
verify(remoteCodeUseService).rollbackReservationCode(1L);
verify(hardBroadcastService).rollbackDoneBroadcast(order);
}
/**
* 验证尚未核销的预约不能执行回退,也不能提前恢复 Wallet 二维码。
*/
@Test
void rollbackReserveVerificationRejectsPendingOrder() {
when(hardOrderMapper.selectByIdForUpdate(1L))
.thenReturn(reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.PAY));
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("仅已核销订单支持退回到核销前", exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
/**
* 验证退款处理中的已核销预约不能恢复为待核销,避免退款与再次履约并发发生。
*/
@Test
void rollbackReserveVerificationRejectsRefundingOrder() {
when(hardOrderMapper.selectByIdForUpdate(1L))
.thenReturn(reservationOrder(HardRefundStatusEnum.APPLY, HardOrderStatusEnum.CANCEL));
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("该预约订单正在退款,不能退回到核销前", exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
/**
* 验证历史上复用 RESERVE 编码的活动核销订单不会被预约纠错入口误处理。
*/
@Test
void rollbackReserveVerificationRejectsNonReservationBusiness() {
HardOrderEntity order = reservationOrder(HardRefundStatusEnum.NONE, HardOrderStatusEnum.CANCEL);
order.setSysType(HardTypeEnum.NONE.getType());
when(hardOrderMapper.selectByIdForUpdate(1L)).thenReturn(order);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.rollbackReserveVerification(1L));
assertEquals("当前业务类型不支持退回到核销前", exception.getMessage());
verify(remoteCodeUseService, never()).rollbackReservationCode(anyLong());
}
/**
* 旧免审退款规则损坏时只能放弃旧快照,不能阻断独立的过期预约退款快照。
*/
@Test
void expiredRefundSnapshotDoesNotDependOnLegacyRuleValidity() {
HardOrderEntity order = new HardOrderEntity();
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(7L);
config.setEnabled("1");
config.setRuleType("BROKEN_RULE");
config.setExpiredReserveRefundEnabled("1");
config.setExpiredReserveRefundDelayHours(2);
config.setExpiredReserveRefundRestoreInventory("1");
HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation(
null, "1", LocalDate.of(2026, 8, 23), 9L,
LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 13, 0));
ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config);
assertNull(order.getAutoRefundConfigId());
assertEquals(7L, order.getExpiredRefundConfigId());
assertEquals(LocalDateTime.of(2026, 8, 23, 15, 0), order.getExpiredRefundDeadline());
assertEquals("1", order.getExpiredRefundRestoreInventory());
}
/**
* 构造旧消费机通用核销接口请求。
*
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.thirdparty.api.feign;
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 com.bsd.pjcloud.thirdparty.dto.pay.ThirdPaymentTaskDispatchDto;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo;
import jakarta.validation.Valid;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Quartz 调用 thirdparty 支付任务分发器的内部 Feign 契约。
*/
@FeignClient(contextId = "remoteThirdPaymentTaskService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoteThirdPaymentTaskService {
/**
* 同步请求 thirdparty 接收一批任务,渠道调用由已取得租约的 Worker 异步执行。
* @param dto 固定任务类型、批量上限和触发来源
* @return 接收、领取、繁忙和积压统计
*/
@NoToken
@PostMapping("/third-payment-task/inner/dispatch")
R<ThirdPaymentTaskDispatchVo> dispatch(@Valid @RequestBody ThirdPaymentTaskDispatchDto dto);
}
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.thirdparty.constant.fz;
import lombok.Getter;
/**
* 商户分账关系业务场景,编码用于区分同一渠道账号未来可能增加的其他分账用途。
*/
@Getter
public enum ThirdMchFzRelationSceneEnum {
/** 商户支付成功后向平台收取服务费的默认分佣关系。 */
DEFAULT_PLATFORM_COMMISSION("DEFAULT_PLATFORM_COMMISSION");
/** 关系表持久化的稳定业务场景编码。 */
private final String code;
/**
* 创建关系场景。
*
* @param code 持久化场景编码
*/
ThirdMchFzRelationSceneEnum(String code) {
this.code = code;
}
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more