feat:hard、wallet表格添加人员、人员身份字段和搜索

fix:优化hard模块统计导出
This commit is contained in:
dzc committed 2026-08-25 12:49:36 +08:00
1 parent 9d2a0ee1ab
commit af9fece0c0
122 files changed
+3963 -1420

No files matched your search

+34 -2
View File
@@ -54,13 +54,29 @@ SET @relation_task_column_exists = (
);
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`)',
'ALTER TABLE `third_payment_task` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''商户分账关系主键'' AFTER `fz_order_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 @relation_task_index_exists = (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_payment_task'
AND INDEX_NAME = 'idx_third_payment_task_relation'
);
SET @relation_task_index_sql = IF(
@relation_task_index_exists = 0,
'ALTER TABLE `third_payment_task` ADD INDEX `idx_third_payment_task_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE relation_task_index_stmt FROM @relation_task_index_sql;
EXECUTE relation_task_index_stmt;
DEALLOCATE PREPARE relation_task_index_stmt;
-- 支付订单固定引用创建时使用的已绑定关系,后续分账不跟随平台接收账号配置漂移。
SET @pay_relation_column_exists = (
SELECT COUNT(1)
@@ -71,9 +87,25 @@ SET @pay_relation_column_exists = (
);
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`)',
'ALTER TABLE `third_pay_order` ADD COLUMN `fz_relation_id` bigint DEFAULT NULL COMMENT ''支付创建时绑定的平台分佣关系主键'' AFTER `mch_pay_setting_json`',
'SELECT 1'
);
PREPARE pay_relation_column_stmt FROM @pay_relation_column_sql;
EXECUTE pay_relation_column_stmt;
DEALLOCATE PREPARE pay_relation_column_stmt;
SET @pay_relation_index_exists = (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'third_pay_order'
AND INDEX_NAME = 'idx_third_pay_order_fz_relation'
);
SET @pay_relation_index_sql = IF(
@pay_relation_index_exists = 0,
'ALTER TABLE `third_pay_order` ADD INDEX `idx_third_pay_order_fz_relation` (`tenant_id`, `fz_relation_id`)',
'SELECT 1'
);
PREPARE pay_relation_index_stmt FROM @pay_relation_index_sql;
EXECUTE pay_relation_index_stmt;
DEALLOCATE PREPARE pay_relation_index_stmt;
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardAuthDto extends HardAuthEntity {
public class HardAuthDto extends HardAuthEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于授权记录查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardBroadcastDto extends HardBroadcastEntity {
public class HardBroadcastDto extends HardBroadcastEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于广播记录查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,12 +1,31 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class HardDoorFlowDto extends HardDoorFlowEntity {
public class HardDoorFlowDto extends HardDoorFlowEntity implements BaseUserIdentityQueryInterface {
/** 身份预筛选后用于开门流程查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import lombok.Data;
@@ -7,12 +10,24 @@ import java.time.LocalDate;
import java.util.List;
@Data
public class HardInvoiceDto extends HardInvoiceEntity {
public class HardInvoiceDto extends HardInvoiceEntity implements BaseUserIdentityQueryInterface {
private List<Long> deptIds;
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> merchantIds;
private String hardType;
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import lombok.Data;
@@ -8,10 +11,22 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardAfterSaleDto extends HardAfterSaleEntity {
public class HardAfterSaleDto extends HardAfterSaleEntity implements BaseUserIdentityQueryInterface {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
private List<Long> mchIds;
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import lombok.Data;
@@ -9,8 +12,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 硬件订单后台查询条件,身份字段用于按订单用户的身份资料筛选。 */
@Data
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface {
public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private DateRangeTypeEnum dateRangeType = DateRangeTypeEnum.YESTERDAY;
@@ -131,4 +135,16 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
* `apptId`或者`appt_rule_id`不为null(筛选预约订单)
*/
Boolean apptIdOrApptRuleIdNotNull = false;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.hard.api.dto.spu;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import lombok.Data;
@@ -7,7 +10,8 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardSpuRepertoryDto extends HardSpuRepertoryEntity {
/** 硬件商品库存更新记录查询条件,支持按上架用户身份快速筛选。 */
public class HardSpuRepertoryDto extends HardSpuRepertoryEntity implements BaseUserIdentityQueryInterface {
private LocalDateTime[] createTimeList;
@@ -15,4 +19,16 @@ public class HardSpuRepertoryDto extends HardSpuRepertoryEntity {
private Long cabinetId;
/** UPMS 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,10 +29,22 @@ public class HardAuthVo extends HardAuthEntity implements BaseHardInterface, Bas
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardBroadcastEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import lombok.Data;
@@ -8,7 +11,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -26,12 +29,24 @@ public class HardBroadcastVo extends HardBroadcastEntity implements BaseHardInte
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 订单相关
*/
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardAuthEntity;
import com.bsd.pjcloud.hard.api.entity.HardDoorFlowEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
@@ -9,7 +12,7 @@ import lombok.Data;
import java.util.List;
@Data
public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface {
public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterface, BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 设备相关
@@ -27,12 +30,24 @@ public class HardDoorFlowVo extends HardDoorFlowEntity implements BaseHardInterf
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
private HardAuthEntity hardAuth;
@@ -1,7 +1,10 @@
package com.bsd.pjcloud.hard.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.HardInvoiceEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
@@ -9,9 +12,10 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface, BaseUserInterface {
public class HardInvoiceVo extends HardInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseOrderInterface, BaseHardInterface {
private Long historyId;
@@ -72,6 +76,24 @@ public class HardInvoiceVo extends HardInvoiceEntity implements BaseDeptInterfac
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
}
@@ -1,15 +1,19 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseOrderInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInterface, BaseDeptInterface, BaseOrderInterface {
public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseOrderInterface {
/**
* 自动退款任务状态;为空表示当前售后仍走人工审核,非空时前端不得展示审核或取消操作。
*/
@@ -40,8 +44,26 @@ public class HardAfterSaleVo extends HardAfterSaleEntity implements BaseUserInte
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -11,6 +11,11 @@ import lombok.Data;
@Data
public class HardCanteenProductApptExportRecord {
/**
* 导出聚合层级,固定为 SUMMARY、MERCHANT 或 PRODUCT;仅用于一次查询后拆分三级结果,不持久化。
*/
private String recordLevel;
/** 业务发生日期,格式为 yyyy-MM-dd;线下取支付日期,预约取核销日期。 */
private String statisticsDate;
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
@@ -11,8 +14,9 @@ import lombok.Data;
import java.time.LocalTime;
import java.util.List;
/** 硬件订单管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseMchInterface, BaseHardInterface {
public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseMchInterface, BaseHardInterface {
/** 统计明细来源编码;线下订单为 OFFLINE_PURCHASE,预约已核销订单为 RESERVATION_VERIFIED。 */
private String saleSource;
@@ -28,6 +32,15 @@ public class HardOrderVo extends HardOrderEntity implements BaseUserDeptInterfac
private String userAvatar;
/** 用户身份协议编码集合;没有身份时为空集合或空值。 */
private List<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,5 +1,9 @@
package com.bsd.pjcloud.hard.api.vo.spu;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.hard.api.entity.spu.HardSpuRepertoryEntity;
import com.bsd.pjcloud.hard.api.vo.base.BaseHardInterface;
@@ -7,7 +11,8 @@ import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import lombok.Data;
@Data
public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseHardInterface {
/** 硬件商品库存更新记录展示对象,包含上架用户主资料、部门和身份信息。 */
public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMchInterface, BaseUserInterface, BaseUserDeptInterface, BaseUserIdentityInterface, BaseHardInterface {
/**
* 商品相关
@@ -45,6 +50,52 @@ public class HardSpuRepertoryVo extends HardSpuRepertoryEntity implements BaseMc
private String userAvatar;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
/** 用户当前所属部门主键,由 UPMS 用户资料回填。 */
private Long userDeptId;
/** 用户当前所属部门名称,由 UPMS 用户资料回填。 */
private String userDeptName;
/** 用户所属部门展示名称,供用户信息单元格读取。 */
private String deptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private java.util.List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/** 获取 UPMS 返回的用户当前部门主键。 */
@Override
public Long getUserDeptId() {
return userDeptId;
}
/** 保存 UPMS 返回的用户当前部门主键。 */
@Override
public void setUserDeptId(Long userDeptId) {
this.userDeptId = userDeptId;
}
/** 获取 UPMS 返回的用户当前部门名称。 */
@Override
public String getUserDeptName() {
return userDeptName;
}
/** 保存 UPMS 返回的用户当前部门名称,并同步公共展示字段。 */
@Override
public void setUserDeptName(String userDeptName) {
this.userDeptName = userDeptName;
this.deptName = userDeptName;
}
/**
* 设备相关
*/
@@ -8,8 +8,6 @@ import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
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.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
@@ -18,11 +16,8 @@ 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;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -55,9 +50,6 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCanteenApptTotalController {
/** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */
private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
/**
* 食堂预约统计服务,负责菜品和用户维度的预约聚合。
*/
@@ -68,11 +60,6 @@ public class HardCanteenApptTotalController {
*/
private final HardCanteenApptTotalExportService canteenApptTotalExportService;
/**
* 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。
*/
private final HardCanteenTotalService canteenTotalService;
/**
* 商户远程服务,用于限定 App 食堂范围。
*/
@@ -106,47 +93,6 @@ public class HardCanteenApptTotalController {
/* ************************************* end app end ************************************* */
/**
* 按预约使用日期统计食堂预约部门。
*
* @param dto 预约统计筛选条件,其中 {@code apptUseDateTimeList} 表示预约使用日期范围
* @return 按食堂聚合并包含部门、用餐时间段明细的预约统计结果
*/
@Operation(summary = "食堂预约部门统计", description = "按预约使用日期统计食堂预约部门")
@ApiCryptoAes
@PostMapping("/list-by-dept")
public R<List<HardCanteenProductTotalVo>> listByDept(@RequestBody HardOrderDto dto) {
// 预约统计必须以预约使用日期为唯一时间口径,避免调用方残留支付时间条件导致交叉筛选。
dto.setPayTimeList(null);
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setTotalIsFindChildren(true);
dto.setApptIdOrApptRuleIdNotNull(true);
PjcloudUser user = SecurityUtils.getUser();
return R.ok(canteenTotalService.listByDept(dto, user, null));
}
/**
* 分页查询后台食堂菜品预约统计。
*
* @param page 分页参数
* @param dto 预约订单筛选条件
* @return 菜品预约统计分页
*/
@Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计")
@PostMapping("/proudct-reserv-total")
public R<Page<HardCanteenProductTotalVo>> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
// 查询商户
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
dto.setOrderType(HardOrderTypeEnum.RESERVE.getType());
dto.setApptIdOrApptRuleIdNotNull(true);
return R.ok(canteenApptTotalService.productReservTotal(page, dto, null));
}
/**
* 分页查询用户在指定用餐日期范围内每天的预约情况。
*
@@ -228,67 +174,4 @@ public class HardCanteenApptTotalController {
return canteenApptTotalExportService.userReservationExport(query);
}
/**
* 提交菜品预约统计异步导出任务。
*
* @param query 菜品预约日期、餐段、食堂和部门筛选条件
* @return 供前端轮询的初始任务进度
*/
@Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel")
@SysLog("菜品预约统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
@PostMapping("/product-reserv-export-task")
public R<AsyncTaskProgressVO> submitProductReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
}
/**
* 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
*/
@Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
@SysLog("菜品预约统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
@ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/product-reserv-export")
public List<HardCanteenProductApptExcelVo> productReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return canteenApptTotalExportService.productReservationExport(query);
}
/**
* 为后台菜品预约导出补充当前操作者可见的食堂范围。
*
* <p>分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
* 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。</p>
*
* @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
*/
private void applyCanteenMerchantScope(HardOrderDto query) {
if (query == null) {
return;
}
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
R<List<MchManageVo>> scopeResponse = remoteMchService.scopeList(mchManageDto);
List<MchManageVo> mchList = scopeResponse == null ? null : scopeResponse.getData();
List<Long> visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
: mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isEmpty(visibleMerchantIds)) {
query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
return;
}
if (ObjectUtil.isEmpty(query.getMchIds())) {
query.setMchIds(visibleMerchantIds);
return;
}
// 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
List<Long> allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
@@ -10,7 +11,8 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenDeptTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -58,11 +60,25 @@ public class HardCanteenDeptTotalController {
@Operation(summary = "食堂菜品部门统计导出", description = "按日期、食堂、来源、部门和餐段导出销售统计")
@SysLog("食堂菜品部门统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
@ResponseExcel(name = "食堂菜品部门统计")
@ResponseExcel(name = "食堂菜品部门统计", inMemory = true,
writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-dept")
public List<HardCanteenDeptTotalExcelVo> exportDept(@RequestBody HardOrderDto dto) {
PjcloudUser user = SecurityUtils.getUser();
return canteenTotalService.exportDept(dto, user, null);
public List<HardCanteenProductApptExcelVo> exportDept(@RequestBody HardOrderDto dto) {
return canteenTotalService.exportSales(dto, null);
}
/**
* 提交食堂部门销售统计异步导出任务。
*
* @param dto 来源统计日期、来源、食堂、餐段和部门筛选
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "食堂菜品部门统计异步导出", description = "按自然日分批查询并流式生成部门销售统计 Excel")
@SysLog("食堂菜品部门统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenDeptTotal_export')")
@PostMapping("/export-dept-task")
public R<AsyncTaskProgressVO> submitDepartmentSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitDepartmentSalesExport(dto), "食堂菜品部门统计导出任务已提交");
}
}
@@ -1,6 +1,7 @@
package com.bsd.pjcloud.hard.controller.order;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -23,7 +24,8 @@ import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductTotalExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -150,17 +152,28 @@ public class HardCanteenTotalController {
*/
@Operation(summary = "食堂菜品销售统计导出", description = "按日期、餐段和来源导出食堂菜品销售统计")
@SysLog("食堂菜品销售统计导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export')")
@ResponseExcel(name = "食堂菜品销售统计")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ "@pms.hasPermission('hard_canteenProductApptTotal_export')")
@ResponseExcel(name = "食堂菜品销售统计", inMemory = true,
writeHandler = HardCanteenProductApptExportMergeHandler.class)
@PostMapping("/export-product")
public List<HardCanteenProductTotalExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
List<MchManageVo> mchList = remoteMchService.scopeList(mchManageDto).getData();
if (ObjectUtil.isNotEmpty(mchList) && ObjectUtil.isEmpty(dto.getMchIds())) {
dto.setMchIds(mchList.stream().map(MchManageVo::getId).toList());
}
return canteenTotalService.exportProduct(dto, null);
public List<HardCanteenProductApptExcelVo> exportProduct(@RequestBody HardOrderDto dto) {
return canteenTotalService.exportSales(dto, null);
}
/**
* 提交食堂菜品销售统计异步导出任务。
*
* @param dto 来源统计日期、来源、食堂、餐段和部门筛选
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
@Operation(summary = "食堂菜品销售统计异步导出", description = "按自然日分批查询并流式生成销售统计 Excel")
@SysLog("食堂菜品销售统计异步导出")
@PreAuthorize("@pms.hasPermission('hard_canteenProductTotal_export') or "
+ "@pms.hasPermission('hard_canteenProductApptTotal_export')")
@PostMapping("/export-product-task")
public R<AsyncTaskProgressVO> submitProductSalesExport(@RequestBody HardOrderDto dto) {
return R.ok(canteenTotalService.submitProductSalesExport(dto), "食堂菜品销售统计导出任务已提交");
}
/**
@@ -47,7 +47,7 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 日期分组边框线型,参考模板使用中等线宽突出每天的数据边界。 */
private static final BorderStyle DATE_GROUP_BORDER_STYLE = BorderStyle.MEDIUM;
/** 日期分组边框颜色,按预约日期顺序交替使用蓝色和橙色。 */
/** 日期分组边框颜色,按统计日期顺序交替使用蓝色和橙色。 */
private static final byte[][] DATE_GROUP_BORDER_COLORS = {
{0x5B, (byte) 0x9B, (byte) 0xD5},
{(byte) 0xED, 0x7D, 0x31}
@@ -75,13 +75,13 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
/** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
private int lastDataRow = -1;
/** 当前预约日期分组序号,同一天的所有餐段共用一个序号和边框颜色。 */
/** 当前统计日期分组序号,同一天的所有餐段和来源块共用一个序号和边框颜色。 */
private int dateGroupIndex = -1;
/** 当前预约日期的单元格值标识,用于判断新餐段是否仍属于同一天。 */
/** 当前统计日期的单元格值标识,用于判断新餐段或来源块是否仍属于同一天。 */
private String currentDateKey;
/** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
/** 当前导出工作表;本处理器只注册到菜品销售统计导出的单个工作表。 */
private Sheet currentSheet;
/**
@@ -203,14 +203,14 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
* 为当前预约日期设置整组外框:当天首个餐段首行加顶部边框,当天所有行加左右边框,末行加底部边框。
* 为当前统计日期设置整组外框:当天首个来源块首行加顶部边框,当天所有行加左右边框,末行加底部边框。
*
* <p>流式工作簿会刷出较早的数据行,分组结束时无法可靠回读整组行,因此左右边框必须随行写出;
* 新日期开始时,最近写出的上一天末行仍在流式窗口内,可补齐上一天的本色底边。</p>
*
* @param row 当前数据行
* @param holder 当前工作簿持有者,用于创建彩色边框样式
* @param groupIndex 当前预约日期分组序号,未识别到日期时为负数
* @param groupIndex 当前统计日期分组序号,未识别到日期时为负数
* @param topBorder 是否为当天首行并设置顶部边框
* @param bottomBorder 是否为当天末行并设置底部边框
*/
@@ -284,10 +284,10 @@ public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler
}
/**
* 获取日期汇总单元格的稳定值标识;同一预约日期的不同餐段会得到相同标识。
* 获取日期汇总单元格的稳定值标识;同一统计日期的不同餐段和来源块会得到相同标识。
*
* @param row 当前日期餐段汇总行
* @param columnIndex 预约日期所在列索引
* @param columnIndex 统计日期所在列索引
* @return 用于判断日期是否变化的非空值标识
*/
private String getCellValueKey(Row row, int columnIndex) {
@@ -20,6 +20,7 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper<HardDoorFlowEntity
MPJLambdaQueryWrapper<HardDoorFlowEntity> wrapper = new MPJLambdaQueryWrapper<HardDoorFlowEntity>()
.selectAll(HardDoorFlowEntity.class)
.eq(ObjectUtil.isNotEmpty(dto.getUserId()), HardDoorFlowEntity::getUserId, dto.getUserId())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), HardDoorFlowEntity::getUserId, dto.getUserIds())
.eq(ObjectUtil.isNotEmpty(dto.getBizType()), HardDoorFlowEntity::getBizType, dto.getBizType())
.eq(ObjectUtil.isNotEmpty(dto.getHardDoorId()), HardDoorFlowEntity::getHardDoorId, dto.getHardDoorId())
.eq(ObjectUtil.isNotEmpty(dto.getCabinetNo()), HardDoorFlowEntity::getCabinetNo, dto.getCabinetNo())
@@ -35,4 +36,4 @@ public interface HardDoorFlowMapper extends PjcloudBaseMapper<HardDoorFlowEntity
.orderByDesc(HardDoorFlowEntity::getId);
return selectJoinPage(page, HardDoorFlowVo.class, wrapper);
}
}
}
@@ -25,4 +25,4 @@ public interface HardInvoiceMapper extends PjcloudBaseMapper<HardInvoiceEntity>
Page<HardInvoiceVo> scopePageList(Page page, @Param("dto") HardInvoiceDto dto, @Param("dataScope") DataScope dataScope);
}
}
@@ -49,8 +49,8 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
/**
* 查询菜品多维统计行且不应用数据库分页。
*
* <p>同一次查询通过 ROLLUP 返回当日合计、餐段合计和餐段来源三层数据,保证交易人数在每个层级独立去重;
* 服务层按完整自然日分页,导出仅选择餐段来源层。</p>
* <p>同一次查询通过统一来源 CTE 返回当日合计、来源合计、餐段合计和餐段来源四层数据,
* 保证交易人数在每个层级独立去重;服务层按完整自然日分页。</p>
*
* @param dto 统计日期、来源、食堂、餐段和部门筛选
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
@@ -70,28 +70,15 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 查询菜品销售三级导出的日期餐段汇总及来源拆分。
* 一次查询菜品销售三级导出的汇总、商户部门和商品聚合行。
*
* <p>SQL 只构造一次来源 CTE,再按记录层级分别聚合,避免三个查询重复扫描订单、购物车和退款数据。</p>
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段的跨来源合计及各来源统计,交易人数在当前层级独立去重
* @param dataScope 订单所属部门数据范围;为空时不附加范围条件
* @return 按层级、日期、餐段、来源、商户部门和商品稳定排序的三级聚合行
*/
List<HardCanteenProductApptExportRecord> listSalesExportSummary(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商户部门汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个日期餐段下商户部门的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportMerchant(@Param("dto") HardOrderDto dto);
/**
* 查询菜品销售三级导出的商品汇总及来源拆分。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 每个商户部门块下商品的跨来源合计及各来源统计
*/
List<HardCanteenProductApptExportRecord> listSalesExportProduct(@Param("dto") HardOrderDto dto);
List<HardCanteenProductApptExportRecord> listSalesExportRows(@Param("dto") HardOrderDto dto, DataScope dataScope);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
@@ -2,8 +2,6 @@ package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import java.util.List;
@@ -31,20 +29,4 @@ public interface HardCanteenApptTotalExportService {
* @return 用户预约导出行
*/
List<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
/**
* 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门、商品三级结构排列的导出行
*/
List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query);
}
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
@@ -78,4 +79,20 @@ public interface HardCanteenTotalService {
* @return 日期餐段、商户部门、商品三级排列的导出行
*/
List<HardCanteenProductApptExcelVo> exportSales(HardOrderDto dto, DataScope dataScope);
/**
* 提交食堂菜品销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitProductSalesExport(HardOrderDto dto);
/**
* 提交食堂部门销售统计异步导出任务。
*
* @param dto 统计日期、来源、餐段、食堂和部门筛选条件
* @return 可供前端轮询的初始任务进度,成功终态包含文件下载地址
*/
AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto);
}
@@ -34,6 +34,7 @@ import com.bsd.pjcloud.hard.api.vo.order.HardAppCreateThirdDoorOrderVo;
import com.bsd.pjcloud.hard.mapper.HardAuthMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -62,8 +63,12 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
private final RemoteUserService remoteUserService;
private final RemoteAppUserService remoteAppUserService;
/** 授权用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAuthVo> scopePageJoin(Page page, HardAuthDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardAuthVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -237,14 +242,18 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardAuthVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将授权用户身份条件预筛选为授权记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardAuthDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
/**
@@ -268,4 +277,4 @@ public class HardAuthServiceImpl extends ServiceImpl<HardAuthMapper, HardAuthEnt
.append("使用本智能柜设备即视为您已阅读并同意以上所有条款。请谨慎使用,确保遵守使用规定。");
return strBuilder.toString();
}
}
}
@@ -29,6 +29,7 @@ import com.bsd.pjcloud.hard.api.vo.HardBroadcastVo;
import com.bsd.pjcloud.hard.mapper.HardBroadcastMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.HardBroadcastService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.constant.MqttActionEnum;
import com.bsd.pjcloud.mqtt.dto.hard.HardTakeFoodBroadcastMsg;
@@ -55,10 +56,14 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
private final RemoteUserService remoteUserService;
/** 广播预定用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteMqttEventService remoteMqttEventService;
@Override
public Page<HardBroadcastVo> scopePageJoin(Page page, HardBroadcastDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardBroadcastVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -310,14 +315,18 @@ public class HardBroadcastServiceImpl extends ServiceImpl<HardBroadcastMapper, H
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardBroadcastVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将广播预定用户身份条件预筛选为广播记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardBroadcastDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
}
@@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.mapper.HardDoorFlowMapper;
import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdDoorApiService;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdKsService;
import com.bsd.pjcloud.thirdparty.dto.door.*;
@@ -88,11 +89,15 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
private final RemoteUserService remoteUserService;
private final RemoteWalletService remoteWalletService;
/** 开门流程用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
/**
* 复杂查询
*/
@Override
public Page<HardDoorFlowVo> scopePageJoin(Page page, HardDoorFlowDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardDoorFlowVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -427,14 +432,18 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
HardUtil.convertHardList(list, CollectionUtils.convertMap(hardList, HardManagerEntity::getId));
}
}
// 用户相关
List<Long> userIds = list.stream().map(HardDoorFlowVo::getUserId).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<UserVO>> userList = remoteUserService.getUserDeptByIds(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserDeptList(list, CollectionUtils.convertMap(userList.getData(), UserVO::getUserId));
}
}
userIdentitySupport.fillUserInfo(list, true);
}
/** 将开门流程用户身份条件预筛选为流程记录可直接使用的用户主键集合。 */
private void prepareIdentityQuery(HardDoorFlowDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
private void disposeList(HardDoorFlowVo detail) {
@@ -458,4 +467,4 @@ public class HardDoorFlowServiceImpl extends ServiceImpl<HardDoorFlowMapper, Har
private HardDoorFlowServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -37,6 +37,7 @@ import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardInvoiceService;
import com.bsd.pjcloud.hard.service.HardManagerService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService;
import com.bsd.pjcloud.mqtt.dto.hard.HardPrintMsg;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
@@ -90,8 +91,12 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private final RemoteFileService fileService;
private final RemoteMqttEventService mqttEventService;
/** 开票记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardInvoiceVo> scopeJoinPageList(Page page, HardInvoiceDto dto) {
prepareIdentityQuery(dto);
Page<HardInvoiceVo> list = baseMapper.scopePageList(page, dto, DataScope.of());
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 部门
@@ -102,14 +107,7 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
DataUtil.convertDeptList(list.getRecords(), deptList.getData());
}
}
// 用户
List<Long> userIds = list.getRecords().stream().map(HardInvoiceVo::getOrderUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 商户
List<Long> mchIds = list.getRecords().stream().map(HardInvoiceVo::getMerchantId).distinct().toList();
if (ObjectUtil.isNotEmpty(mchIds)) {
@@ -122,6 +120,20 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
return list;
}
/**
* 将开票用户身份条件预筛选为订单用户主键集合。
*
* @param dto 开票记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardInvoiceDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, null, dto.getUserIds()));
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
@Override
public String generateInvoice(HardOrderEntity order) {
return generateInvoice(order, null);
@@ -303,4 +315,4 @@ public class HardInvoiceServiceImpl extends ServiceImpl<HardInvoiceMapper, HardI
private HardInvoiceServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -46,6 +46,7 @@ import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
@@ -115,19 +116,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
/** 退款订单级分布式锁模板,与数据库订单行锁共同保护微服务和单体部署。 */
private final LockTemplate lockTemplate;
/** 售后申请用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardAfterSaleVo> pageJoin(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.pageJoin(page, dto);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List<Long> deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(deptIds)) {
@@ -151,16 +149,10 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
@Override
public Page<HardAfterSaleVo> scopePageList(Page page, HardAfterSaleDto dto) {
prepareIdentityQuery(dto);
Page<HardAfterSaleVo> list = baseMapper.scopePageJoinList(page, dto, null);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
// 用户
List<Long> userIds = list.getRecords().stream().map(HardAfterSaleVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
R<List<SysUser>> userList = userService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list.getRecords(), CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
}
userIdentitySupport.fillUserInfo(list.getRecords(), true);
// 部门
List<Long> deptIds = list.getRecords().stream().map(HardAfterSaleVo::getDeptId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(deptIds)) {
@@ -174,6 +166,21 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
return list;
}
/**
* 将售后申请用户身份条件预筛选为售后记录可直接使用的用户主键集合。
*
* @param dto 售后记录查询条件;处理后保留最终用户主键集合
*/
private void prepareIdentityQuery(HardAfterSaleDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) return;
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
@Override
public Long create(HardAfterSaleDto dto) {
HardOrderVo order = validateOrderCanRefund(dto.getOrderId());
@@ -17,9 +17,6 @@ import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
@@ -32,15 +29,11 @@ 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.HardCanteenUserApptQueryDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
@@ -54,7 +47,6 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -63,8 +55,6 @@ import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -75,10 +65,9 @@ import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 食堂预约统计导出服务实现。
* 用户食堂预约统计导出服务实现。
*
* <p>菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过
* Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。</p>
* <p>按权限用户分批查询每日预约并流式写入 Excel,避免长日期范围和大量用户一次性占用过多内存。</p>
*/
@Slf4j
@Validated
@@ -131,33 +120,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 菜品预约导出进度的 Redis Key 命名空间。 */
private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export";
/** 菜品预约导出任务名称。 */
private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出";
/** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */
private static final int INITIAL_PROGRESS_TOTAL = 1;
/** 菜品预约导出工作表名称。 */
private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计";
/** 文件中心存储目录。 */
private static final String EXPORT_FILE_DIR = "hard-export";
/** 文件中心业务类型编码。 */
private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT";
/** Excel 文件 MIME 类型。 */
private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/** 用餐时间段字典类型。 */
private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu";
/** Excel 文件名时间格式。 */
private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */
private static final long MAX_QUERY_MONTHS = 2L;
@@ -206,9 +171,6 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
/** 菜品预约聚合 Mapper。 */
private final HardCarMapper hardCarMapper;
/** 每日菜谱远程服务,用于展开套餐组成。 */
private final RemoteDailyMenuService remoteDailyMenuService;
/** 部门远程服务,用于补齐预约人所属部门名称。 */
private final RemoteDeptService remoteDeptService;
@@ -1303,459 +1265,4 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
}
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 初始异步任务进度
*/
@Override
public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) {
HardOrderDto querySnapshot = copyProductQuery(query);
return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME,
INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter));
}
/**
* 同步查询菜品预约自定义 Excel 行。
*
* @param query 菜品预约统计筛选条件
* @return 三级层级导出行
*/
@Override
public List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query) {
HardOrderDto querySnapshot = copyProductQuery(query);
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
for (LocalDate date : getQueryDates(querySnapshot)) {
HardOrderDto dateQuery = copyProductQuery(querySnapshot);
dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
rows.addAll(buildProductRows(dateQuery, date));
}
return rows;
}
/**
* 后台按日期批次生成菜品预约工作簿并上传文件中心。
*
* @param query 后台线程独占的查询快照
* @param reporter 当前异步任务进度报告器
* @return 文件中心结果
*/
private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) {
List<LocalDate> dates = getQueryDates(query);
reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出");
Path outputPath = null;
String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx";
try {
outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx");
long rowCount;
try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) {
WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build();
rowCount = 0L;
for (LocalDate date : dates) {
HardOrderDto dateQuery = copyProductQuery(query);
dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
List<HardCanteenProductApptExcelVo> batchRows = buildProductRows(dateQuery, date);
if (!batchRows.isEmpty()) {
writer.write(batchRows, sheet);
rowCount += batchRows.size();
}
reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行");
}
if (rowCount == 0L) {
throw new IllegalStateException("没有符合当前条件的菜品预约数据");
}
}
String fileUrl = upload(outputPath, fileName);
reporter.advance("菜品预约导出文件已生成");
return AsyncTaskResult.file(fileUrl, fileName);
} catch (IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
log.error("菜品预约统计导出失败,fileName:{}", fileName, exception);
throw new IllegalStateException("生成菜品预约导出文件失败", exception);
} finally {
if (outputPath != null) {
try {
Files.deleteIfExists(outputPath);
} catch (Exception exception) {
log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception);
}
}
}
}
/**
* 查询一个日期批次的三层数据并转换为 Excel 行。
*
* @param query 日期批次查询条件
* @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取
* @return 当前批次的汇总、商户部门、商品明细和商品统计行
*/
private List<HardCanteenProductApptExcelVo> buildProductRows(HardOrderDto query, LocalDate fixedDate) {
List<HardCanteenProductTotalVo> summaries = hardCarMapper.listApptExportSummary(query);
List<HardCanteenProductApptExportRecord> merchants = hardCarMapper.listApptExportMerchant(query);
List<HardCanteenProductApptExportRecord> products = hardCarMapper.listApptExportProduct(query);
if (summaries == null || summaries.isEmpty()) {
return List.of();
}
Map<Long, String> merchantNames = fetchMerchantNames(merchants);
Map<Long, String> departmentNames = fetchDepartmentNames(merchants);
Map<String, String> hourNames = fetchHourNames();
Map<String, DailyMenuAppVO> menuMap = fetchDailyMenus(products, fixedDate);
Map<String, List<HardCanteenProductApptExportRecord>> merchantGroups = merchants.stream()
.collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList()));
Map<String, List<HardCanteenProductApptExportRecord>> productGroups = products.stream()
.collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList()));
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
for (HardCanteenProductTotalVo summary : summaries) {
LocalDate date = fixedDate != null ? fixedDate
: summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate();
if (date == null) {
log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType());
continue;
}
String hourType = summary.getHourType();
String summaryKey = date + "|" + hourType;
List<HardCanteenProductApptExportRecord> merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
Map<String, List<HardCanteenProductApptExcelVo>> detailRowsByMerchant = new LinkedHashMap<>();
Map<String, Long> detailCountsByMerchant = new HashMap<>();
Map<String, Long> detailPricesByMerchant = new HashMap<>();
long summaryProductCount = 0L;
long summaryTotalPrice = 0L;
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
String merchantKey = groupKey(merchant);
List<HardCanteenProductApptExcelVo> detailRows = productGroups
.getOrDefault(merchantKey, List.of()).stream()
.flatMap(product -> toProductRows(product,
menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
.toList();
List<HardCanteenProductApptExcelVo> statisticRows = buildProductStatistics(detailRows);
long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum();
long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum();
// 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。
if (detailRows.isEmpty()) {
detailCount = valueOrZero(merchant.getPayCount());
detailPrice = valueOrZero(merchant.getTotalPrice());
}
List<HardCanteenProductApptExcelVo> merchantProductRows = new ArrayList<>(
detailRows.size() + statisticRows.size());
merchantProductRows.addAll(detailRows);
merchantProductRows.addAll(statisticRows);
detailRowsByMerchant.put(merchantKey, merchantProductRows);
detailCountsByMerchant.put(merchantKey, detailCount);
detailPricesByMerchant.put(merchantKey, detailPrice);
summaryProductCount += detailCount;
summaryTotalPrice += detailPrice;
}
if (merchantRows.isEmpty()) {
summaryProductCount = valueOrZero(summary.getPayCount());
summaryTotalPrice = valueOrZero(summary.getTotalPrice());
}
HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo();
summaryRow.setSummaryDate(date);
summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames));
summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount()));
summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount()));
// 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。
summaryRow.setSummaryPayCount(summaryProductCount);
summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice));
rows.add(summaryRow);
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo();
merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录"));
merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录"));
merchantRow.setMerchantDate(date);
merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames));
merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount()));
merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount()));
String merchantKey = groupKey(merchant);
merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey,
valueOrZero(merchant.getPayCount())));
merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey,
valueOrZero(merchant.getTotalPrice()))));
rows.add(merchantRow);
rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of()));
}
}
return rows;
}
/**
* 将商品聚合行按预约当天每日菜谱中的套餐组成展开。
*
* <p>组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊,
* 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。</p>
*
* @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径
* @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行
* @return 单品原始行,或按套餐组成顺序生成的商品明细行
*/
private List<HardCanteenProductApptExcelVo> toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) {
List<OnlineSpuComboItemVO> items = menu != null && menu.getComboItems() != null
&& !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of();
if (items.isEmpty()) {
return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()),
product.getTotalPrice()));
}
long packageCount = valueOrZero(product.getPayCount());
long totalCents = valueOrZero(product.getTotalPrice());
long sign = totalCents < 0 ? -1L : 1L;
long absoluteTotalCents = Math.abs(totalCents);
long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice()))
* Math.max(1, valueOrZero(item.getQuantity()))).sum();
long[] itemPrices = new long[items.size()];
long[] itemWeights = new long[items.size()];
long allocated = 0L;
List<Integer> remainderIndexes = new ArrayList<>();
List<HardCanteenProductApptExcelVo> result = new ArrayList<>();
for (int index = 0; index < items.size(); index++) {
OnlineSpuComboItemVO item = items.get(index);
long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity()));
itemWeights[index] = itemWeight;
if (baseAmount > 0) {
BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
.multiply(BigDecimal.valueOf(itemWeight))
.divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue();
allocated += itemPrices[index];
remainderIndexes.add(index);
}
}
if (baseAmount > 0) {
long remainder = absoluteTotalCents - allocated;
// 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。
remainderIndexes.sort(Comparator.comparing((Integer index) -> {
BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
.multiply(BigDecimal.valueOf(itemWeights[index]))
.divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index]));
}).reversed());
for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) {
itemPrices[remainderIndexes.get(index)]++;
}
} else if (!items.isEmpty()) {
itemPrices[items.size() - 1] = absoluteTotalCents;
}
for (int index = 0; index < items.size(); index++) {
OnlineSpuComboItemVO item = items.get(index);
long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity()));
long itemPrice = itemPrices[index] * sign;
result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice));
}
return result;
}
/** 创建单个商品明细行。 */
private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setProductName(name);
row.setProductCount(count);
row.setProductTotalPrice(centsToYuan(totalPrice));
return row;
}
/**
* 在当前商户部门的商品明细右侧写入按商品名称合并后的统计结果。
*
* <p>套餐组成商品可能由多个套餐分别展开成同名明细,因此不能按原套餐 SPU 汇总;这里使用压缩空白后的名称作为键,
* 保留首次出现的展示名称,并使用整数分累计金额,确保统计总数量、总价格与左侧有效明细严格一致。</p>
*
* @param detailRows 同一预约日期、餐段、商户和部门下,已经扣减完成退款并完成套餐展开的商品明细
* @return 独立的商品统计行,顺序与商品名称首次出现的顺序一致
*/
private List<HardCanteenProductApptExcelVo> buildProductStatistics(
List<HardCanteenProductApptExcelVo> detailRows) {
Map<String, HardCanteenProductApptExcelVo> statistics = new LinkedHashMap<>();
for (HardCanteenProductApptExcelVo detailRow : detailRows) {
String displayName = StrUtil.blankToDefault(detailRow.getProductName(), "未命名商品").trim();
String statisticKey = normalizeProductName(displayName);
HardCanteenProductApptExcelVo statistic = statistics.computeIfAbsent(statisticKey, key -> {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setStatisticProductName(displayName);
row.setStatisticProductCount(0L);
row.setStatisticProductTotalPrice(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
return row;
});
statistic.setStatisticProductCount(valueOrZero(statistic.getStatisticProductCount())
+ valueOrZero(detailRow.getProductCount()));
long statisticTotalPrice = yuanToCents(statistic.getStatisticProductTotalPrice())
+ yuanToCents(detailRow.getProductTotalPrice());
statistic.setStatisticProductTotalPrice(centsToYuan(statisticTotalPrice));
}
return new ArrayList<>(statistics.values());
}
/**
* 规范化商品名称中的换行和连续空白,用于合并仅排版不同的同名套餐组成商品。
*
* @param productName 首次出现的商品展示名称
* @return 去除首尾空白并把连续空白压缩为单个空格的统计键
*/
private String normalizeProductName(String productName) {
return productName.replaceAll("\\s+", " ").trim();
}
/** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */
private Map<String, DailyMenuAppVO> fetchDailyMenus(List<HardCanteenProductApptExportRecord> products,
LocalDate fixedDate) {
Map<String, DailyMenuAppVO> result = new HashMap<>();
LocalDate date = fixedDate;
if (date == null) return result;
List<Long> merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId)
.filter(Objects::nonNull).distinct().toList();
for (Long merchantId : merchantIds) {
for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId))
.map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) {
try {
Integer hour = Integer.valueOf(hourType);
R<List<DailyMenuAppVO>> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId);
if (response != null && response.isOk() && response.getData() != null) {
response.getData().forEach(menu -> {
if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu);
});
}
} catch (Exception exception) {
log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}",
merchantId, date, hourType, exception.getMessage());
}
}
}
return result;
}
/** 批量查询商户名称。 */
private Map<Long, String> fetchMerchantNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<List<MchManageVo>> response = remoteMchService.getByIds(ids);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().stream().filter(item -> item.getId() != null)
.collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出商户名称失败,使用空名称", exception);
return Map.of();
}
}
/** 批量查询部门名称。 */
private Map<Long, String> fetchDepartmentNames(List<HardCanteenProductApptExportRecord> rows) {
List<Long> ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList();
if (ids.isEmpty()) return Map.of();
try {
R<Map<Long, SysDept>> response = remoteDeptService.getDeptByDeptIds(ids);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().values().stream().filter(item -> item.getDeptId() != null)
.collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出部门名称失败,使用空名称", exception);
return Map.of();
}
}
/** 查询餐段字典。 */
private Map<String, String> fetchHourNames() {
try {
R<List<SysDictItem>> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE);
if (response == null || !response.isOk() || response.getData() == null) return Map.of();
return response.getData().stream().filter(item -> StrUtil.isNotBlank(item.getItemValue()))
.collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left));
} catch (Exception exception) {
log.warn("查询导出餐段字典失败,使用餐段编码", exception);
return Map.of();
}
}
/** 复制菜品统计查询条件,避免异步任务与请求对象共享可变数组和集合。 */
private HardOrderDto copyProductQuery(HardOrderDto source) {
if (source == null) throw new IllegalArgumentException("菜品预约导出查询条件不能为空");
HardOrderDto target = new HardOrderDto();
target.setApptUseDateTimeList(source.getApptUseDateTimeList() == null ? null : source.getApptUseDateTimeList().clone());
target.setHourType(source.getHourType());
target.setMerchantId(source.getMerchantId());
target.setUserDeptIds(copyList(source.getUserDeptIds()));
target.setIsInclude(source.getIsInclude());
target.setMerchantDeptIds(copyList(source.getMerchantDeptIds()));
target.setMchIds(copyList(source.getMchIds()));
target.setOrderType(HardOrderTypeEnum.RESERVE.getType());
target.setApptIdOrApptRuleIdNotNull(true);
target.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes());
return target;
}
/** 获取查询日期闭区间。 */
private List<LocalDate> getQueryDates(HardOrderDto query) {
if (query.getApptUseDateTimeList() == null || query.getApptUseDateTimeList().length != 2
|| query.getApptUseDateTimeList()[0] == null || query.getApptUseDateTimeList()[1] == null) {
throw new IllegalArgumentException("预约时间不能为空");
}
LocalDate start = query.getApptUseDateTimeList()[0].toLocalDate();
LocalDate end = query.getApptUseDateTimeList()[1].toLocalDate();
if (end.isBefore(start)) throw new IllegalArgumentException("预约结束时间不能早于开始时间");
List<LocalDate> dates = new ArrayList<>();
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) dates.add(date);
return dates;
}
/** 构造商户部门分组键。 */
private String groupKey(HardCanteenProductApptExportRecord record) {
return record.getApptDate() + "|" + record.getHourType() + "|" + record.getMerchantId() + "|" + record.getUserDeptId();
}
/** 构造日期餐段分组键,用于从日期餐段汇总行定位其下属商户部门统计。 */
private String summaryGroupKey(HardCanteenProductApptExportRecord record) {
return record.getApptDate() + "|" + record.getHourType();
}
/** 构造商品每日菜谱缓存键。 */
private String menuKey(HardCanteenProductApptExportRecord record, LocalDate date) {
return record.getMerchantId() + "|" + date + "|" + record.getHourType() + "|" + record.getProductId();
}
/** 将空数值按零处理。 */
private long valueOrZero(Number value) {
return value == null ? 0L : value.longValue();
}
/** 将分转换为元,保持两位小数。 */
private BigDecimal centsToYuan(Number cents) {
return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
}
/**
* 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。
*
* <p>商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。</p>
*
* @param yuan 商品明细展示金额,单位为元;为空按零处理
* @return 金额,单位为分
*/
private long yuanToCents(BigDecimal yuan) {
return yuan == null ? 0L : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue();
}
/** 解析餐段名称,字典缺失时保留原始编码。 */
private String resolveHourName(String hourType, Map<String, String> hourNames) {
return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段"));
}
/** 上传已关闭的 Excel 临时文件并提取下载地址。 */
private String upload(Path path, String fileName) throws Exception {
MultipartFile file = new MockMultipartFile("file", encodeFileName(fileName), EXCEL_CONTENT_TYPE,
Files.readAllBytes(path));
R<?> response = remoteFileService.innerUpload(file, EXPORT_FILE_DIR, null, PRODUCT_EXPORT_FILE_TYPE);
if (response == null || !response.isOk() || !(response.getData() instanceof Map<?, ?> data)
|| data.get("url") == null || StrUtil.isBlank(data.get("url").toString())) {
throw new IllegalStateException("文件中心未返回菜品预约导出下载地址");
}
return data.get("url").toString();
}
/** 按文件中心约定编码中文文件名。 */
private String encodeFileName(String fileName) {
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
}
}
@@ -91,6 +91,7 @@ import com.bsd.pjcloud.hard.mapper.HardThirdMiOrderMapper;
import com.bsd.pjcloud.hard.mapper.order.HardMessageOutboxMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.supermarket.api.feign.RemoteMallProductService;
import com.bsd.pjcloud.supermarket.api.feign.RemoteShopSkuService;
import com.bsd.pjcloud.supermarket.api.feign.RemoteSupermarketSpuService;
@@ -217,6 +218,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final RemoteWalletTransactionService walletTransactionService;
private final RemoteWalletSettingService walletSettingService;
private final RemoteUserService userService;
/** 硬件订单用户身份筛选和展示资料装配器,统一复用 UPMS 权限用户查询。 */
private final HardUserIdentitySupport userIdentitySupport;
private final RemoteUserRelationService userRelationService;
private final RemoteDeptService deptService;
private final RemoteMchRuleJoinService mchRuleJoinService;
@@ -244,6 +247,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public Page<HardOrderVo> pagejoin(Page page, HardOrderDto dto) {
applyIdentityQuery(dto);
Page<HardOrderVo> list = baseMapper.pageJoin(page, dto);
disposeList(list.getRecords(), dto);
return list;
@@ -251,6 +255,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
@Override
public List<HardOrderVo> scopeList(HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
List<HardOrderVo> list = baseMapper.scopeList(dto, dataScope);
disposeList(list, dto);
return list;
@@ -305,6 +310,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> scopePageList(Page page, HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
if (dto.getAppViewerUserId() != null) {
Page<HardOrderVo> list = appVisibilityPageList(page, dto, dataScope);
disposeList(list.getRecords(), dto);
@@ -351,6 +357,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
*/
@Override
public Page<HardOrderVo> noRecogScopePageJoin(Page page, HardOrderDto dto, DataScope dataScope) {
applyIdentityQuery(dto);
dto.setOrderPayTypeCodes(List.of(HardPayTypeEnum.HARD_DOOR_FREE_PAY.getType()));
dto.setOrderStatus(HardOrderStatusEnum.CREATE.getType());
Page<HardOrderVo> list = baseMapper.scopePageList(page, dto, dataScope);
@@ -371,10 +378,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
CompletableFuture<Void> findUserFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
if (ObjectUtil.isNotEmpty(detail.getUserId())) {
List<UserVO> userVoList = userService.getUserDeptByIds(List.of(detail.getUserId())).getData();
if (ObjectUtil.isNotEmpty(userVoList) && ObjectUtil.isNotEmpty(userVoList.get(0))) {
DataUtil.convertUserDept(detail, userVoList.get(0));
}
userIdentitySupport.fillUserInfo(List.of(detail), true);
}
});
// 商户
@@ -4839,20 +4843,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
// 用户部门
CompletableFuture<Void> deptFuture = CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
List<Long> userIds = list.stream().map(HardOrderVo::getUserId).filter(ObjectUtil::isNotEmpty).distinct().toList();
if (ObjectUtil.isNotEmpty(userIds)) {
List<UserVO> userList = userService.getUserDeptByIds(userIds).getData();
if (ObjectUtil.isNotEmpty(userList)) {
Map<Long, UserVO> map = CollectionUtils.convertMap(userList, UserVO::getUserId);
for (HardOrderVo item : list) {
UserVO userVO = map.get(item.getUserId());
if (ObjectUtil.isEmpty(userVO)) continue;
item.setUsername(userVO.getName());
item.setUserMobile(userVO.getPhone());
item.setUserAvatar(userVO.getAvatar());
}
}
}
userIdentitySupport.fillUserInfo(list, true);
});
// 当前部门
CompletableFuture<Void> currentDeptFuture = CompletableFuture.runAsync(() -> {
@@ -4935,4 +4926,21 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
});
CompletableFuture.allOf(deptFuture, mchFuture, currentDeptFuture, walletFuture, findCarListFuture, findApptListFuture).join();
}
/**
* 将身份条件预筛选为用户主键,保持订单 SQL 的原有用户条件和数据范围语义。
*
* @param dto 订单查询条件
*/
private void applyIdentityQuery(HardOrderDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) {
return;
}
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
}
@@ -4,11 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.common.core.util.CollectionUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.spu.HardSpuRepertoryTypeEnum;
@@ -23,6 +19,7 @@ import com.bsd.pjcloud.hard.api.vo.spu.HardSpuRepertoryVo;
import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
import com.bsd.pjcloud.hard.mapper.spu.HardSpuRepertoryMapper;
import com.bsd.pjcloud.hard.service.*;
import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.utils.WalletDataUtil;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
@@ -32,7 +29,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.List;
/**
@@ -50,10 +46,12 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
private final HardSpuJoinService hardSpuJoinService;
private final RemoteMchService remoteMchService;
private final RemoteUserService remoteUserService;
/** 库存上架记录用户身份筛选与展示资料装配器。 */
private final HardUserIdentitySupport userIdentitySupport;
@Override
public Page<HardSpuRepertoryVo> scopePageJoin(Page page, HardSpuRepertoryDto dto, DataScope dataScope) {
prepareIdentityQuery(dto);
Page<HardSpuRepertoryVo> pageList = baseMapper.scopePageJoin(page, dto, dataScope);
disposeList(pageList.getRecords(), dto);
return pageList;
@@ -246,11 +244,20 @@ public class HardSpuRepertoryServiceImpl extends ServiceImpl<HardSpuRepertoryMap
WalletDataUtil.convertMchList(list, CollectionUtils.convertMap(mchList, MchManageVo::getId));
}
}
// 用户
List<Long> userIds = list.stream().map(HardSpuRepertoryVo::getUserId).distinct().toList();
R<List<SysUser>> userList = remoteUserService.getUserById(new ArrayList<>(userIds));
if (ObjectUtil.isNotEmpty(userList.getData())) {
DataUtil.convertUserList(list, CollectionUtils.convertMap(userList.getData(), SysUser::getUserId));
}
// 用户身份、姓名、手机号、头像及部门统一由 UPMS 公共装配器回填。
userIdentitySupport.fillUserInfo(list, true);
}
}
/** 将库存上架记录的身份条件转换为最终用户主键集合,保持原有 userIds SQL 过滤契约。 */
private void prepareIdentityQuery(HardSpuRepertoryDto dto) {
if (!userIdentitySupport.hasIdentityQuery(dto)) {
return;
}
dto.setUserIds(userIdentitySupport.findIdentityUserIds(dto, dto.getUserId(), dto.getUserIds()));
dto.setUserId(null);
dto.setIdentityTypes(null);
dto.setStudentInfo(null);
dto.setTeacherInfo(null);
dto.setHasFace(null);
}
}
@@ -41,6 +41,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -70,4 +76,4 @@
</where>
order by a.create_time desc , a.id desc
</select>
</mapper>
</mapper>
@@ -51,6 +51,12 @@
<if test="dto.userId != null">
and a.user_id = #{dto.userId}
</if>
<if test="dto.userIds != null and dto.userIds.size() != 0">
and a.user_id in
<foreach collection="dto.userIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hardId != null">
and a.hard_id = #{dto.hardId}
</if>
@@ -87,4 +93,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
@@ -84,4 +84,4 @@
</where>
order by a.create_time desc, a.id desc
</select>
</mapper>
</mapper>
@@ -101,4 +101,4 @@
</where>
order by a.create_time desc,a.id desc
</select>
</mapper>
</mapper>
@@ -57,6 +57,21 @@
</if>
</sql>
<!-- 已完成售后按父购物车和商品聚合,供预约商品统计复用同一退款扣减口径。 -->
<sql id="completedRefundCarTotals">
(
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
</sql>
<!--
预约菜品统计统一使用退款扣减后的商品快照:先按父购物车和商品汇总原购买量,再扣除已完成(50)
售后占用的数量、金额。退款申请中(10)仍属于有效预约,部分退款完成后只减少对应份数,取消或拒绝的售后仍正常统计;
@@ -86,17 +101,8 @@
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
left join <include refid="completedRefundCarTotals"/>
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
@@ -112,12 +118,92 @@
</choose>
</sql>
<!--
来源分支可以安全下推的公共订单筛选。日期条件在各分支分别使用支付时间或核销时间,外层仍保留
同一组条件作为契约保护;这里先缩小订单和购物车连接范围,避免大日期表在派生表中无条件聚合。
-->
<sql id="canteenSaleSourceOrderPushdownWhere">
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">
and o.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 o.dept_id in
</when>
<otherwise>
and o.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 o.user_dept_id in
</when>
<otherwise>
and o.user_dept_id not in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
</sql>
<!-- 商品名称只能在已连接购物车别名 a 的查询中下推,避免订单型退款子查询引用不存在的字段。 -->
<sql id="canteenSaleSourceProductPushdownWhere">
<if test="dto.productName != null and dto.productName != ''">
and a.product_name like concat('%', #{dto.productName}, '%')
</if>
</sql>
<!--
预约退款只聚合当前核销统计范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
商品名称条件不在此应用,退款最终仍按父购物车和商品主键精确关联到已筛选的原购物车行。
-->
<sql id="canteenSaleReservationRefundTotals">
(
select refund_item.parent_car_id,
refund_item.product_id,
sum(ifnull(refund_item.product_count, 0)) as product_count,
sum(ifnull(refund_item.total_price, 0)) as total_price
from hard_after_sale_car refund_item
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
and refund_order.del_flag = '0' and refund_order.status = '50'
inner join hard_order o on o.parent_car_id = refund_item.parent_car_id
and o.del_flag = '0' and o.merchant_type = '1' and o.order_type = '2'
and (o.appt_id is not null or o.appt_rule_id is not null)
and o.order_status = '20' and o.verification_time is not null
and o.hour_type is not null and o.hour_type not in ('-1')
where refund_item.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
group by refund_item.parent_car_id, refund_item.product_id
) refund_car
</sql>
<!--
新版食堂销售统计按业务发生时间拆分来源:线下订单使用支付时间,预约订单只使用已核销订单的核销时间。
预约分支沿用退款扣减后的商品快照;线下分支保留原始购物车快照,避免改变历史线下统计口径。
单来源查询只生成对应分支;预约分支先按已核销订单过滤再聚合购物车并扣减退款,避免全量购物车聚合。
-->
<sql id="canteenSaleStatsSource">
(
<trim prefix="(" suffix=") sale_stats" suffixOverrides="union all">
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('OFFLINE_PURCHASE')">
select DATE_FORMAT(o.pay_time, '%Y-%m-%d') as stat_date,
o.pay_time as stat_time,
o.id as order_id,
@@ -142,7 +228,14 @@
where a.del_flag = '0'
and o.pay_time is not null
and o.order_status in ('5', '20')
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.pay_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
union all
</if>
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('RESERVATION_VERIFIED')">
select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date,
o.verification_time as stat_time,
o.id as order_id,
@@ -153,13 +246,13 @@
o.merchant_dept_id,
o.hour_type,
a.product_id,
a.product_name,
a.product_pic,
a.product_price,
a.product_count,
a.total_price,
max(a.product_name) as product_name,
max(a.product_pic) as product_pic,
max(a.product_price) as product_price,
greatest(sum(ifnull(a.product_count, 0)) - ifnull(refund_car.product_count, 0), 0) as product_count,
greatest(sum(ifnull(a.total_price, 0)) - ifnull(refund_car.total_price, 0), 0) as total_price,
'RESERVATION_VERIFIED' as sale_source
from <include refid="apptEffectiveCarSource"/>
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.merchant_type = '1' and o.order_type = '2'
@@ -167,7 +260,20 @@
and o.order_status = '20'
and o.verification_time is not null
and o.hour_type is not null and o.hour_type not in ('-1')
) sale_stats
left join <include refid="canteenSaleReservationRefundTotals"/>
on refund_car.parent_car_id = a.parent_car_id
and refund_car.product_id = a.product_id
where a.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id,
o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price
having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0)
</if>
</trim>
</sql>
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和核销时间使用同一日期控件。 -->
@@ -644,22 +750,32 @@
综合菜品销售导出为每个日期餐段同时生成跨来源合计和来源拆分。
三层 SQL 使用相同的来源、业务时间、食堂和部门筛选,保证页面与导出以及上下层之间口径一致。
-->
<select id="listSalesExportSummary"
<select id="listSalesExportRows"
resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
with sale_stats as (
select * from <include refid="canteenSaleStatsSource"/>
)
select export_rows.statisticsDate,
select export_rows.recordLevel,
export_rows.statisticsDate,
export_rows.hourType,
export_rows.saleSource,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productId,
export_rows.productName,
export_rows.payUserCount,
export_rows.timeCount,
export_rows.payCount,
export_rows.totalPrice
from (
select sale_stats.stat_date as statisticsDate,
select 'SUMMARY' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
null as saleSource,
null as merchantId,
null as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -670,9 +786,14 @@
</where>
group by sale_stats.stat_date, sale_stats.hour_type
union all
select sale_stats.stat_date as statisticsDate,
select 'SUMMARY' as recordLevel,
sale_stats.stat_date as statisticsDate,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
null as merchantId,
null as userDeptId,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -682,33 +803,15 @@
<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,
union all
select 'MERCHANT' as recordLevel,
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,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -719,11 +822,14 @@
</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,
select 'MERCHANT' as recordLevel,
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,
null as productId,
null as productName,
count(distinct sale_stats.user_id) as payUserCount,
count(distinct sale_stats.order_id) as timeCount,
sum(sale_stats.product_count) as payCount,
@@ -734,37 +840,17 @@
</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,
union all
select 'PRODUCT' as recordLevel,
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,
null as payUserCount,
null as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
@@ -774,13 +860,16 @@
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,
select 'PRODUCT' as recordLevel,
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,
null as payUserCount,
null as timeCount,
sum(sale_stats.product_count) as payCount,
sum(sale_stats.total_price) as totalPrice
from sale_stats
@@ -794,6 +883,11 @@
export_rows.hourType,
case when export_rows.saleSource is null then 0
when export_rows.saleSource = 'OFFLINE_PURCHASE' then 1 else 2 end,
case export_rows.recordLevel
when 'SUMMARY' then 0
when 'MERCHANT' then 1
else 2
end,
export_rows.merchantId,
export_rows.userDeptId,
export_rows.productName
@@ -1111,6 +1205,7 @@
select * from <include refid="canteenSaleStatsSource"/>
)
select statistics_rows.createTime,
statistics_rows.mchId,
statistics_rows.userDeptId,
statistics_rows.hourType,
statistics_rows.saleSource,
@@ -1121,6 +1216,7 @@
statistics_rows.statisticsLevel
from (
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
null as saleSource,
@@ -1133,9 +1229,10 @@
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type
group by sale_stats.stat_date, sale_stats.mch_id, sale_stats.user_dept_id, sale_stats.hour_type
union all
select sale_stats.stat_date as createTime,
sale_stats.mch_id as mchId,
sale_stats.user_dept_id as userDeptId,
sale_stats.hour_type as hourType,
sale_stats.sale_source as saleSource,
@@ -1148,9 +1245,11 @@
<where>
<include refid="canteenSaleOrderStatsWhere"/>
</where>
group by sale_stats.stat_date, sale_stats.user_dept_id, sale_stats.hour_type, sale_stats.sale_source
group by sale_stats.stat_date, sale_stats.mch_id, sale_stats.user_dept_id,
sale_stats.hour_type, sale_stats.sale_source
) statistics_rows
order by statistics_rows.createTime,
statistics_rows.mchId,
statistics_rows.userDeptId,
statistics_rows.hourType,
case statistics_rows.statisticsLevel
@@ -24,7 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
* 菜品销售导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
*/
class HardCanteenProductApptExportMergeHandlerTest {
@@ -53,34 +53,35 @@ class HardCanteenProductApptExportMergeHandlerTest {
EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler())
.sheet("菜品预约统计").doWrite(rows);
.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("G3:G135"));
assertTrue(mergedRegions.contains("H4:H135"));
assertTrue(mergedRegions.contains("O4:O135"));
assertTrue(mergedRegions.contains("A136:A140"));
assertTrue(mergedRegions.contains("G137:G140"));
assertTrue(mergedRegions.contains("H137:H140"));
assertTrue(mergedRegions.contains("A141:A148"));
assertTrue(mergedRegions.contains("G142:G148"));
assertTrue(mergedRegions.contains("H142:H148"));
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));
assertEquals(10 * 256, workbook.getSheetAt(0).getColumnWidth(4));
assertEquals(10 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(11 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(15));
assertEquals(11 * 256, workbook.getSheetAt(0).getColumnWidth(17));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(18));
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(20));
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();
.getCell(20).getCellStyle();
XSSFCellStyle sameDateMealTopStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(135)
.getCell(0).getCellStyle();
XSSFCellStyle firstDateBottomStyle = (XSSFCellStyle) workbook.getSheetAt(0).getRow(139)
@@ -109,7 +110,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造日期餐段汇总行。
*
* @param date 预约日期
* @param date 统计日期
* @param hourType 餐段名称
* @return 仅填写汇总列的导出行
*/
@@ -117,6 +118,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setSummaryDate(date);
row.setSummaryHourType(hourType);
row.setSummarySaleSourceName("合计");
row.setSummaryPayUserCount(1L);
row.setSummaryTimeCount(1L);
row.setSummaryPayCount(1L);
@@ -127,7 +129,7 @@ class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造商户部门统计行。
*
* @param date 预约日期
* @param date 统计日期
* @param hourType 餐段名称
* @return 仅填写商户部门列的导出行
*/
@@ -1,169 +1,28 @@
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 com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.util.List;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
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);
void shouldExposeOnlyUserReservationExportOperations() {
Set<String> methodNames = Arrays.stream(HardCanteenApptTotalExportService.class.getDeclaredMethods())
.map(method -> method.getName())
.collect(Collectors.toSet());
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());
assertEquals(Set.of("submitUserReservationExport", "userReservationExport"), methodNames);
}
}
@@ -4,29 +4,50 @@ 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.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenCategoryService;
import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
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.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.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 org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -44,6 +65,18 @@ class HardCanteenTotalServiceImplTest {
/** 食堂范围远程服务,用于部门统计返回可查询的食堂集合。 */
private final RemoteMchService remoteMchService = mock(RemoteMchService.class);
/** 每日菜谱远程服务,用于隔离导出中的套餐展开查询。 */
private final RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class);
/** 餐段字典远程服务,用于校验导出的餐段中文名称。 */
private final RemoteDictService remoteDictService = mock(RemoteDictService.class);
/** 文件中心远程服务,用于验证异步导出上传和结果地址。 */
private final RemoteFileService remoteFileService = mock(RemoteFileService.class);
/** 通用异步任务执行器,用于捕获后台导出动作并在测试线程执行。 */
private final AsyncTaskExecutorService asyncTaskExecutorService = mock(AsyncTaskExecutorService.class);
/** 被测统计服务。 */
private HardCanteenTotalServiceImpl service;
@@ -59,7 +92,10 @@ class HardCanteenTotalServiceImplTest {
remoteMchService,
mock(RemoteDataScopeService.class),
mock(RemoteCanteenCategoryService.class),
mock(RemoteDictService.class));
remoteDailyMenuService,
remoteDictService,
remoteFileService,
asyncTaskExecutorService);
}
/**
@@ -153,6 +189,7 @@ class HardCanteenTotalServiceImplTest {
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());
verify(hardCarMapper, times(1)).listByHourTypeStats(any(HardOrderDto.class));
}
/**
@@ -180,6 +217,137 @@ class HardCanteenTotalServiceImplTest {
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
}
/**
* 验证综合导出为同一日期餐段依次生成合计、线下购买和预约核销三个完整三级块。
*/
@Test
void exportSalesBuildsTotalAndSourceBlocksWithMerchantAndProductRows() {
HardOrderDto dto = statisticsDto();
mockCanteenScope();
SysDept department = new SysDept();
department.setDeptId(20L);
department.setName("测试部门");
SysDictItem hourType = new SysDictItem();
hourType.setItemValue("1");
hourType.setLabel("早餐");
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of(20L, department)));
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(hourType)));
when(remoteDailyMenuService.getCanteenList("2026-08-24", 1, null, 10L)).thenReturn(R.ok(List.of()));
List<HardCanteenProductApptExportRecord> summaries = List.of(
exportRecord(null, null),
exportRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, null),
exportRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, null));
List<HardCanteenProductApptExportRecord> merchants = List.of(
exportRecord(null, 20L),
exportRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, 20L),
exportRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, 20L));
List<HardCanteenProductApptExportRecord> products = List.of(
exportProductRecord(null, "全部商品"),
exportProductRecord(HardSaleSourceEnum.OFFLINE_PURCHASE, "线下商品"),
exportProductRecord(HardSaleSourceEnum.RESERVATION_VERIFIED, "预约商品"));
List<HardCanteenProductApptExportRecord> exportRecords = new ArrayList<>();
exportRecords.addAll(summaries);
exportRecords.addAll(merchants);
exportRecords.addAll(products);
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull())).thenReturn(exportRecords);
List<HardCanteenProductApptExcelVo> rows = service.exportSales(dto, null);
assertEquals(12, rows.size());
assertEquals(List.of("合计", "线下购买", "预约核销"), rows.stream()
.filter(row -> "SUMMARY".equals(row.getRowLevel()))
.map(HardCanteenProductApptExcelVo::getSummarySaleSourceName).toList());
assertEquals(3, rows.stream().filter(row -> "MERCHANT".equals(row.getRowLevel())).count());
assertEquals(3, rows.stream().filter(row -> "PRODUCT".equals(row.getRowLevel())).count());
assertEquals(3, rows.stream().filter(row -> "PRODUCT_STATISTIC".equals(row.getRowLevel())).count());
assertEquals(List.of(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode(),
HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), dto.getSaleSourceCodes());
}
/**
* 验证跨日导出只按自然日拆批,不会把首尾日扩展到用户未选择的时间段。
*/
@Test
void exportSalesPreservesSelectedTimesAtDateRangeBoundaries() {
HardOrderDto dto = new HardOrderDto();
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 8, 23, 8, 30),
LocalDateTime.of(2026, 8, 24, 12, 15)
});
mockCanteenScope();
service.exportSales(dto, null);
ArgumentCaptor<HardOrderDto> queryCaptor = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper, atLeastOnce()).listSalesExportRows(queryCaptor.capture(), isNull());
List<HardOrderDto> queries = queryCaptor.getAllValues();
assertEquals(2, queries.size());
assertEquals(LocalDateTime.of(2026, 8, 23, 8, 30), queries.get(0).getStatisticsDateTimeList()[0]);
assertEquals(LocalDateTime.of(2026, 8, 23, 23, 59, 59), queries.get(0).getStatisticsDateTimeList()[1]);
assertEquals(LocalDateTime.of(2026, 8, 24, 0, 0), queries.get(1).getStatisticsDateTimeList()[0]);
assertEquals(LocalDateTime.of(2026, 8, 24, 12, 15), queries.get(1).getStatisticsDateTimeList()[1]);
}
/**
* 验证异步菜品导出冻结查询快照、按日期推进进度并返回文件中心下载结果。
*
* @throws Exception 执行捕获的异步任务动作失败时抛出
*/
@Test
void productSalesAsyncExportStreamsDateBatchAndReturnsUploadedFile() throws Exception {
HardOrderDto dto = statisticsDto();
dto.setUserDeptIds(new ArrayList<>(List.of(20L)));
mockCanteenScope();
when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of()));
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull()))
.thenReturn(List.of(exportRecord(null, null)));
when(remoteFileService.innerUpload(any(), eq("hard-export"), isNull(),
eq("HARD_CANTEEN_PRODUCT_SALES_EXPORT")))
.thenReturn(R.ok(Map.of("url", "https://file.example/sales.xlsx")));
AsyncTaskProgressVO initialProgress = new AsyncTaskProgressVO();
initialProgress.setTaskId("test-task");
when(asyncTaskExecutorService.submitWithResult(anyString(), anyString(), anyInt(),
any(AsyncTaskResultAction.class))).thenReturn(initialProgress);
service.submitProductSalesExport(dto);
dto.getStatisticsDateTimeList()[0] = LocalDateTime.of(2026, 8, 25, 0, 0);
dto.getUserDeptIds().add(30L);
ArgumentCaptor<AsyncTaskResultAction> actionCaptor = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:product"),
eq("食堂菜品销售统计导出"), eq(1), actionCaptor.capture());
AsyncTaskProgressReporter reporter = mock(AsyncTaskProgressReporter.class);
AsyncTaskResult result = actionCaptor.getValue().execute(reporter);
ArgumentCaptor<HardOrderDto> queryCaptor = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper).listSalesExportRows(queryCaptor.capture(), isNull());
assertEquals(LocalDateTime.of(2026, 8, 24, 0, 0),
queryCaptor.getValue().getStatisticsDateTimeList()[0]);
assertEquals(List.of(20L), queryCaptor.getValue().getUserDeptIds());
assertEquals("https://file.example/sales.xlsx", result.fileUrl());
assertTrue(result.fileName().startsWith("食堂菜品销售统计_"));
verify(reporter).updateTotal(eq(3), contains("1 个日期批次"));
verify(reporter, times(3)).advance(anyString());
}
/**
* 验证部门统计使用独立任务命名空间,避免与菜品统计进度互相覆盖。
*/
@Test
void departmentSalesAsyncExportUsesIndependentTaskNamespace() {
HardOrderDto dto = statisticsDto();
mockCanteenScope();
AsyncTaskProgressVO initialProgress = new AsyncTaskProgressVO();
when(asyncTaskExecutorService.submitWithResult(anyString(), anyString(), anyInt(),
any(AsyncTaskResultAction.class))).thenReturn(initialProgress);
service.submitDepartmentSalesExport(dto);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:department"),
eq("食堂菜品部门统计导出"), eq(1), any(AsyncTaskResultAction.class));
}
/**
* 构造覆盖 2026-08-24 完整自然日的来源统计请求。
*
@@ -243,4 +411,41 @@ class HardCanteenTotalServiceImplTest {
row.setUserDeptId(userDeptId);
return row;
}
/**
* 构造指定来源口径的日期餐段或商户部门导出聚合记录。
*
* @param source 销售来源;为空表示当前筛选范围合计
* @param userDeptId 用户部门主键;为空表示日期餐段汇总层级
* @return 可与商品聚合记录使用相同分组键的导出记录
*/
private HardCanteenProductApptExportRecord exportRecord(HardSaleSourceEnum source, Long userDeptId) {
HardCanteenProductApptExportRecord row = new HardCanteenProductApptExportRecord();
row.setRecordLevel(userDeptId == null ? "SUMMARY" : "MERCHANT");
row.setStatisticsDate(LocalDate.of(2026, 8, 24).toString());
row.setHourType("1");
row.setSaleSource(source == null ? null : source.getCode());
row.setMerchantId(userDeptId == null ? null : 10L);
row.setUserDeptId(userDeptId);
row.setPayUserCount(1L);
row.setTimeCount(1L);
row.setPayCount(1L);
row.setTotalPrice(100L);
return row;
}
/**
* 构造指定来源口径的商品导出聚合记录。
*
* @param source 销售来源;为空表示当前筛选范围合计
* @param productName 商品快照名称
* @return 带食堂、部门和商品键的导出记录
*/
private HardCanteenProductApptExportRecord exportProductRecord(HardSaleSourceEnum source, String productName) {
HardCanteenProductApptExportRecord row = exportRecord(source, 20L);
row.setRecordLevel("PRODUCT");
row.setProductId(source == null ? 100L : source == HardSaleSourceEnum.OFFLINE_PURCHASE ? 101L : 102L);
row.setProductName(productName);
return row;
}
}
@@ -15,6 +15,8 @@ public enum ThirdPaymentTaskTypeEnum {
REFUND_QUERY("REFUND_QUERY"),
/** 普通退款创建结果补偿。 */
REFUND_CREATE("REFUND_CREATE"),
/** 商户微信或支付宝默认平台分佣关系绑定。 */
FZ_RELATION_BIND("FZ_RELATION_BIND"),
/** 微信或支付宝分账创建/阶段推进。 */
FZ_CREATE("FZ_CREATE"),
/** 支付宝直付通确认结算;只推进确认结算阶段。 */
@@ -11,6 +11,11 @@ public class ThirdAppPlusPayDto {
private Long mchId;
/**
* 支付创建前由服务端解析的已绑定平台分佣关系主键;客户端不得提交或覆盖。
*/
private Long fzRelationId;
/**
* 子商户id
*/
@@ -72,6 +72,11 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
@Schema(description = "商户支付配置")
private String mchPaySettingJson;
/**
* 支付创建时固定的平台分佣关系主键,后续分账和退分账必须使用该历史关系快照。
*/
private Long fzRelationId;
/**
* 用户code
*/
@@ -221,4 +226,4 @@ public class ThirdPayOrderEntity extends Model<ThirdPayOrderEntity> {
*/
@Schema(description = "tenantId")
private Long tenantId;
}
}
@@ -31,6 +31,8 @@ public class ThirdPaymentTaskEntity extends Model<ThirdPaymentTaskEntity> {
private Long payOrderId;
/** 关联分账订单主键;非分账及退分账任务允许为空。 */
private Long fzOrderId;
/** 关联商户分账关系主键;仅关系绑定任务必填,禁止从任务键字符串反解析。 */
private Long fzRelationId;
/** 关联聚合支付会话主键;非聚合支付任务允许为空。 */
private Long aggregateSessionId;
/** 平台内稳定支付单号,来源于支付订单;未关联支付订单的任务允许为空。 */
@@ -49,6 +49,16 @@ public class AppPlusPayConfig {
*/
private String merchantId;
/**
* 微信平台分佣接收商户号;为空时使用服务商 {@link #merchantId},不能由商户支付配置覆盖。
*/
private String profitSharingReceiverAccount;
/**
* 微信平台分佣接收方名称;为空时使用平台默认企业名称,仅用于渠道关系和分账请求。
*/
private String profitSharingReceiverName;
/**
* 商户私钥
*/
@@ -1,25 +1,20 @@
package com.bsd.pjcloud.thirdparty.controller.appplus;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -31,8 +26,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 第三方支付内部远程调用入口,统一通过 {@link Inner} 约束服务间访问。
*/
@@ -46,9 +39,9 @@ import java.util.List;
public class ThirdAppPlusController {
/**
* 微信分账服务,用于现有商户配置变更后的接收方同步。
* 商户分账关系服务,用于统一物化微信和支付宝默认平台分佣关系。
*/
private final ThirdAppPlusWechatFzService thirdAppPlusWechatFzService;
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 第三方支付编排服务,承接退款、付款码、订单码和 App 支付请求。
@@ -151,7 +144,7 @@ public class ThirdAppPlusController {
/* ************************************* end 远程调用 end ************************************* */
/**
* 同步商户配置变更到微信分账接收方维护流程。
* 同步商户启用支付配置到微信和支付宝共用的分账关系事实。
*
* @param dto 变更后的商户详情和支付配置
* @return 接收变更请求后返回 {@code true}
@@ -160,19 +153,7 @@ public class ThirdAppPlusController {
@PostMapping("/change-mch")
@Inner
public R<Boolean> remoterChangeMch(@RequestBody MchManageVo dto) {
if (ObjectUtil.isNotEmpty(dto)) {
List<MchPaySettingVo> paySettingList = dto.getPaySettingList();
if (ObjectUtil.isNotEmpty(paySettingList)) {
for (MchPaySettingVo v : paySettingList) {
if (ObjectUtil.equals(v.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType())) {
MchPaySettingEntity.WechatPaySetting jsonSetting = JSONUtil.toBean(v.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (ObjectUtil.isNotEmpty(jsonSetting)) {
thirdAppPlusWechatFzService.addReceiver(jsonSetting.getSubMchid());
}
}
}
}
}
mchFzRelationService.syncDefaultRelations(dto);
return R.ok(true);
}
@@ -165,4 +165,16 @@ public interface ThirdPaymentTaskMapper extends PjcloudBaseMapper<ThirdPaymentTa
* @return 状态允许重试且更新成功为1
*/
int manualRetry(@Param("id") Long id, @Param("nextRunTime") LocalDateTime nextRunTime);
/**
* 配置重新保存后唤醒同一关系的历史终态或退避任务,保留原任务键与关系主键。
*
* @param tenantId 关系所属租户ID
* @param taskKey 稳定关系任务键
* @param nextRunTime 重新执行时间
* @return 任务状态允许重新唤醒且更新成功时为1
*/
@InterceptorIgnore(tenantLine = "true")
int rearmByTaskKey(@Param("tenantId") Long tenantId, @Param("taskKey") String taskKey,
@Param("nextRunTime") LocalDateTime nextRunTime);
}
@@ -38,6 +38,9 @@ public class ThirdAggregatePayCreateContext {
*/
private Long mchId;
/** 创建子订单时固定的平台分佣关系主键;由服务端在平台选择后填充。 */
private Long fzRelationId;
/**
* 应付金额,单位为人民币分。
*/
@@ -24,6 +24,14 @@ public interface ThirdMchFzRelationService {
*/
ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting);
/**
* 按支付订单保存的关系主键读取历史关系快照,供分账和退分账使用。
*
* @param relationId 关系主键
* @return 关系快照;关系不存在时返回 {@code null}
*/
ThirdMchFzRelationEntity getById(Long relationId);
/**
* 执行一条关系绑定任务;重复执行必须使用关系事实中保存的稳定请求号和参数。
*
@@ -19,6 +19,52 @@ public class ThirdPaymentTaskEnqueuer {
/** 支付任务 Mapper。 */
private final ThirdPaymentTaskMapper taskMapper;
/**
* 为商户默认平台分佣关系写入稳定绑定任务;配置重新保存时可唤醒原终态任务。
*
* @param tenantId 关系所属租户ID
* @param relationId 分账关系主键
* @param nextRunTime 最早执行时间
* @param priority 调度优先级
*/
public void enqueueRelationBind(Long tenantId, Long relationId, LocalDateTime nextRunTime, int priority) {
ThirdPaymentTaskEntity task = ensureRelationBind(tenantId, relationId, nextRunTime, priority);
taskMapper.rearmByTaskKey(task.getTenantId(), task.getTaskKey(), task.getNextRunTime());
}
/**
* 确保关系绑定任务存在但不打断既有退避;支付请求只能催生缺失任务,不能用用户流量放大渠道重试。
*
* @param tenantId 关系所属租户ID
* @param relationId 分账关系主键
* @param nextRunTime 最早执行时间
* @param priority 调度优先级
* @return 已构造并尝试写入的稳定任务事实
*/
public ThirdPaymentTaskEntity ensureRelationBind(Long tenantId, Long relationId, LocalDateTime nextRunTime, int priority) {
tenantId = tenantId == null ? TenantContextHolder.getTenantId() : tenantId;
if (tenantId == null || relationId == null) {
throw new IllegalArgumentException("分账关系任务租户和关系主键不能为空");
}
LocalDateTime now = LocalDateTime.now();
String taskKey = "FZ_RELATION_BIND:" + relationId;
ThirdPaymentTaskEntity task = new ThirdPaymentTaskEntity();
task.setId(IdWorker.getId());
task.setTenantId(tenantId);
task.setTaskType(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND.getCode());
task.setTaskKey(taskKey);
task.setFzRelationId(relationId);
task.setStatus("READY");
task.setNextRunTime(nextRunTime == null ? now : nextRunTime);
task.setPriority(priority);
task.setAttemptCount(0);
task.setCreateTime(now);
task.setUpdateTime(now);
task.setDelFlag("0");
taskMapper.insertIgnore(task);
return task;
}
/**
* 为支付订单写入查单任务;唯一键冲突时保持既有任务,不重新生成支付号。
* @param tenantId 租户ID
@@ -10,12 +10,14 @@ import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.domain.SubMerchant;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
@@ -138,6 +140,7 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity) {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting);
validateFzRelation(context, paySetting);
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
AlipayTradeCreateModel model = new AlipayTradeCreateModel();
@@ -146,6 +149,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
model.setSubject(SUBJECT);
model.setProductCode(PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN));
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
if (StrUtil.isNotBlank(identity.getOpenId())) {
model.setBuyerOpenId(identity.getOpenId());
} else {
@@ -274,6 +280,9 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0
? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
@@ -309,9 +318,28 @@ public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayServ
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (setting == null || StrUtil.isBlank(setting.getSubMchid())) {
throw new RuntimeException("商户支付宝直付通二级商户标识未配置");
}
return setting;
}
/** 聚合支付底层再次校验分佣关系,防止绕过编排层直接创建无关系直付通订单。 */
private void validateFzRelation(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) {
if (paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) {
if (paySetting.getFzRatio() != null && (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() < 0)) {
throw new RuntimeException("支付宝平台抽佣比例不能为负数");
}
return;
}
if (!Double.isFinite(paySetting.getFzRatio()) || paySetting.getFzRatio() > 100) {
throw new RuntimeException("支付宝平台抽佣比例不能超过100%");
}
if (context == null || context.getFzRelationId() == null) {
throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定");
}
}
/**
* 将整数人民币分精确转换为两位小数人民币元字符串。
*
@@ -22,6 +22,7 @@ import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
@@ -189,6 +190,9 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
*/
private final ThirdPayOrderService thirdPayOrderService;
/** 聚合平台选择后验证并固定默认平台分佣关系。 */
private final ThirdMchFzRelationService mchFzRelationService;
/**
* 钱包商户远程服务,用于读取当前租户收款商户及启用支付配置。
*/
@@ -569,9 +573,13 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
try {
ThirdAggregateCashierLaunch launch;
if (ObjectUtil.equals(platform, AppPlusPlatformTypeEnum.ALI_PAY)) {
launch = aggregateAliPayService.createPay(context, getRequiredAliPaySetting(merchant), alipayIdentity);
MchPaySettingVo setting = getRequiredAliPaySetting(merchant);
context.setFzRelationId(resolveFzRelation(setting));
launch = aggregateAliPayService.createPay(context, setting, alipayIdentity);
} else {
launch = aggregateWechatPayService.createPay(context, getRequiredWechatPaySetting(merchant), payerIdentity);
MchPaySettingVo setting = getRequiredWechatPaySetting(merchant);
context.setFzRelationId(resolveFzRelation(setting));
launch = aggregateWechatPayService.createPay(context, setting, payerIdentity);
}
if (!aggregatePaySessionService.markPaying(session.getId(), payNo)) {
ThirdPayOrderEntity latestPayOrder = thirdPayOrderService.getByPayNo(payNo);
@@ -1043,7 +1051,7 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
}
/**
* 选择已启用且包含 app_auth_token 的支付宝商户配置。
* 选择已启用且包含直付通二级商户标识的支付宝商户配置;app_auth_token 可为空。
*
* @param merchant 收款商户详情
* @return 当前商户支付宝配置
@@ -1057,8 +1065,8 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
throw new RuntimeException("商户支付宝支付配置未启用");
}
MchPaySettingEntity.AliPaySetting detail = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (detail == null || StrUtil.isBlank(detail.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
if (detail == null || StrUtil.isBlank(detail.getSubMchid())) {
throw new RuntimeException("商户支付宝直付通二级商户标识未配置");
}
return setting;
}
@@ -1205,6 +1213,17 @@ public class ThirdAggregateOrderPayServiceImpl implements ThirdAggregateOrderPay
return context;
}
/**
* 聚合支付在选定平台后解析默认平台分佣关系;多平台会话不会合并不同商户关系。
*
* @param setting 已确认启用的支付配置
* @return 绑定关系主键;抽佣比例未启用时为空
*/
private Long resolveFzRelation(MchPaySettingVo setting) {
ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(setting);
return relation == null ? null : relation.getId();
}
/**
* 获取聚合二维码和状态查询共用的公网基础地址。
*
@@ -23,6 +23,7 @@ import com.wechat.pay.java.service.partnerpayments.jsapi.model.Payer;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.SettleInfo;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -161,6 +162,13 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
request.setPayer(payer);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0;
if (enableFz && context.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
payOrder.setPayRep(buildRequestSnapshot(context, wechatPaySetting).toString());
PrepayWithRequestPaymentResponse response;
@@ -286,6 +294,10 @@ public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatP
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setFzRelationId(context.getFzRelationId());
payOrder.setEnableFz(paySetting.getFzRatio() != null && paySetting.getFzRatio() > 0
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
@@ -54,6 +54,7 @@ import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
@@ -62,6 +63,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentOrderReservationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -157,6 +159,10 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
@Autowired(required = false)
private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单创建时固定的平台分佣关系快照。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** 支付订单与首任务预留事务;可选注入仅用于兼容既有纯单元测试构造方式。 */
@Autowired(required = false)
private ThirdPaymentOrderReservationService paymentOrderReservationService;
@@ -384,6 +390,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
model.setSubject(ORDER_CODE_SUBJECT);
model.setProductCode(ORDER_CODE_PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN));
SubMerchant subMerchant = new SubMerchant();
subMerchant.setMerchantId(aliPaySetting.getSubMchid());
model.setSubMerchant(subMerchant);
payOrder.setPayRep(JSONUtil.toJsonStr(model));
request.setBizModel(model);
@@ -468,14 +477,18 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setExpireTime(now.plusMinutes(15));
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
if (setting.getFzRatio() == null || setting.getFzRatio() <= 0 || setting.getFzRatio() > 100) {
throw new RuntimeException("支付宝直付通平台抽佣比例必须大于0且不超过100%");
}
payOrder.setFzRelationId(dto.getFzRelationId());
validateFzRatioAndRelation(dto, setting);
if (dto.getAmount() == null || dto.getAmount() <= 10) {
throw new RuntimeException("支付宝直付通支付金额必须大于10分");
}
calculateFzAmount(dto.getAmount(), setting.getFzRatio());
payOrder.setEnableFz(com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode());
boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0;
if (enableFz) {
calculateFzAmount(dto.getAmount(), setting.getFzRatio());
}
payOrder.setEnableFz(enableFz
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest();
request.setNotifyUrl(resolvePayNotifyUrl(aliPaySetting));
@@ -563,19 +576,20 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
: thirdFzOrderService.getByPayNo(thirdPayOrder.getPayNo());
if (fzOrder != null && ObjectUtil.equals(fzOrder.getStatus(), ThirdFzOrderStatusEnum.FZ_SUCCESS.getType())
&& fzOrder.getPrice() != null && fzOrder.getPrice() > 0) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == null || StrUtil.hasBlank(config.getRoyaltyTransIn(), config.getRoyaltyTransInType())) {
throw new RuntimeException("平台支付宝抽佣接收方未配置,无法退分账");
}
validateRoyaltyAccountType(config.getRoyaltyTransInType());
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(thirdPayOrder.getFzRelationId());
requireDirectPayRelation(relation, thirdPayOrder, aliPaySetting);
String receiverAccount = relation.getReceiverAccount();
String receiverType = relation.getReceiverType();
validateRoyaltyAccountType(receiverType);
int royaltyRefundAmount = BigDecimal.valueOf(fzOrder.getPrice())
.multiply(BigDecimal.valueOf(refundFee))
.divide(BigDecimal.valueOf(thirdPayOrder.getPrice()), 0, RoundingMode.DOWN).intValue();
if (royaltyRefundAmount > 0) {
OpenApiRoyaltyDetailInfoPojo refundRoyalty = new OpenApiRoyaltyDetailInfoPojo();
refundRoyalty.setRoyaltyType("transfer");
refundRoyalty.setTransOut(config.getRoyaltyTransIn());
refundRoyalty.setTransOutType(config.getRoyaltyTransInType());
refundRoyalty.setTransOut(receiverAccount);
refundRoyalty.setTransOutType(receiverType);
refundRoyalty.setAmount(fenToYuan(BigDecimal.valueOf(royaltyRefundAmount)));
refundRoyalty.setDesc("平台服务费退款");
model.setRefundRoyaltyParameters(List.of(refundRoyalty));
@@ -1197,14 +1211,19 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
MchPaySettingEntity.AliPaySetting aliPaySetting, ThirdFzOrderEntity fzOrder) {
JSONObject stage = JSONUtil.createObj();
String outRequestNo = "royalty_" + payOrder.getPayNo();
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (config == null || StrUtil.hasBlank(config.getRoyaltyTransIn(), config.getRoyaltyTransInType())) {
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null : mchFzRelationService.getById(payOrder.getFzRelationId());
try {
requireDirectPayRelation(relation, payOrder, aliPaySetting);
} catch (RuntimeException exception) {
stage.set("status", "FAILED");
stage.set("error", "平台支付宝抽佣接收方未配置");
stage.set("error", exception.getMessage());
return stage;
}
String receiverAccount = relation.getReceiverAccount();
String receiverType = relation.getReceiverType();
String receiverName = relation.getReceiverName();
try {
validateRoyaltyAccountType(config.getRoyaltyTransInType());
validateRoyaltyAccountType(receiverType);
} catch (RuntimeException exception) {
stage.set("status", "FAILED");
stage.set("error", exception.getMessage());
@@ -1212,9 +1231,9 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
OpenApiRoyaltyDetailInfoPojo detail = new OpenApiRoyaltyDetailInfoPojo();
detail.setRoyaltyType("transfer");
detail.setTransIn(config.getRoyaltyTransIn());
detail.setTransInType(config.getRoyaltyTransInType());
detail.setTransInName(config.getRoyaltyTransInName());
detail.setTransIn(receiverAccount);
detail.setTransInType(receiverType);
detail.setTransInName(receiverName);
detail.setAmount(fenToYuan(BigDecimal.valueOf(fzOrder.getPrice())));
detail.setDesc("平台服务费");
AlipayTradeOrderSettleModel model = new AlipayTradeOrderSettleModel();
@@ -1258,6 +1277,31 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
}
/**
* 校验支付宝支付订单固定的直付通关系;历史关系允许继续退分账,但禁止回退当前可变配置。
*
* @param relation 支付创建时保存的关系快照
* @param payOrder 关系所属支付订单
* @param aliPaySetting 支付订单创建时保存的支付宝配置快照
*/
private void requireDirectPayRelation(ThirdMchFzRelationEntity relation,
ThirdPayOrderEntity payOrder,
MchPaySettingEntity.AliPaySetting aliPaySetting) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(aliPaySetting);
if (relation == null || !"0".equals(relation.getDelFlag())
|| relation.getBoundTime() == null
|| payOrder == null
|| !ObjectUtil.equals(relation.getMchId(), payOrder.getMchId())
|| !ObjectUtil.equals(relation.getPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| config == null
|| !ObjectUtil.equals(relation.getChannelAppId(), config.getAppId())
|| !ObjectUtil.equals(relation.getChannelSubMchId(), aliPaySetting == null ? null : aliPaySetting.getSubMchid())
|| StrUtil.hasBlank(relation.getReceiverAccount(), relation.getReceiverType())) {
throw new IllegalStateException("支付宝支付订单的固定直付通分佣关系不存在或与二级商户不一致");
}
validateRoyaltyAccountType(relation.getReceiverType());
}
/**
* 将平台抽佣响应映射为本地阶段状态,结算状态传播和限频按可重试处理。
*
@@ -1611,6 +1655,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
* @return 可直接持久化的待支付订单
*/
private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
validateFzRatioAndRelation(dto, setting);
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
payOrder.setBizId(dto.getBizId());
@@ -1622,10 +1667,27 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setExpireTime(expireTime);
return payOrder;
}
/** 支付宝支付底层统一校验抽佣比例与已绑定关系,避免内部调用绕过上层编排。 */
private void validateFzRatioAndRelation(ThirdAppPlusPayDto dto, MchPaySettingVo setting) {
if (setting == null || setting.getFzRatio() == null || setting.getFzRatio() == 0) {
return;
}
if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) {
throw new RuntimeException("支付宝直付通平台抽佣比例必须大于等于0且不超过100%");
}
if (dto == null || dto.getFzRelationId() == null) {
throw new RuntimeException("支付宝直付通平台分佣关系尚未绑定");
}
}
/**
* 解析当前商户支付宝配置;授权令牌允许为空,账号选择和直付通标识由具体调用继续校验。
*
@@ -65,6 +65,7 @@ import com.wechat.pay.java.service.refund.model.RefundNotification;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@@ -233,6 +234,10 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
*/
private final RemoteHardOrderService remoteHardOrderService;
/** 支付创建前验证并固定商户平台分佣关系。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 小程序登录
*/
@@ -410,6 +415,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 微信小程序
if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
MchPaySettingVo paySetting = getEnabledWechatPaySetting(mchManage);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid());
@@ -428,6 +434,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
// 支付宝
else if (ObjectUtil.equals(dto.getMiniPlatformType().getType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(mchManage);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid());
@@ -590,6 +597,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
throw new RuntimeException("商户未配置微信支付参数");
}
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
appPlusPayDto.setSubMchid(wechatPaySetting.getSubMchid());
String wxOutTradeNo = thirdAppPlusWechatMicropayService.micropay(appPlusPayDto, paySetting);
@@ -604,6 +612,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
throw new RuntimeException("商户未配置微信支付参数");
}
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
appPlusPayDto.setFzRelationId(resolveFzRelation(paySetting));
appPlusPayDto.setSubMchid(aliPaySetting.getSubMchid());
}
// 不支持的支付类型
@@ -730,10 +739,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(ORDER_CODE_EXPIRE_MINUTES);
if (ObjectUtil.equals(dto.getMiniPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(merchant);
payDto.setFzRelationId(resolveFzRelation(paySetting));
validateAliPayApplicationConfig();
return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime);
}
MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant);
payDto.setFzRelationId(resolveFzRelation(paySetting));
validateWechatApplicationConfig();
return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime);
}
@@ -741,6 +752,25 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
/**
* 校验订单码支付首版允许的业务、平台、金额和商户参数。
*/
/**
* 解析支付配置对应的已绑定平台分佣关系,未启用抽佣时不写关系主键。
*
* @param paySetting 已由商户服务确认启用的支付配置
* @return 已绑定关系主键;平台抽佣比例为零时为空
*/
private Long resolveFzRelation(MchPaySettingVo paySetting) {
if (mchFzRelationService == null) {
// 独立单元测试或历史单体启动未装载关系模块时,不改变无分佣配置的既有支付路径;
// 生产容器必须提供关系服务,启用抽佣时由其严格拒绝未绑定支付。
if (paySetting == null || paySetting.getFzRatio() == null || paySetting.getFzRatio() <= 0) {
return null;
}
throw new IllegalStateException("支付平台分佣关系服务不可用");
}
com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity relation = mchFzRelationService.requireBound(paySetting);
return relation == null ? null : relation.getId();
}
private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) {
if (dto == null) {
throw new RuntimeException("订单码支付参数不能为空");
@@ -71,6 +71,7 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setIsBizCallback(YesNoEnum.YES.getCode());
WxPayMicropayRequest request = new WxPayMicropayRequest();
@@ -87,6 +88,9 @@ public class ThirdAppPlusWechatMicropayServiceImpl implements ThirdAppPlusWechat
request.setProfitSharing("N");
// 是否需要分账
if (dto.getAmount() > 10 && ObjectUtil.isNotEmpty(setting) && ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) {
if (dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
}
request.setProfitSharing("Y");
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -19,6 +19,7 @@ import com.wechat.pay.java.service.partnerpayments.nativepay.model.Amount;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.SettleInfo;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction;
import lombok.RequiredArgsConstructor;
@@ -98,6 +99,13 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
request.setTimeExpire(formatWechatTime(expireTime));
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
SettleInfo settleInfo = new SettleInfo();
boolean enableFz = setting.getFzRatio() != null && setting.getFzRatio() > 0;
if (enableFz && dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
}
settleInfo.setProfitSharing(enableFz);
request.setSettleInfo(settleInfo);
payOrder.setPayRep(JSONUtil.toJsonStr(request));
PrepayResponse response;
@@ -200,6 +208,10 @@ public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWecha
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
payOrder.setEnableFz(setting.getFzRatio() != null && setting.getFzRatio() > 0
? com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.YES.getCode()
: com.bsd.pjcloud.common.core.constant.enums.YesNoEnum.NO.getCode());
payOrder.setExpireTime(expireTime);
return payOrder;
}
@@ -119,6 +119,7 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
payOrder.setExpireTime(expireTime);
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setFzRelationId(dto.getFzRelationId());
try {
String description = "饭卡钱包充值" + fenToYuan(BigDecimal.valueOf(dto.getAmount())) + "元";
// 支付金额
@@ -130,6 +131,9 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
settleInfo.setProfitSharing(false);
// 是否需要分账
if (ObjectUtil.isNotEmpty(setting.getFzRatio()) && setting.getFzRatio() > 0) {
if (dto.getFzRelationId() == null) {
throw new RuntimeException("微信平台分佣关系尚未绑定");
}
settleInfo.setProfitSharing(true);
payOrder.setEnableFz(YesNoEnum.YES.getCode());
} else {
@@ -8,8 +8,10 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
@@ -17,6 +19,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.profitsharing.model.*;
@@ -56,6 +59,10 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
*/
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单创建时固定的平台分佣关系快照。 */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/**
* 发起分账
*/
@@ -100,6 +107,11 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
fzOrderEntity.setStatus(ThirdFzOrderStatusEnum.FZ_UNPAID.getType());
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(orderEntity.getFzRelationId());
requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), wechatConfig);
String receiverAccount = relation.getReceiverAccount();
String receiverName = StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司");
// 最少分账1分
Long amount = BigDecimal.valueOf(orderEntity.getPrice())
@@ -120,8 +132,8 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
createDto.setOutOrderNo(outOrderNo);
CreateOrderReceiver zaxReceiver = new CreateOrderReceiver();
zaxReceiver.setType("MERCHANT_ID");
zaxReceiver.setAccount(wechatConfig.getMerchantId());
zaxReceiver.setName("浙江资安信信息科技有限公司");
zaxReceiver.setAccount(receiverAccount);
zaxReceiver.setName(receiverName);
zaxReceiver.setAmount(amount);
zaxReceiver.setDescription(getDescription(mchPaySetting, amount));
createDto.setReceivers(List.of(zaxReceiver));
@@ -178,10 +190,11 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
return null;
}
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
MchPaySettingEntity mchPaySetting = JSONUtil.toBean(orderEntity.getMchPaySettingJson(), MchPaySettingEntity.class);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(mchPaySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
ThirdMchFzRelationEntity relation = mchFzRelationService == null ? null
: mchFzRelationService.getById(orderEntity.getFzRelationId());
requireWechatRelation(relation, orderEntity, wechatPaySetting.getSubMchid(), appPlusPayConfig.getWechatConfig());
String outOrderNo = "refund_" + fzOrderDetail.getFzNo();
@@ -189,7 +202,7 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
returnDto.setSubMchid(wechatPaySetting.getSubMchid());
returnDto.setOutOrderNo(fzOrderDetail.getFzNo());
returnDto.setOutReturnNo(outOrderNo);
returnDto.setReturnMchid(wechatConfig.getMerchantId());
returnDto.setReturnMchid(relation.getReceiverAccount());
returnDto.setAmount((long) fzOrderDetail.getPrice());
returnDto.setDescription("退回" + getDescription(mchPaySetting, returnDto.getAmount()));
@@ -439,6 +452,30 @@ public class ThirdAppPlusWechatFzServiceImpl implements ThirdAppPlusWechatFzServ
}
}
/**
* 校验支付订单固定的微信分账关系;历史关系允许为 SUPERSEDED,但不能缺失、软删除或指向其他子商户。
*
* @param relation 支付创建时保存的关系快照
* @param payOrder 关系所属支付订单
* @param subMchid 支付订单快照中的微信子商户号
* @param wechatConfig 当前服务商调用配置;AppID 必须与历史关系一致
*/
private void requireWechatRelation(ThirdMchFzRelationEntity relation, ThirdPayOrderEntity payOrder,
String subMchid, AppPlusPayConfig.WechatConfig wechatConfig) {
if (relation == null || !"0".equals(relation.getDelFlag())
|| relation.getBoundTime() == null
|| payOrder == null
|| !ObjectUtil.equals(relation.getMchId(), payOrder.getMchId())
|| !ObjectUtil.equals(relation.getPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())
|| wechatConfig == null
|| !ObjectUtil.equals(relation.getChannelAppId(), wechatConfig.getAppPlatformAppId())
|| !ObjectUtil.equals(relation.getChannelSubMchId(), subMchid)
|| !ObjectUtil.equals(relation.getReceiverType(), ReceiverType.MERCHANT_ID.name())
|| StrUtil.isBlank(relation.getReceiverAccount())) {
throw new IllegalStateException("微信支付订单的固定分账关系不存在或与子商户不一致");
}
}
/**
* 合并微信平台抽佣阶段快照,保留首次请求并持续更新最近查询时间、外部状态和错误。
*
@@ -3,6 +3,7 @@ package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeRoyaltyRelationBatchqueryModel;
@@ -16,6 +17,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdMchFzRelationSceneEnum;
@@ -26,9 +28,10 @@ import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdMchFzRelationMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.github.yulichang.toolkit.MPJReflectionKit;
import com.wechat.pay.java.core.exception.ServiceException;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.profitsharing.model.AddReceiverRequest;
import com.wechat.pay.java.service.profitsharing.model.AddReceiverResponse;
@@ -38,6 +41,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -72,6 +76,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
private final ProfitsharingService profitsharingService;
/** 应用级支付宝、微信平台配置。 */
private final AppPlusPayConfig appPlusPayConfig;
/** 钱包商户内部接口,用于 Worker 执行时读取当前支付宝授权 token。 */
private final RemoteMchService remoteMchService;
/**
* 物化当前商户启用且配置抽佣比例的默认关系,并把旧配置标记为历史关系。
@@ -92,6 +98,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
? List.of() : merchant.getPaySettingList();
List<Long> currentSettingIds = new ArrayList<>();
for (MchPaySettingVo setting : settings) {
validateFzRatio(setting);
if (!isEligible(setting)) {
continue;
}
@@ -108,8 +115,28 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (existing == null) {
relationMapper.insert(relation);
existing = relation;
try {
relationMapper.insert(relation);
existing = relation;
} catch (DuplicateKeyException ignored) {
// 多个钱包实例并发保存同一配置时,数据库唯一键选出唯一关系事实,失败方复用胜出记录。
existing = relationMapper.selectOne(new LambdaQueryWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getPlatformType, relation.getPlatformType())
.eq(ThirdMchFzRelationEntity::getChannelAppId, relation.getChannelAppId())
.eq(ThirdMchFzRelationEntity::getChannelSubMchId, relation.getChannelSubMchId())
.eq(ThirdMchFzRelationEntity::getReceiverType, relation.getReceiverType())
.eq(ThirdMchFzRelationEntity::getReceiverAccount, relation.getReceiverAccount())
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (existing == null) {
throw new IllegalStateException("并发创建平台分佣关系后未找到关系事实");
}
restoreCurrentRelation(existing);
}
} else {
restoreCurrentRelation(existing);
}
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
@@ -119,11 +146,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
.eq(ThirdMchFzRelationEntity::getDelFlag, "0")
.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()));
if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus())
&& !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(existing.getStatus())) {
taskEnqueuer.enqueueAt(tenantId, com.bsd.pjcloud.thirdparty.constant.pay.ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND,
"FZ_RELATION_BIND:" + existing.getId(), null, null, null, null, null,
LocalDateTime.now(), RELATION_TASK_PRIORITY);
if (!ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(existing.getStatus())) {
taskEnqueuer.enqueueRelationBind(tenantId, existing.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY);
}
}
// 配置被禁用、删除或改成其他平台账号时,旧关系只停止接新单,不删除渠道事实。
@@ -140,6 +164,22 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
relationMapper.update(null, stale);
}
/** 把复用的历史同身份关系恢复为当前默认关系;已确认绑定的关系不得重新降级为待绑定。 */
private void restoreCurrentRelation(ThirdMchFzRelationEntity relation) {
String restoredStatus = relation.getBoundTime() != null
? ThirdMchFzRelationStatusEnum.BOUND.getCode()
: ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(relation.getStatus())
? ThirdMchFzRelationStatusEnum.BINDING.getCode()
: ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode();
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relation.getId())
.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
.set(ThirdMchFzRelationEntity::getStatus, restoredStatus)
.set(ThirdMchFzRelationEntity::getBindError, null));
relation.setIsDefault(YesNoEnum.YES.getCode());
relation.setStatus(restoredStatus);
}
/**
* 校验支付配置当前默认关系已明确绑定。
*
@@ -147,7 +187,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
* @return 已绑定关系;无抽佣比例时为空
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ThirdMchFzRelationEntity requireBound(MchPaySettingVo setting) {
validateFzRatio(setting);
if (!isEligible(setting)) {
return null;
}
@@ -167,11 +209,75 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
.eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BOUND.getCode())
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (relation == null) {
ThirdMchFzRelationEntity pending = relationMapper.selectOne(new LambdaQueryWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId())
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType())
.eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId())
.eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId())
.eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType())
.eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount())
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
if (pending == null) {
try {
relationMapper.insert(expected);
pending = expected;
} catch (DuplicateKeyException ignored) {
pending = relationMapper.selectOne(new LambdaQueryWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getMchId, setting.getMchId())
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getPlatformType, expected.getPlatformType())
.eq(ThirdMchFzRelationEntity::getChannelAppId, expected.getChannelAppId())
.eq(ThirdMchFzRelationEntity::getChannelSubMchId, expected.getChannelSubMchId())
.eq(ThirdMchFzRelationEntity::getReceiverType, expected.getReceiverType())
.eq(ThirdMchFzRelationEntity::getReceiverAccount, expected.getReceiverAccount())
.eq(ThirdMchFzRelationEntity::getRelationScene, DEFAULT_SCENE)
.eq(ThirdMchFzRelationEntity::getDelFlag, "0"));
}
}
if (pending != null) {
boolean restored = !YesNoEnum.YES.getCode().equals(pending.getIsDefault())
|| ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(pending.getStatus());
if (restored) {
// 钱包本地事务与跨服务同步之间发生回滚时,按当前配置精确恢复同一渠道身份,避免永久拒付。
restoreCurrentRelation(pending);
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getTenantId, tenantId)
.eq(ThirdMchFzRelationEntity::getPaySettingId, setting.getId())
.eq(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.YES.getCode())
.ne(ThirdMchFzRelationEntity::getId, pending.getId())
.eq(ThirdMchFzRelationEntity::getDelFlag, "0")
.set(ThirdMchFzRelationEntity::getIsDefault, YesNoEnum.NO.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode()));
}
if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(pending.getStatus())) {
return pending;
}
if (restored) {
taskEnqueuer.enqueueRelationBind(tenantId, pending.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY);
} else {
taskEnqueuer.ensureRelationBind(tenantId, pending.getId(), LocalDateTime.now(), RELATION_TASK_PRIORITY);
}
}
throw new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试");
}
return relation;
}
/**
* 读取关系历史快照,不按当前支付配置重新推导接收账号。
*
* @param relationId 关系主键
* @return 关系快照
*/
@Override
public ThirdMchFzRelationEntity getById(Long relationId) {
return relationId == null ? null : relationMapper.selectById(relationId);
}
/**
* 执行关系绑定并保存脱敏快照;异常或渠道未明确成功时返回失败,交由任务状态机重试。
*
@@ -179,11 +285,11 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
* @return 关系已绑定
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean bind(Long relationId) {
ThirdMchFzRelationEntity relation = relationMapper.selectById(relationId);
if (relation == null || !"0".equals(relation.getDelFlag())) {
return false;
if (relation == null || !"0".equals(relation.getDelFlag())
|| ThirdMchFzRelationStatusEnum.SUPERSEDED.getCode().equals(relation.getStatus())) {
return true;
}
if (ThirdMchFzRelationStatusEnum.BOUND.getCode().equals(relation.getStatus())) {
return true;
@@ -191,8 +297,8 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relationId)
.in(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.PENDING_BIND.getCode(),
ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING)
ThirdMchFzRelationStatusEnum.BIND_FAILED.getCode(), ThirdMchFzRelationStatusEnum.BINDING.getCode())
.set(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
.set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
relation = relationMapper.selectById(relationId);
if (relation == null || !ThirdMchFzRelationStatusEnum.BINDING.getCode().equals(relation.getStatus())) {
@@ -206,6 +312,9 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
return bindAlipay(relation);
}
return markFailed(relation, "UNSUPPORTED_PLATFORM", "不支持的支付平台");
} catch (RetryableRelationException exception) {
markRetry(relation, exception.getCode(), exception.getMessage());
throw exception;
} catch (Exception exception) {
log.warn("绑定商户平台分佣关系异常,relationId:{},type:{},exceptionType:{}", relationId,
relation.getPlatformType(), exception.getClass().getSimpleName());
@@ -215,9 +324,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
/** 构建关系事实。 */
private ThirdMchFzRelationEntity buildRelation(Long tenantId, MchManageVo merchant, MchPaySettingVo setting) {
ThirdMchFzRelationEntity result = buildRelation(tenantId, setting.getMchId(), setting);
result.setReceiverName("浙江资安信信息科技有限公司");
return result;
return buildRelation(tenantId, merchant.getId(), setting);
}
/** 按支付配置解析渠道关系身份和平台默认接收方。 */
@@ -240,15 +347,18 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
if (WECHAT_TYPE.equals(setting.getPlatformType())) {
AppPlusPayConfig.WechatConfig config = requireWechatConfig();
MchPaySettingEntity.WechatPaySetting paySetting = parseWechat(setting);
String receiverAccount = StrUtil.blankToDefault(config.getProfitSharingReceiverAccount(), config.getMerchantId());
String receiverName = StrUtil.blankToDefault(config.getProfitSharingReceiverName(), "浙江资安信信息科技有限公司");
result.setChannelAppId(config.getAppPlatformAppId());
result.setChannelSubMchId(paySetting.getSubMchid());
result.setReceiverType(ReceiverType.MERCHANT_ID.name());
result.setReceiverAccount(config.getMerchantId());
result.setReceiverAccount(receiverAccount);
result.setReceiverName(receiverName);
result.setChannelRelationType(ReceiverRelationType.SERVICE_PROVIDER.name());
result.setOutRequestNo("wxrel_" + setting.getId() + "_" + paySetting.getSubMchid());
result.setOutRequestNo("wxrel_" + result.getId());
result.setBindReq(JSONUtil.createObj().set("platform", "WECHAT").set("appid", config.getAppPlatformAppId())
.set("subMchid", paySetting.getSubMchid()).set("receiverType", ReceiverType.MERCHANT_ID.name())
.set("receiverAccount", mask(paySetting(config.getMerchantId()))).toString());
.set("receiverAccount", mask(receiverAccount)).toString());
} else if (ALIPAY_TYPE.equals(setting.getPlatformType())) {
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(setting);
MchPaySettingEntity.AliPaySetting paySetting = parseAliPay(setting);
@@ -266,7 +376,7 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
result.setReceiverType(receiverType);
result.setReceiverAccount(receiverAccount);
result.setReceiverName(config.getRoyaltyTransInName());
result.setOutRequestNo("alirel_" + setting.getId() + "_" + paySetting.getSubMchid());
result.setOutRequestNo("alirel_" + result.getId());
result.setBindReq(JSONUtil.createObj().set("platform", "ALIPAY").set("appId", config.getAppId())
.set("smid", paySetting.getSubMchid()).set("receiverType", receiverType)
.set("receiverAccount", mask(receiverAccount)).set("appAuthTokenConfigured", StrUtil.isNotBlank(paySetting.getAppAuthToken())).toString());
@@ -283,6 +393,18 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
&& setting.getFzRatio() != null && setting.getFzRatio() > 0 && setting.getFzRatio() <= 100;
}
/** 校验已启用微信/支付宝配置的抽佣比例;零值表示关闭抽佣,负数或大于100属于配置错误。 */
private void validateFzRatio(MchPaySettingVo setting) {
if (setting == null || !YesNoEnum.YES.getCode().equals(setting.getEnable())
|| !List.of(WECHAT_TYPE, ALIPAY_TYPE).contains(setting.getPlatformType())
|| setting.getFzRatio() == null) {
return;
}
if (!Double.isFinite(setting.getFzRatio()) || setting.getFzRatio() < 0 || setting.getFzRatio() > 100) {
throw new IllegalArgumentException("平台抽佣比例必须大于等于0且不超过100%");
}
}
/** 微信关系绑定。 */
private boolean bindWechat(ThirdMchFzRelationEntity relation) {
AddReceiverRequest request = new AddReceiverRequest();
@@ -292,18 +414,36 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
request.setAccount(relation.getReceiverAccount());
request.setName(StrUtil.blankToDefault(relation.getReceiverName(), "浙江资安信信息科技有限公司"));
request.setRelationType(ReceiverRelationType.SERVICE_PROVIDER);
AddReceiverResponse response = profitsharingService.addReceiver(request);
if (response == null) {
return markFailed(relation, "EMPTY_RESPONSE", "微信未返回关系绑定结果");
try {
AddReceiverResponse response = profitsharingService.addReceiver(request);
if (response == null) {
throw new RetryableRelationException("EMPTY_RESPONSE", "微信未返回关系绑定结果");
}
if (!wechatResponseMatches(response, relation)) {
return markFailed(relation, "RESPONSE_MISMATCH", "微信返回的分账关系与请求不一致");
}
return markBound(relation, wechatResponseSnapshot(response), "SUCCESS");
} catch (ServiceException exception) {
String errorCode = StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN");
if (List.of("PARAM_ERROR", "INVALID_REQUEST", "NO_AUTH").contains(errorCode)) {
return markFailed(relation, errorCode, "微信分账关系配置或权限无效");
}
throw new RetryableRelationException(errorCode, "微信分账关系绑定结果尚未确认", exception);
} catch (RetryableRelationException exception) {
throw exception;
} catch (Exception exception) {
throw new RetryableRelationException("WECHAT_SDK_EXCEPTION", "微信分账关系绑定结果尚未确认", exception);
}
return markBound(relation, JSONUtil.toJsonStr(response), response.getAccount());
}
/** 支付宝关系绑定。 */
private boolean bindAlipay(ThirdMchFzRelationEntity relation) throws Exception {
MchPaySettingEntity.AliPaySetting paySetting = new MchPaySettingEntity.AliPaySetting();
paySetting.setAccountKey(relation.getAccountKey());
MchPaySettingEntity.AliPaySetting paySetting = loadAliPaySetting(relation);
AppPlusPayConfig.AliPayConfig config = resolveAliPayConfig(paySetting);
if (!Objects.equals(relation.getChannelAppId(), config.getAppId())
|| !Objects.equals(relation.getChannelSubMchId(), paySetting.getSubMchid())) {
return markFailed(relation, "CONFIG_CHANGED", "支付宝平台账号或直付通二级商户标识已变化");
}
AlipayTradeRoyaltyRelationBindModel model = new AlipayTradeRoyaltyRelationBindModel();
model.setOutRequestNo(relation.getOutRequestNo());
RoyaltyEntity receiver = new RoyaltyEntity();
@@ -321,17 +461,35 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
if (StrUtil.isNotBlank(paySetting.getAppAuthToken())) {
request.putOtherTextParam("app_auth_token", paySetting.getAppAuthToken());
}
AlipayTradeRoyaltyRelationBindResponse response = client(config).execute(request);
if (response != null && response.isSuccess()) {
return markBound(relation, JSONUtil.toJsonStr(response), response.getResultCode());
AlipayTradeRoyaltyRelationBindResponse response;
try {
response = client(config).execute(request);
} catch (Exception exception) {
AlipayTradeRoyaltyRelationBatchqueryResponse queried = tryQueryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken());
if (hasAlipayReceiver(queried, relation)) {
return markBound(relation, alipayQuerySnapshot(queried), "QUERY_BOUND");
}
throw new RetryableRelationException("ALIPAY_SDK_EXCEPTION", "支付宝分账关系绑定结果尚未确认", exception);
}
if (isAlipayBindSuccess(response)) {
return markBound(relation, alipayBindSnapshot(response), response.getResultCode());
}
// 绑定响应丢失时用相同 out_request_no 查询,避免重复建立关系或误判失败。
AlipayTradeRoyaltyRelationBatchqueryResponse queried = queryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken());
if (queried != null && queried.isSuccess() && queried.getReceiverList() != null && !queried.getReceiverList().isEmpty()) {
return markBound(relation, JSONUtil.toJsonStr(queried), "QUERY_BOUND");
AlipayTradeRoyaltyRelationBatchqueryResponse queried = tryQueryAlipay(config, relation.getOutRequestNo(), paySetting.getAppAuthToken());
if (hasAlipayReceiver(queried, relation)) {
return markBound(relation, alipayQuerySnapshot(queried), "QUERY_BOUND");
}
return markFailed(relation, response == null ? "EMPTY_RESPONSE" : response.getSubCode(),
response == null ? "支付宝未返回关系绑定结果" : response.getSubMsg());
String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN");
if (response == null || List.of("ACQ.SYSTEM_ERROR", "SYSTEM_ERROR", "isp.unknow-error", "aop.unknown-error").contains(errorCode)) {
throw new RetryableRelationException(errorCode, "支付宝分账关系绑定结果尚未确认");
}
return markFailed(relation, errorCode, "支付宝分账关系配置或授权无效");
}
/** 支付宝关系绑定必须同时满足网关成功和业务 result_code=SUCCESS。 */
private boolean isAlipayBindSuccess(AlipayTradeRoyaltyRelationBindResponse response) {
return response != null && response.isSuccess()
&& "SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode()));
}
/** 查询支付宝关系绑定结果。 */
@@ -349,8 +507,70 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
return client(config).execute(request);
}
/** 动态创建支付宝客户端,命名账号不存在时直接失败。 */
private AlipayClient client(AppPlusPayConfig.AliPayConfig config) {
/** 查询异常只表示当前无法核实,不得覆盖原绑定请求的故障语义。 */
private AlipayTradeRoyaltyRelationBatchqueryResponse tryQueryAlipay(AppPlusPayConfig.AliPayConfig config,
String outRequestNo, String appAuthToken) {
try {
return queryAlipay(config, outRequestNo, appAuthToken);
} catch (Exception exception) {
return null;
}
}
/** 校验查询结果确实包含当前关系接收方,不能只凭查询接口成功判定绑定成功。 */
private boolean hasAlipayReceiver(AlipayTradeRoyaltyRelationBatchqueryResponse response,
ThirdMchFzRelationEntity relation) {
if (response == null || !response.isSuccess()
|| !"SUCCESS".equalsIgnoreCase(StrUtil.trim(response.getResultCode()))
|| response.getReceiverList() == null) {
return false;
}
return response.getReceiverList().stream().anyMatch(receiver -> {
String account = "loginName".equals(relation.getReceiverType())
? receiver.getLoginName() : receiver.getAccount();
return relation.getReceiverType().equals(receiver.getType())
&& relation.getReceiverAccount().equals(account);
});
}
/** 微信绑定成功响应必须与本地关系请求完全对应,避免把错误接收方误记为已绑定。 */
private boolean wechatResponseMatches(AddReceiverResponse response, ThirdMchFzRelationEntity relation) {
return Objects.equals(response.getSubMchid(), relation.getChannelSubMchId())
&& response.getType() != null
&& Objects.equals(response.getType().name(), relation.getReceiverType())
&& Objects.equals(response.getAccount(), relation.getReceiverAccount())
&& response.getRelationType() != null
&& Objects.equals(response.getRelationType().name(), relation.getChannelRelationType());
}
/** Worker 执行时通过内部 Feign 重新读取完整支付宝配置,关系表不保存授权 token。 */
private MchPaySettingEntity.AliPaySetting loadAliPaySetting(ThirdMchFzRelationEntity relation) {
R<MchManageVo> response = remoteMchService.getById(relation.getMchId());
if (response == null || !response.isOk() || response.getData() == null) {
throw new RetryableRelationException("MCH_QUERY_FAILED", "读取商户支付宝支付配置失败");
}
MchPaySettingVo setting = response.getData().getPaySettingList() == null ? null
: response.getData().getPaySettingList().stream()
.filter(item -> relation.getPaySettingId().equals(item.getId()))
.findFirst().orElse(null);
if (setting == null || !ALIPAY_TYPE.equals(setting.getPlatformType())) {
throw new IllegalStateException("支付宝支付配置已停用或删除");
}
MchPaySettingEntity.AliPaySetting result = parseAliPay(setting);
if (!Objects.equals(StrUtil.nullToEmpty(relation.getAccountKey()), StrUtil.nullToEmpty(result.getAccountKey()))) {
throw new IllegalStateException("支付宝平台账号配置已变化");
}
return result;
}
/**
* 动态创建支付宝客户端,命名账号不存在时直接失败;测试实现可替换客户端以验证渠道响应边界。
*
* @param config 已解析的平台支付宝账号配置
* @return 使用指定平台账号创建的支付宝 SDK 客户端
* @throws AlipayApiException SDK 配置无法初始化时抛出
*/
protected AlipayClient client(AppPlusPayConfig.AliPayConfig config) throws AlipayApiException {
if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey())) {
throw new IllegalStateException("支付宝平台账号配置不完整");
}
@@ -441,6 +661,41 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
return false;
}
/** 保存可重试错误但保持 BINDING,后续必须使用相同关系主键和请求号重试。 */
private void markRetry(ThirdMchFzRelationEntity relation, String code, String message) {
relationMapper.update(null, new LambdaUpdateWrapper<ThirdMchFzRelationEntity>()
.eq(ThirdMchFzRelationEntity::getId, relation.getId())
.eq(ThirdMchFzRelationEntity::getStatus, ThirdMchFzRelationStatusEnum.BINDING.getCode())
.set(ThirdMchFzRelationEntity::getLastExternalStatus, code)
.set(ThirdMchFzRelationEntity::getBindError, StrUtil.subPre(StrUtil.blankToDefault(message, code), 1000))
.set(ThirdMchFzRelationEntity::getLastQueryTime, LocalDateTime.now()));
}
/** 微信响应脱敏快照。 */
private String wechatResponseSnapshot(AddReceiverResponse response) {
return JSONUtil.createObj().set("success", true).set("subMchid", response.getSubMchid())
.set("receiverType", response.getType() == null ? null : response.getType().name())
.set("receiverAccount", mask(response.getAccount()))
.set("relationType", response.getRelationType() == null ? null : response.getRelationType().name()).toString();
}
/** 支付宝绑定响应脱敏快照。 */
private String alipayBindSnapshot(AlipayTradeRoyaltyRelationBindResponse response) {
return JSONUtil.createObj().set("success", response != null && response.isSuccess())
.set("code", response == null ? null : response.getCode())
.set("subCode", response == null ? null : response.getSubCode())
.set("resultCode", response == null ? null : response.getResultCode()).toString();
}
/** 支付宝查询响应脱敏快照。 */
private String alipayQuerySnapshot(AlipayTradeRoyaltyRelationBatchqueryResponse response) {
return JSONUtil.createObj().set("success", response != null && response.isSuccess())
.set("code", response == null ? null : response.getCode())
.set("subCode", response == null ? null : response.getSubCode())
.set("resultCode", response == null ? null : response.getResultCode())
.set("receiverCount", response == null || response.getReceiverList() == null ? 0 : response.getReceiverList().size()).toString();
}
/** 脱敏账号。 */
private String mask(String value) {
if (StrUtil.isBlank(value) || value.length() <= 4) {
@@ -449,8 +704,28 @@ public class ThirdMchFzRelationServiceImpl implements ThirdMchFzRelationService
return value.substring(0, 2) + "****" + value.substring(value.length() - 2);
}
/** 避免静态检查把敏感值直接写入快照。 */
private String paySetting(String value) {
return value;
/**
* 表示渠道结果不确定的异常,Worker 捕获后按指数退避保留原请求号重试。
*/
private static class RetryableRelationException extends RuntimeException {
/** 任务尝试日志使用的脱敏错误码。 */
private final String code;
/** 创建无底层异常的可重试错误。 */
RetryableRelationException(String code, String message) {
super(message);
this.code = code;
}
/** 创建保留底层异常类型但不向数据库写入敏感报文的可重试错误。 */
RetryableRelationException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
/** 获取稳定错误码。 */
String getCode() {
return code;
}
}
}
@@ -17,6 +17,7 @@ import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPaymentTaskDispatchVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
@@ -56,6 +57,10 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
/** 实时任务写入器,用于对账发现状态缺口后复用原业务号重新排队。 */
private final ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 商户分账关系绑定服务;可选注入仅用于兼容既有不启动 Spring 的单元测试。 */
@Autowired(required = false)
private ThirdMchFzRelationService mchFzRelationService;
/** Worker并发数,受配置限制,避免渠道调用耗尽 Web 线程。 */
@Value("${third.payment.task.worker-threads:16}")
private int workerThreads;
@@ -273,6 +278,13 @@ public class ThirdPaymentTaskServiceImpl extends ServiceImpl<ThirdPaymentTaskMap
return TaskResult.success("REFUND_ACCEPTED");
}
return settleByRefundStatus(payOrder);
case FZ_RELATION_BIND:
if (mchFzRelationService == null) {
return TaskResult.failed("MISSING_SERVICE", "FZ_RELATION_SERVICE_MISSING", "商户分账关系服务不可用");
}
return mchFzRelationService.bind(task.getFzRelationId())
? TaskResult.success("BOUND")
: TaskResult.failed("BIND_FAILED", "FZ_RELATION_BIND_FAILED", "商户平台分佣关系绑定失败");
case FZ_CREATE:
payOrder = payOrderService.getById(task.getPayOrderId());
if (payOrder == null || !ThirdPayOrderStatusEnum.PAY_SUCCESS.getType().equals(payOrder.getStatus())) {
@@ -13,6 +13,7 @@
a.user_code,
a.mch_id,
a.mch_pay_setting_json,
a.fz_relation_id,
a.price,
a.fz_price,
a.status,
@@ -68,6 +68,21 @@
AND t.del_flag = '0')
ORDER BY p.update_time, p.id LIMIT #{limit}
</when>
<when test="taskType == 'FZ_RELATION_BIND'">
SELECT r.tenant_id, #{taskType} AS task_type,
CONCAT('FZ_RELATION_BIND:', r.id) AS task_key,
r.id AS fz_relation_id, 150 AS priority,
COALESCE(r.update_time, r.create_time, #{now}) AS next_run_time
FROM third_mch_fz_relation r
WHERE r.del_flag = '0' AND r.is_default = '1'
AND r.status IN ('PENDING_BIND', 'BINDING')
AND NOT EXISTS (
SELECT 1 FROM third_payment_task t
WHERE t.tenant_id = r.tenant_id
AND t.task_key = CONCAT('FZ_RELATION_BIND:', r.id)
AND t.del_flag = '0')
ORDER BY r.update_time, r.id LIMIT #{limit}
</when>
<when test="taskType == 'FZ_CREATE'">
SELECT p.tenant_id, #{taskType} AS task_type, CONCAT('FZ_CREATE:', p.pay_no) AS task_key,
p.id AS pay_order_id, p.pay_no, 90 AS priority,
@@ -235,11 +250,11 @@
<insert id="insertIgnore">
INSERT IGNORE INTO third_payment_task
(id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, aggregate_session_id,
(id, tenant_id, task_type, task_key, pay_order_id, fz_order_id, fz_relation_id, aggregate_session_id,
pay_no, refund_no, fz_no, aggregate_no, status, next_run_time, priority, attempt_count,
create_time, update_time, del_flag)
VALUES
(#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{aggregateSessionId},
(#{id}, #{tenantId}, #{taskType}, #{taskKey}, #{payOrderId}, #{fzOrderId}, #{fzRelationId}, #{aggregateSessionId},
#{payNo}, #{refundNo}, #{fzNo}, #{aggregateNo}, #{status}, #{nextRunTime}, #{priority},
#{attemptCount}, #{createTime}, #{updateTime}, #{delFlag})
</insert>
@@ -328,4 +343,13 @@
last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime}
WHERE id = #{id} AND status IN ('FAILED', 'ALERT', 'CANCELLED') AND del_flag = '0'
</update>
<update id="rearmByTaskKey">
UPDATE third_payment_task
SET status = 'READY', next_run_time = #{nextRunTime}, finished_time = NULL,
lease_owner = NULL, lease_token = NULL, lease_expire_time = NULL,
last_error_code = NULL, last_error_message = NULL, update_time = #{nextRunTime}
WHERE tenant_id = #{tenantId} AND task_key = #{taskKey}
AND status IN ('SUCCESS', 'FAILED', 'RETRY', 'ALERT', 'CANCELLED') AND del_flag = '0'
</update>
</mapper>
@@ -17,6 +17,8 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** MyBatis 支付任务映射资源路径。 */
private static final String MAPPER_RESOURCE = "mapper/ThirdPaymentTaskMapper.xml";
/** MyBatis 支付订单映射资源路径,支付订单必须回读创建时固定的分账关系主键。 */
private static final String PAY_ORDER_MAPPER_RESOURCE = "mapper/ThirdPayOrderMapper.xml";
/** 支付任务表与高频业务扫描索引迁移相对项目根目录的路径。 */
private static final String PAYMENT_TASK_MIGRATION = "db/20260824_add_third_payment_task.sql";
@@ -26,6 +28,8 @@ class ThirdPaymentTaskMapperSqlContractTest {
/** 测试启动时读取的支付任务数据库迁移。 */
private static String migrationSql;
/** 测试启动时读取的支付订单 Mapper XML。 */
private static String payOrderMapperXml;
/**
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
@@ -39,9 +43,23 @@ class ThirdPaymentTaskMapperSqlContractTest {
Assertions.assertNotNull(input, "支付任务 Mapper XML 必须进入运行时类路径");
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
try (InputStream input = ThirdPaymentTaskMapperSqlContractTest.class.getClassLoader()
.getResourceAsStream(PAY_ORDER_MAPPER_RESOURCE)) {
Assertions.assertNotNull(input, "支付订单 Mapper XML 必须进入运行时类路径");
payOrderMapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
migrationSql = readProjectFile(PAYMENT_TASK_MIGRATION);
}
/**
* 支付订单管理查询必须投影关系主键,分账和退分账才能使用支付创建时的关系快照。
*/
@Test
void payOrderProjectionShouldKeepProfitSharingRelationId() {
Assertions.assertTrue(payOrderMapperXml.contains("a.fz_relation_id"),
"支付订单查询必须保留创建时固定的分账关系主键");
}
/**
* 验证领取动作必须同时受可领取原状态和到期时间约束,多个 thirdparty 实例只能有一个 CAS 成功。
*/
@@ -92,4 +92,30 @@ class ThirdPaymentTaskEnqueuerTest {
() -> Assertions.assertEquals(105, task.getPriority()),
() -> Assertions.assertNotNull(task.getNextRunTime()));
}
/** 验证关系绑定任务使用关系主键构造稳定键,并携带关系结构化字段。 */
@Test
void ensureRelationBindShouldUseStableRelationTaskKeyWithoutRearm() {
enqueuer.ensureRelationBind(9L, 901L, null, 150);
ArgumentCaptor<ThirdPaymentTaskEntity> captor = ArgumentCaptor.forClass(ThirdPaymentTaskEntity.class);
verify(taskMapper).insertIgnore(captor.capture());
ThirdPaymentTaskEntity task = captor.getValue();
Assertions.assertAll(
() -> Assertions.assertEquals("FZ_RELATION_BIND:901", task.getTaskKey()),
() -> Assertions.assertEquals(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND.getCode(), task.getTaskType()),
() -> Assertions.assertEquals(901L, task.getFzRelationId()),
() -> Assertions.assertEquals("READY", task.getStatus()));
}
/** 配置重新保存才允许显式唤醒原退避任务,普通支付流量不调用 rearm。 */
@Test
void enqueueRelationBindShouldRearmOnlyWhenExplicitlyRequested() {
enqueuer.enqueueRelationBind(9L, 902L, null, 150);
verify(taskMapper).insertIgnore(org.mockito.ArgumentMatchers.any(ThirdPaymentTaskEntity.class));
verify(taskMapper).rearmByTaskKey(org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq("FZ_RELATION_BIND:902"),
org.mockito.ArgumentMatchers.any());
}
}
@@ -30,6 +30,7 @@ import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -98,6 +99,7 @@ class ThirdAggregateAliPayServiceImplTest {
context.setExpireTime(LocalDateTime.now().plusMinutes(2));
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setSubMchid("smid-1001");
aliPaySetting.setAppAuthToken("merchant-auth-token");
paySetting = new MchPaySettingVo();
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
@@ -166,7 +168,9 @@ class ThirdAggregateAliPayServiceImplTest {
*/
@Test
void createPayShouldOmitEmptyAppAuthToken() throws Exception {
paySetting.setSettingJson(JSONUtil.toJsonStr(new MchPaySettingEntity.AliPaySetting()));
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setSubMchid("smid-1001");
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000003");
@@ -180,6 +184,24 @@ class ThirdAggregateAliPayServiceImplTest {
Assertions.assertFalse(requestCaptor.getValue().getTextParams().containsKey("app_auth_token"));
}
/**
* 启用平台抽佣但编排层未固定关系主键时,底层不得向支付宝创建交易。
*
* @throws Exception Mockito 校验的支付宝 SDK 方法声明受检异常
*/
@Test
void createPayShouldRejectCommissionWithoutRelationId() throws Exception {
paySetting.setFzRatio(5D);
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.createPay(context, paySetting,
new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)));
Assertions.assertTrue(exception.getMessage().contains("分佣关系尚未绑定"));
verify(alipayClient, never()).execute(any(AlipayTradeCreateRequest.class));
verify(thirdPayOrderService, never()).save(any(ThirdPayOrderEntity.class));
}
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
@@ -20,6 +20,7 @@ import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayPollingCoordinator;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -80,6 +81,10 @@ class ThirdAggregateOrderPayServiceImplTest {
@Mock
private ThirdPayOrderService thirdPayOrderService;
/** 商户默认平台分佣关系服务 Mock,用于支付平台选定后的前置校验。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 钱包商户远程服务 Mock。
*/
@@ -138,7 +143,7 @@ class ThirdAggregateOrderPayServiceImplTest {
void setUp() {
AppPlusPayConfig appPlusPayConfig = completeApplicationConfig();
service = new ThirdAggregateOrderPayServiceImpl(aggregatePaySessionService, aggregateAliPayService,
aggregateWechatPayService, thirdPayOrderService, remoteMchService,
aggregateWechatPayService, thirdPayOrderService, mchFzRelationService, remoteMchService,
remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor, pollingCoordinator);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
@@ -360,6 +365,7 @@ class ThirdAggregateOrderPayServiceImplTest {
private MchManageVo completeMerchant() {
MchPaySettingEntity.AliPaySetting aliDetail = new MchPaySettingEntity.AliPaySetting();
aliDetail.setAppAuthToken("merchant-auth-token");
aliDetail.setSubMchid("smid-1001");
MchPaySettingVo ali = new MchPaySettingVo();
ali.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
ali.setEnable(YesNoEnum.YES.getCode());
@@ -38,10 +38,12 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDt
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
@@ -92,6 +94,10 @@ class ThirdAppPlusAliPayServiceImplTest {
@Mock
private ThirdAggregatePaySessionService aggregatePaySessionService;
/** 支付订单固定分佣关系服务 Mock,用于禁止抽佣和退分账回退当前平台配置。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
private AppPlusPayConfig appPlusPayConfig;
private MchPaySettingVo paySetting;
@@ -131,9 +137,12 @@ class ThirdAppPlusAliPayServiceImplTest {
payDto.setUserId(-1L);
payDto.setAmount(1234);
payDto.setMchId(88L);
payDto.setFzRelationId(9001L);
service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient);
ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService);
ReflectionTestUtils.setField(service, "thirdFzOrderService", thirdFzOrderService);
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
org.mockito.Mockito.lenient().when(mchFzRelationService.getById(9001L)).thenReturn(boundAlipayRelation());
}
/**
@@ -246,6 +255,24 @@ class ThirdAppPlusAliPayServiceImplTest {
verify(thirdPayOrderService).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/**
* 验证固定分佣关系缺失时平台抽佣直接失败,不会回退到当前支付宝平台接收账号。
*/
@Test
void directPayRoyaltyShouldRejectMissingFixedRelation() throws Exception {
ThirdPayOrderEntity payOrder = paidDirectPayOrder();
ThirdFzOrderEntity fzOrder = pendingFzOrder(payOrder);
fzOrder.setCreateRes(JSONUtil.createObj().set("settleConfirm",
JSONUtil.createObj().set("status", "SUCCESS")).toString());
when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(null);
when(thirdFzOrderService.update(any(ThirdFzOrderEntity.class), any(Wrapper.class))).thenReturn(true);
String status = service.submitDirectPayRoyaltyStage(fzOrder, payOrder);
Assertions.assertEquals("FAILED", status);
verify(alipayClient, never()).execute(any(AlipayTradeOrderSettleRequest.class));
}
/**
* 验证确认结算遇到系统不确定结果后使用相同 out_request_no 重试。
*/
@@ -588,10 +615,31 @@ class ThirdAppPlusAliPayServiceImplTest {
order.setThirdPayNo("202600000001");
order.setPayTime(LocalDateTime.now().minusMinutes(1));
order.setEnableFz(YesNoEnum.YES.getCode());
order.setFzRelationId(payDto.getFzRelationId());
order.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting));
return order;
}
/**
* 构造与测试订单、平台账号和二级商户完全匹配的已绑定支付宝关系。
*
* @return 可供历史订单抽佣和退分账使用的固定关系快照
*/
private ThirdMchFzRelationEntity boundAlipayRelation() {
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
relation.setId(payDto.getFzRelationId());
relation.setMchId(payDto.getMchId());
relation.setPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
relation.setChannelAppId(appPlusPayConfig.getAliPayConfig().getAppId());
relation.setChannelSubMchId("smid-1001");
relation.setReceiverType("userId");
relation.setReceiverAccount("2088000000000001");
relation.setReceiverName("资安信平台");
relation.setBoundTime(LocalDateTime.now().minusDays(1));
relation.setDelFlag("0");
return relation;
}
/**
* 构造包含稳定退款号和原始退款请求快照的退款中订单。
*
@@ -21,6 +21,7 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.service.*;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
@@ -42,6 +43,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.List;
@@ -76,6 +78,10 @@ class ThirdAppPlusServiceImplOrderCodePayTest {
@Mock
private ThirdPayOrderService thirdPayOrderService;
/** 商户默认平台分佣关系服务 Mock,用于订单码创建前校验渠道绑定状态。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/**
* 聚合支付服务 Mock,用于验证旧原生订单码创建前执行跨模式互斥检查。
*/
@@ -130,8 +136,12 @@ class ThirdAppPlusServiceImplOrderCodePayTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true);
ThirdMchFzRelationEntity boundRelation = new ThirdMchFzRelationEntity();
boundRelation.setId(9001L);
lenient().when(mchFzRelationService.requireBound(any())).thenReturn(boundRelation);
AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig();
aliPayConfig.setAppId("2026000000000001");
@@ -216,6 +226,20 @@ class ThirdAppPlusServiceImplOrderCodePayTest {
verify(pollingCoordinator).wakeOrderCodePolling();
}
/** 支付宝订单码启用平台抽佣但关系未绑定时必须拒付,不能继续调用预下单接口。 */
@Test
void orderCodePayShouldRejectCommissionWhenRelationNotBound() {
mockNoExistingOrder();
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
when(mchFzRelationService.requireBound(any())).thenThrow(
new IllegalStateException("支付平台分佣关系尚未绑定,请稍后重试"));
RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
Assertions.assertTrue(exception.getMessage().contains("尚未绑定"));
verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any());
}
@Test
void orderCodePayShouldCreateWechatNativeOrder() {
request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT);
@@ -5,11 +5,14 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.fz.ThirdFzOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdFzOrderEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdMchFzRelationEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdFzOrderMapper;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayOrderMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdFzOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdMchFzRelationService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.ThirdPaymentTaskEnqueuer;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.ThirdAppPlusWechatFzServiceImpl;
@@ -17,6 +20,7 @@ import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.profitsharing.model.OrderStatus;
import com.wechat.pay.java.service.profitsharing.model.OrdersEntity;
import com.wechat.pay.java.service.profitsharing.model.CreateOrderRequest;
import com.wechat.pay.java.service.profitsharing.model.QueryOrderRequest;
import com.wechat.pay.java.service.profitsharing.model.QueryReturnOrderRequest;
import com.wechat.pay.java.service.profitsharing.model.ReturnOrderStatus;
@@ -29,8 +33,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -71,6 +77,10 @@ class ThirdAppPlusWechatFzServiceImplTest {
@Mock
private ThirdPaymentTaskEnqueuer paymentTaskEnqueuer;
/** 支付订单固定分账关系服务 Mock,用于验证分账接收方不跟随当前配置漂移。 */
@Mock
private ThirdMchFzRelationService mchFzRelationService;
/** 被测微信分账服务。 */
@InjectMocks
private ThirdAppPlusWechatFzServiceImpl service;
@@ -86,12 +96,51 @@ class ThirdAppPlusWechatFzServiceImplTest {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("wx-sub-1001");
MchPaySettingEntity setting = new MchPaySettingEntity();
setting.setFzRatio(10D);
setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
payOrder = new ThirdPayOrderEntity();
payOrder.setId(1L);
payOrder.setTenantId(1L);
payOrder.setMchId(88L);
payOrder.setPayNo("pay-1001");
payOrder.setThirdPayNo("wx-transaction-1001");
payOrder.setPrice(200);
payOrder.setStatus(com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
payOrder.setEnableFz(YesNoEnum.YES.getCode());
payOrder.setFzRelationId(9001L);
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
ReflectionTestUtils.setField(service, "mchFzRelationService", mchFzRelationService);
}
/**
* 验证微信分账请求使用支付创建时固定关系的接收商户,而不是当前服务商配置的默认值。
*/
@Test
void createShouldUseFixedRelationReceiver() {
AppPlusPayConfig.WechatConfig config = wechatConfig();
when(appPlusPayConfig.getWechatConfig()).thenReturn(config);
when(mchFzRelationService.getById(payOrder.getFzRelationId())).thenReturn(boundWechatRelation());
OrdersEntity response = new OrdersEntity();
response.setState(OrderStatus.PROCESSING);
when(profitsharingService.createOrder(any(CreateOrderRequest.class))).thenReturn(response);
service.create(payOrder);
ArgumentCaptor<CreateOrderRequest> requestCaptor = ArgumentCaptor.forClass(CreateOrderRequest.class);
verify(profitsharingService).createOrder(requestCaptor.capture());
Assertions.assertEquals("1900000002", requestCaptor.getValue().getReceivers().get(0).getAccount());
}
/**
* 验证固定关系缺失时微信分账拒绝调用渠道,不会回退到当前服务商商户号。
*/
@Test
void createShouldRejectMissingFixedRelation() {
when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig());
Assertions.assertThrows(IllegalStateException.class, () -> service.create(payOrder));
verify(profitsharingService, never()).createOrder(any(CreateOrderRequest.class));
}
/**
@@ -196,4 +245,36 @@ class ThirdAppPlusWechatFzServiceImplTest {
response.setOutReturnNo("fz-refund-pay-1001");
return response;
}
/**
* 构造微信服务商调用配置,接收账号故意与服务商号不同以验证关系快照优先级。
*
* @return 测试用微信平台配置
*/
private AppPlusPayConfig.WechatConfig wechatConfig() {
AppPlusPayConfig.WechatConfig config = new AppPlusPayConfig.WechatConfig();
config.setAppPlatformAppId("wx-app-1001");
config.setMerchantId("1900000001");
return config;
}
/**
* 构造与支付订单完全匹配且已绑定的微信分账关系。
*
* @return 固定平台分佣关系快照
*/
private ThirdMchFzRelationEntity boundWechatRelation() {
ThirdMchFzRelationEntity relation = new ThirdMchFzRelationEntity();
relation.setId(payOrder.getFzRelationId());
relation.setMchId(payOrder.getMchId());
relation.setPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
relation.setChannelAppId("wx-app-1001");
relation.setChannelSubMchId("wx-sub-1001");
relation.setReceiverType("MERCHANT_ID");
relation.setReceiverAccount("1900000002");
relation.setReceiverName("平台分佣接收方");
relation.setBoundTime(LocalDateTime.now().minusDays(1));
relation.setDelFlag("0");
return relation;
}
}
@@ -33,6 +33,7 @@ public class ThirdPaymentQuartzInitializer implements InitializingBean {
new PaymentSchedule(ThirdPaymentTaskTypeEnum.PAY_QUERY, 5),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_CREATE, 10),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.REFUND_QUERY, 10),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_RELATION_BIND, 10),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_CREATE, 10),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_SETTLE_CONFIRM, 10),
new PaymentSchedule(ThirdPaymentTaskTypeEnum.FZ_ROYALTY, 10),
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
@@ -11,7 +14,7 @@ import lombok.Data;
import java.util.List;
@Data
public class WalletActiveDto extends WalletActiveEntity implements BaseIsIncludeTreeInterface {
public class WalletActiveDto extends WalletActiveEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
@Schema(description = "数量")
@Max(200)
@@ -28,6 +31,18 @@ public class WalletActiveDto extends WalletActiveEntity implements BaseIsInclude
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private Boolean isInclude;
private List<Long> deptIds;
@@ -1,17 +1,30 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletForeignEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletForeignDto extends WalletForeignEntity {
public class WalletForeignDto extends WalletForeignEntity implements BaseUserIdentityQueryInterface {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> deptIds;
private Boolean isFindCheckUser = false;
@@ -1,12 +1,15 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletFreezeEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletFreezeDto extends WalletFreezeEntity {
public class WalletFreezeDto extends WalletFreezeEntity implements BaseUserIdentityQueryInterface {
private Long walletTypeId;
@@ -14,6 +17,18 @@ public class WalletFreezeDto extends WalletFreezeEntity {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private Boolean isFindCheckUser = false;
}
@@ -1,12 +1,16 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import lombok.Data;
import java.util.List;
/** 钱包记录余额查询条件,身份字段用于按用户身份快速筛选记录。 */
@Data
public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface {
public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private String walletRecordMonth;
@@ -22,4 +26,16 @@ public class WalletManageBalanceDto implements BaseIsIncludeTreeInterface {
private List<Long> scopeDeptIds;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import lombok.Data;
@@ -8,8 +11,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 钱包管理后台查询条件,身份字段用于按用户身份快速筛选钱包。 */
@Data
public class WalletManageDto extends WalletManageEntity implements BaseIsIncludeTreeInterface {
public class WalletManageDto extends WalletManageEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private List<Long> userIds;
@@ -27,4 +31,16 @@ public class WalletManageDto extends WalletManageEntity implements BaseIsInclude
*/
private Boolean isInclude;
}
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -1,15 +1,30 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletStrideEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletStrideDto extends WalletStrideEntity {
public class WalletStrideDto extends WalletStrideEntity implements BaseUserIdentityQueryInterface {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> deptIds;
private Boolean isFindCheckUser = false;
@@ -1,12 +1,15 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import lombok.Data;
import java.util.List;
@Data
public class WalletTopupActionDto implements BaseIsIncludeTreeInterface {
public class WalletTopupActionDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/**
* 用户id
@@ -28,4 +31,19 @@ public class WalletTopupActionDto implements BaseIsIncludeTreeInterface {
*/
private Boolean isInclude;
/** 身份协议编码筛选条件;为空时不限制用户身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件;为空或字段为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件;为空或字段为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态筛选条件:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
/** 经过 UPMS 身份筛选后的用户主键集合,供钱包 SQL 使用。 */
private List<Long> userIds;
}
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.wallet.api.dto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -8,8 +11,9 @@ import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/** 钱包流水后台查询条件,身份字段沿用 UPMS 用户查询契约。 */
@Data
public class WalletTransactionDto extends WalletTransactionEntity implements BaseIsIncludeTreeInterface {
public class WalletTransactionDto extends WalletTransactionEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/**
* 类型 - 收入
@@ -71,4 +75,16 @@ public class WalletTransactionDto extends WalletTransactionEntity implements Bas
*/
private List<Integer> priceList;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件;字段语义由 UPMS 用户查询统一解释。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件;老师学号为空时不限制。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
}
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.wallet.api.dto.recharge;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import lombok.Data;
@@ -7,10 +10,22 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class WalletRechargeApplyDto extends WalletRechargeApplyEntity {
public class WalletRechargeApplyDto extends WalletRechargeApplyEntity implements BaseUserIdentityQueryInterface {
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private List<Long> walletTypeIds;
/**
@@ -1,5 +1,8 @@
package com.bsd.pjcloud.wallet.api.dto.recharge;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
import lombok.Data;
@@ -10,7 +13,7 @@ import java.util.List;
* 充值记录绑定发票
*/
@Data
public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity {
public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity implements BaseUserIdentityQueryInterface {
/**
* 充值/扣款申请id
@@ -22,6 +25,18 @@ public class WalletRechargeBindInvoiceDto extends WalletRechargeInvoiceEntity {
*/
private List<String> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
/**
* 创建时间
*/
@@ -1,14 +1,18 @@
package com.bsd.pjcloud.wallet.api.dto.recharge;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 充值水单查询条件,身份字段用于筛选充值对应用户。 */
@Data
public class WalletRechargeImportDto extends WalletRechargeImportEntity implements BaseIsIncludeTreeInterface {
public class WalletRechargeImportDto extends WalletRechargeImportEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
private String processStatus;
@@ -16,6 +20,18 @@ public class WalletRechargeImportDto extends WalletRechargeImportEntity implemen
private List<Long> userIds;
/** 用户身份协议编码集合;为空表示不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展筛选条件。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展筛选条件。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态:1 已录入、0 未录入、空值表示全部。 */
private String hasFace;
private List<Long> userUniqueIds;
/**
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.wallet.api.dto.rule;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity;
import lombok.Data;
@@ -9,7 +12,7 @@ import java.time.LocalDateTime;
import java.util.List;
@Data
public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implements BaseIsIncludeTreeInterface {
public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/**
* 树状结构搜索
@@ -20,6 +23,21 @@ public class WalletRuleTransactionDto extends WalletRuleTransactionEntity implem
private Long userId;
/** 身份预筛选后用于钱包转账流水查询的用户主键集合。 */
private List<Long> userIds;
/** UPMS 用户身份协议编码集合;为空时不限制身份。 */
private List<String> identityTypes;
/** 学生身份扩展查询条件;对象为空或字段均为空时不限制学生资料。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展查询条件;对象为空或老师学号为空时不限制老师资料。 */
private SysUserTeacherDto teacherInfo;
/** 人脸录入状态;1 表示已录入,0 表示未录入,空值表示不限制。 */
private String hasFace;
private LocalDate[] createTimeList;
private LocalDate[] refundTimeList;
@@ -1,15 +1,20 @@
package com.bsd.pjcloud.wallet.api.vo;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletActiveEntity;
import com.bsd.pjcloud.wallet.api.vo.base.BaseMchInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.util.List;
@Data
public class WalletActiveVo extends WalletActiveEntity implements BaseUserInterface, BaseDeptInterface, BaseMchInterface, BaseWalletTypeInterface {
public class WalletActiveVo extends WalletActiveEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseMchInterface, BaseWalletTypeInterface {
/**
* 用户相关
*/
@@ -17,8 +22,26 @@ public class WalletActiveVo extends WalletActiveEntity implements BaseUserInterf
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,12 +1,17 @@
package com.bsd.pjcloud.wallet.api.vo;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletForeignEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletForeignVo extends WalletForeignEntity implements BaseUserInterface, BaseDeptInterface {
public class WalletForeignVo extends WalletForeignEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface {
/**
* 用户相关
@@ -15,8 +20,26 @@ public class WalletForeignVo extends WalletForeignEntity implements BaseUserInte
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,12 +1,17 @@
package com.bsd.pjcloud.wallet.api.vo;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletFreezeEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserInterface, BaseDeptInterface {
public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface {
/**
* 用户相关
*/
@@ -14,8 +19,26 @@ public class WalletFreezeVo extends WalletFreezeEntity implements BaseUserInterf
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,7 +1,9 @@
package com.bsd.pjcloud.wallet.api.vo;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletManageEntity;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
@@ -9,9 +11,9 @@ import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 钱包管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
public class WalletManageVo extends WalletManageEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface {
public class WalletManageVo extends WalletManageEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface {
/**
* 钱包类型
@@ -33,13 +35,65 @@ public class WalletManageVo extends WalletManageEntity implements BaseUserInterf
private String userMobile;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
private String userAvatar;
/** 用户身份协议编码集合;没有身份时为空集合或空值。 */
private List<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
private String deptName;
/**
* 获取用户当前部门主键,沿用钱包实体的部门字段。
*
* @return 钱包记录关联的用户部门主键;未记录时为空
*/
@Override
public Long getUserDeptId() {
return getDeptId();
}
/**
* 设置用户当前部门主键,沿用钱包实体的部门字段。
*
* @param userDeptId UPMS 返回的用户当前部门主键
*/
@Override
public void setUserDeptId(Long userDeptId) {
setDeptId(userDeptId);
}
/**
* 获取用户当前部门名称。
*
* @return 用户当前部门名称;未分配时为空
*/
@Override
public String getUserDeptName() {
return deptName;
}
/**
* 设置用户当前部门名称。
*
* @param userDeptName UPMS 返回的用户当前部门名称
*/
@Override
public void setUserDeptName(String userDeptName) {
this.deptName = userDeptName;
}
/**
* 导出相关
*/
@@ -1,12 +1,17 @@
package com.bsd.pjcloud.wallet.api.vo;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletStrideEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletStrideVo extends WalletStrideEntity implements BaseUserInterface, BaseDeptInterface {
public class WalletStrideVo extends WalletStrideEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface {
/**
* 用户相关
@@ -15,8 +20,26 @@ public class WalletStrideVo extends WalletStrideEntity implements BaseUserInterf
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,13 +1,18 @@
package com.bsd.pjcloud.wallet.api.vo.recharge;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeApplyEntity;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.util.List;
@Data
public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface {
public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseDeptInterface, BaseWalletTypeInterface {
/**
* 钱包类型相关
*/
@@ -28,8 +33,26 @@ public class WalletRechargeApplyVo extends WalletRechargeApplyEntity implements
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
/** UPMS 当前用户部门主键,用于公共用户信息组件的标准别名。 */
private Long userDeptId;
/** UPMS 当前用户部门名称,用于公共用户信息组件的标准别名。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -1,16 +1,21 @@
package com.bsd.pjcloud.wallet.api.vo.recharge;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeImportEntity;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 充值水单管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
public class WalletRechargeImportVo extends WalletRechargeImportEntity implements BaseUserDeptInterface, BaseWalletTypeInterface, BaseUserInterface {
public class WalletRechargeImportVo extends WalletRechargeImportEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface, BaseUserInterface {
/**
* 用户相关
*/
@@ -18,8 +23,20 @@ public class WalletRechargeImportVo extends WalletRechargeImportEntity implement
private String userMobile;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
private String userAvatar;
/** 用户身份协议编码集合;没有身份时为空集合或空值。 */
private List<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
private Long userDeptId;
private String userDeptName;
@@ -1,11 +1,47 @@
package com.bsd.pjcloud.wallet.api.vo.recharge;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeInvoiceEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class WalletRechargeInvoiceVo extends WalletRechargeInvoiceEntity {
public class WalletRechargeInvoiceVo extends WalletRechargeInvoiceEntity implements BaseUserDeptInterface, BaseUserIdentityInterface {
/** 发票申请人的 UPMS 用户主键,来源于 apply_user_id 查询别名。 */
private Long userId;
/** 发票申请人的姓名,由 UPMS 用户资料回填。 */
private String username;
/** 发票申请人的手机号,由 UPMS 用户资料回填。 */
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
/** 发票申请人的头像地址,由 UPMS 用户资料回填。 */
private String userAvatar;
/** 发票申请人的当前部门主键,由 UPMS 用户资料回填。 */
private Long userDeptId;
/** 发票申请人的当前部门名称,由 UPMS 用户资料回填。 */
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
private String totalPriceCnStr;
@@ -1,11 +1,16 @@
package com.bsd.pjcloud.wallet.api.vo.rule;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.rule.WalletRuleTransactionEntity;
import lombok.Data;
import java.util.List;
@Data
public class WalletRuleTransactionVo extends WalletRuleTransactionEntity implements BaseUserDeptInterface {
public class WalletRuleTransactionVo extends WalletRuleTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface {
/**
* 用户相关
@@ -16,12 +21,24 @@ public class WalletRuleTransactionVo extends WalletRuleTransactionEntity impleme
private String userMobile;
/** 用户标准手机号字段,供身份组件展示;与历史 userMobile 同步回填。 */
private String phone;
private String userAvatar;
private Long userDeptId;
private String userDeptName;
/** 用户拥有的 UPMS 身份协议编码集合。 */
private List<String> identityTypes;
/** 用户的学生身份扩展资料;无学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 用户的老师身份扩展资料;无老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 源钱包相关
*/
@@ -1,16 +1,20 @@
package com.bsd.pjcloud.wallet.api.vo.transaction;
import com.bsd.pjcloud.admin.api.vo.base.BaseDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.bsd.pjcloud.wallet.api.entity.WalletTransactionEntity;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletInterface;
import com.bsd.pjcloud.wallet.api.vo.base.BaseWalletTypeInterface;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/** 钱包流水管理端展示对象,包含公共用户主资料、部门及身份信息。 */
@Data
public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserInterface, BaseDeptInterface, BaseWalletTypeInterface, BaseWalletInterface {
public class WalletTransactionVo extends WalletTransactionEntity implements BaseUserDeptInterface, BaseUserIdentityInterface, BaseWalletTypeInterface, BaseWalletInterface {
/**
* 字符串金额用于导出
@@ -26,8 +30,20 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base
private String userMobile;
/** 用户标准手机号字段,供统一身份组件展示;与历史 userMobile 字段并存。 */
private String phone;
private String userAvatar;
/** 用户身份协议编码集合;没有身份时为空集合或空值。 */
private List<String> identityTypes;
/** 学生身份扩展资料;没有学生身份时为空。 */
private SysUserStudentDto studentInfo;
/** 老师身份扩展资料;没有老师身份时为空。 */
private SysUserTeacherDto teacherInfo;
/**
* 部门相关
*/
@@ -35,6 +51,46 @@ public class WalletTransactionVo extends WalletTransactionEntity implements Base
private String deptName;
/**
* 获取用户当前部门主键,沿用流水中的部门快照字段。
*
* @return 用户部门主键;未记录时为空
*/
@Override
public Long getUserDeptId() {
return deptId;
}
/**
* 设置用户当前部门主键并写入流水展示字段。
*
* @param userDeptId UPMS 返回的用户部门主键
*/
@Override
public void setUserDeptId(Long userDeptId) {
this.deptId = userDeptId;
}
/**
* 获取用户当前部门名称。
*
* @return 用户部门名称;未分配时为空
*/
@Override
public String getUserDeptName() {
return deptName;
}
/**
* 设置用户当前部门名称。
*
* @param userDeptName UPMS 返回的用户部门名称
*/
@Override
public void setUserDeptName(String userDeptName) {
this.deptName = userDeptName;
}
/**
* 钱包相关
*/
@@ -336,6 +336,7 @@ public class WalletTransactionController {
public void exportCz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_INCOME);
walletTransactionService.prepareIdentityQuery(dto);
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
@@ -358,6 +359,7 @@ public class WalletTransactionController {
public void exportJz(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(WalletBizEnum.getTopUpCodes());
dto.setType(WalletTransactionDto.TYPE_EXPENSE);
walletTransactionService.prepareIdentityQuery(dto);
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
@@ -381,6 +383,7 @@ public class WalletTransactionController {
@PreAuthorize("@pms.hasPermission('wallet_walletTransaction_export')")
public void exportXf(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.PAYMENT.getCode()));
walletTransactionService.prepareIdentityQuery(dto);
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
@@ -403,6 +406,7 @@ public class WalletTransactionController {
public void exportTk(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.REFUND.getCode()));
dto.setIsFindOrder(true);
walletTransactionService.prepareIdentityQuery(dto);
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
@@ -426,6 +430,7 @@ public class WalletTransactionController {
public void exportTh(HttpServletResponse response, @RequestBody WalletTransactionDto dto) {
dto.setBizTypes(List.of(WalletBizEnum.RETURN_CANTEEN.getCode()));
dto.setIsFindOrder(false);
walletTransactionService.prepareIdentityQuery(dto);
Long count = walletTransactionMapper.count(dto, DataScope.of().func(DataScopeFuncEnum.COUNT));
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
@@ -1,7 +1,6 @@
package com.bsd.pjcloud.wallet.controller.mch;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -101,6 +100,6 @@ public class MchPaySettingController {
@DeleteMapping
@PreAuthorize("@pms.hasPermission('wallet_mchPaySetting_del')")
public R removeById(@RequestBody Long[] ids) {
return R.ok(mchPaySettingService.removeBatchByIds(CollUtil.toList(ids)));
return R.ok(mchPaySettingService.delete(List.of(ids)));
}
}
@@ -117,6 +117,7 @@ public class WalletRechargeImportController {
@PreAuthorize("@pms.hasPermission('wallet_walletRechargeImport_export')")
public void export(HttpServletResponse response, @RequestBody WalletRechargeImportDto dto) throws IOException {
DataScope dataScope = DataScope.of();
walletRechargeImportService.prepareIdentityQuery(dto);
Long count = walletRechargeImportMapper.count(dto, dataScope);
if (count > 5000) throw new RuntimeException("导出数据超出5000条,请缩小查询范围");
List<WalletRechargeImportVo> list = walletRechargeImportService.scopeList(dto, dataScope);
@@ -158,4 +159,4 @@ public class WalletRechargeImportController {
excelWriter.fill(list, fillConfig, writeSheet);
}
}
}
}
@@ -24,7 +24,7 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
.likeIfExists(WalletActiveEntity::getCardNumber, dto.getCardNumber())
.eqIfExists(WalletActiveEntity::getIsActiveHardware, dto.getIsActiveHardware())
.eqIfExists(WalletActiveEntity::getDeptId, dto.getDeptId())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletActiveEntity::getId, dto.getUserIds())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletActiveEntity::getUserId, dto.getUserIds())
.orderByDesc(WalletActiveEntity::getCardSort);
return selectJoinPage(page, WalletActiveVo.class, wrapper);
}
@@ -72,4 +72,4 @@ public interface WalletActiveMapper extends PjcloudBaseMapper<WalletActiveEntity
Page<WalletActiveEntity> openCardList(Page page, @Param("dto") WalletActiveDto dto);
Integer openCardCount(@Param("dto") WalletRemoteOpenCardCountDto dto);
}
}
@@ -23,11 +23,11 @@ public interface WalletFreezeMapper extends PjcloudBaseMapper<WalletFreezeEntity
.selectAs(WalletManageEntity::getCardNumber, WalletFreezeDto::getCardNumber)
.leftJoin(WalletManageEntity.class, WalletManageEntity::getId, WalletFreezeEntity::getWalletId)
.eqIfExists(WalletManageEntity::getCardNumber, dto.getCardNumber())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletFreezeEntity::getId, dto.getUserIds())
.in(ObjectUtil.isNotEmpty(dto.getUserIds()), WalletFreezeEntity::getUserId, dto.getUserIds())
.orderByDesc(WalletManageEntity::getCreateTime)
.orderByDesc(WalletManageEntity::getId);
return selectJoinPage(page, WalletFreezeVo.class, wrapper);
}
Page<WalletFreezeVo> scopePageList(Page page, @Param("dto") WalletFreezeDto dto, DataScope dataScope);
}
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more