feat:订单导出优化
feat:分账统计模块
This commit is contained in:
1 parent
14c2acb6c2
commit
4bf5930e64
22 files changed
+1892
-392
No files matched your search
@@ -14,3 +14,5 @@ WHERE ((`order_status` = '20' AND `order_type` IN ('1', '2', '3', '4'))
|
||||
OR `pay_type` IN ('5', '6', '50'))
|
||||
AND `verification_time` IS NULL
|
||||
AND `update_time` IS NOT NULL;
|
||||
|
||||
|
||||
+6
@@ -38,6 +38,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
|
||||
|
||||
private List<String> mchTypeCodes;
|
||||
|
||||
/** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */
|
||||
private List<String> merchantTypeCodes;
|
||||
|
||||
private List<String> orderStatusCodes;
|
||||
|
||||
private LocalDateTime[] createTimeList;
|
||||
@@ -82,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
|
||||
|
||||
private List<String> hardTypeCodes;
|
||||
|
||||
/** 用餐时间段字典编码集合;为空表示不限制餐段。 */
|
||||
private List<String> hourTypeCodes;
|
||||
|
||||
private List<String> refundStatusCodes;
|
||||
|
||||
/**
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.bsd.pjcloud.hard.api.vo.order.excel;
|
||||
|
||||
import com.alibaba.excel.annotation.ExcelProperty;
|
||||
import com.alibaba.excel.annotation.format.NumberFormat;
|
||||
import com.alibaba.excel.annotation.write.style.ColumnWidth;
|
||||
import com.alibaba.excel.annotation.write.style.ContentFontStyle;
|
||||
import com.alibaba.excel.annotation.write.style.HeadFontStyle;
|
||||
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 硬件订单明细导出行,按订单业务域组织二级表头。
|
||||
*
|
||||
* <p>仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段;
|
||||
* 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。</p>
|
||||
*/
|
||||
@Data
|
||||
@ColumnWidth(15)
|
||||
@HeadRowHeight(15)
|
||||
@HeadFontStyle(fontHeightInPoints = 10)
|
||||
@ContentFontStyle(fontHeightInPoints = 10)
|
||||
public class HardOrderExportExcelVo {
|
||||
|
||||
/** 当前订单在本次导出结果中的一基序号;合计行为空。 */
|
||||
@ExcelProperty({"订单", "序号"})
|
||||
private String lineNumber;
|
||||
|
||||
/** 订单类型中文名称;未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"订单", "订单类型"})
|
||||
private String orderType;
|
||||
|
||||
/** 订单状态中文名称;未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"订单", "订单状态"})
|
||||
private String orderStatus;
|
||||
|
||||
/** 统计场景中的订单来源名称;不属于统计来源时为空。 */
|
||||
@ExcelProperty({"订单", "订单来源"})
|
||||
private String saleSourceName;
|
||||
|
||||
/** 订单创建时间,格式为 yyyy-MM-dd HH:mm:ss。 */
|
||||
@ExcelProperty({"订单", "下单时间"})
|
||||
@ColumnWidth(22)
|
||||
private String createTime;
|
||||
|
||||
/** 用户姓名,优先取 UPMS 姓名,其次取昵称、账号和订单历史展示名。 */
|
||||
@ExcelProperty({"用户", "姓名"})
|
||||
private String userName;
|
||||
|
||||
/** 用户默认身份手机号;UPMS 资料缺失时回退订单历史手机号。 */
|
||||
@ExcelProperty({"用户", "手机号"})
|
||||
private String phone;
|
||||
|
||||
/** 用户当前所属部门名称;UPMS 资料缺失时回退订单历史部门名称。 */
|
||||
@ExcelProperty({"用户", "部门"})
|
||||
private String deptName;
|
||||
|
||||
/** 用户岗位名称,多个岗位使用中文逗号分隔。 */
|
||||
@ExcelProperty({"用户", "岗位"})
|
||||
private String postNames;
|
||||
|
||||
/** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */
|
||||
@ExcelProperty({"身份", "身份类型"})
|
||||
private String identityTypeNames;
|
||||
|
||||
/** 学生身份学号;没有学生身份时为空。 */
|
||||
@ExcelProperty({"身份", "学号"})
|
||||
private String studentNo;
|
||||
|
||||
/** 学生身份学校名称;没有学生身份时为空。 */
|
||||
@ExcelProperty({"身份", "学校"})
|
||||
private String schoolName;
|
||||
|
||||
/** 学生身份班级名称;没有学生身份时为空。 */
|
||||
@ExcelProperty({"身份", "班级"})
|
||||
private String className;
|
||||
|
||||
/** 学生身份监护人手机号,多个号码沿用业务存储格式。 */
|
||||
@ExcelProperty({"身份", "监护人手机号"})
|
||||
private String guardianPhones;
|
||||
|
||||
/** 老师身份工号或学号;没有老师身份时为空。 */
|
||||
@ExcelProperty({"身份", "老师学号"})
|
||||
private String teacherNo;
|
||||
|
||||
/** 钱包服务返回的商户名称;历史商户不存在时回退订单查询结果。 */
|
||||
@ExcelProperty({"商户设备", "商户"})
|
||||
private String merchantName;
|
||||
|
||||
/** 订单关联设备名称;预约核销订单中用于识别核销机器。 */
|
||||
@ExcelProperty({"商户设备", "核销机器"})
|
||||
private String hardName;
|
||||
|
||||
/** 实际关联设备的设备类型中文名称;未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"商户设备", "设备类型"})
|
||||
private String hardType;
|
||||
|
||||
/** 订单商品摘要,来源于下单时保存的商品描述。 */
|
||||
@ExcelProperty({"订单", "商品内容"})
|
||||
@ColumnWidth(24)
|
||||
private String productDesc;
|
||||
|
||||
/** 用餐时间段中文名称;未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"订单", "用餐时段"})
|
||||
private String hourType;
|
||||
|
||||
/** 预约目标用餐日期;非预约订单可能为下单日期或空值,页面统一称为预约时间。 */
|
||||
@ExcelProperty({"订单", "预约时间"})
|
||||
@ColumnWidth(22)
|
||||
private LocalDate apptUseDate;
|
||||
|
||||
/** 支付方式中文名称;未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"支付", "支付方式"})
|
||||
private String payType;
|
||||
|
||||
/** 商品总价,单位为元并保留两位小数。 */
|
||||
@ExcelProperty({"支付", "商品总价(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal totalPrice;
|
||||
|
||||
/** 额外费用,单位为元并保留两位小数。 */
|
||||
@ExcelProperty({"支付", "额外支付(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal extraPrice;
|
||||
|
||||
/** 配送费用,单位为元并保留两位小数。 */
|
||||
@ExcelProperty({"支付", "配送费(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal deliveryPrice;
|
||||
|
||||
/** 实际支付金额,单位为元并保留两位小数。 */
|
||||
@ExcelProperty({"支付", "实际支付(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal payPrice;
|
||||
|
||||
/** 订单实际金额,单位为元,按实际支付金额扣除退款金额计算。 */
|
||||
@ExcelProperty({"支付", "订单实际(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal orderActualPrice;
|
||||
|
||||
/** 支付完成时间,格式为 yyyy-MM-dd HH:mm:ss;未支付时为空。 */
|
||||
@ExcelProperty({"支付", "支付时间"})
|
||||
@ColumnWidth(22)
|
||||
private String payTime;
|
||||
|
||||
/** 订单实际核销完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
|
||||
@ExcelProperty({"核销", "核销时间"})
|
||||
@ColumnWidth(22)
|
||||
private String verificationTime;
|
||||
|
||||
/** 订单退款状态中文名称;未记录时为空,未知历史编码保留原始值。 */
|
||||
@ExcelProperty({"退款", "退款状态"})
|
||||
private String refundStatus;
|
||||
|
||||
/** 已退款金额,单位为元并保留两位小数。 */
|
||||
@ExcelProperty({"退款", "退款金额(元)"})
|
||||
@NumberFormat("#,##0.00")
|
||||
private BigDecimal refundPrice;
|
||||
|
||||
/** 退款完成时间,格式为 yyyy-MM-dd HH:mm:ss。 */
|
||||
@ExcelProperty({"退款", "退款完成时间"})
|
||||
@ColumnWidth(22)
|
||||
private String refundTime;
|
||||
}
|
||||
+1
-1
@@ -86,7 +86,7 @@ public class HardOrderController {
|
||||
private final HardOrderSyncService hardOrderSyncService;
|
||||
private final HardCanteenTotalService canteenTotalService;
|
||||
|
||||
/** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */
|
||||
/** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */
|
||||
private final HardOrderExportService hardOrderExportService;
|
||||
|
||||
private final HardOrderMapper hardOrderMapper;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
/**
|
||||
* 硬件订单导出服务。
|
||||
*
|
||||
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传,
|
||||
* <p>该服务独立承担导出条件冻结、数据权限、分批查询、自定义表头写入、进度上报和文件中心上传,
|
||||
* 订单查询服务只保留订单业务查询职责。</p>
|
||||
*/
|
||||
public interface HardOrderExportService {
|
||||
|
||||
+330
-390
@@ -1,9 +1,9 @@
|
||||
package com.bsd.pjcloud.hard.service.impl.order;
|
||||
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.io.resource.ResourceUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.alibaba.excel.ExcelWriter;
|
||||
import com.alibaba.excel.write.metadata.WriteSheet;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
|
||||
import com.bsd.pjcloud.admin.api.dto.UserDTO;
|
||||
@@ -22,11 +22,16 @@ import com.bsd.pjcloud.common.data.datascope.DataScope;
|
||||
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
|
||||
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
|
||||
import com.bsd.pjcloud.common.security.util.SecurityUtils;
|
||||
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
|
||||
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
|
||||
import com.bsd.pjcloud.hard.api.dto.hard.CurrentUserHardTypeInfoDto;
|
||||
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
|
||||
import com.bsd.pjcloud.hard.api.vo.order.excel.HardOrderExportExcelVo;
|
||||
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
|
||||
import com.bsd.pjcloud.hard.service.HardManagerService;
|
||||
import com.bsd.pjcloud.hard.service.HardOrderExportService;
|
||||
@@ -36,13 +41,6 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.streaming.SXSSFSheet;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -52,6 +50,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -65,139 +64,132 @@ import java.util.stream.Collectors;
|
||||
* 硬件订单导出服务实现。
|
||||
*
|
||||
* <p>导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单,
|
||||
* 并在同一任务线程同步补充完整用户资料和商户名称。使用 SXSSF 保留原订单模板样式,
|
||||
* 完成后上传文件中心并返回下载地址。</p>
|
||||
* 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入,
|
||||
* 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
|
||||
/** 订单导出日志标签,用于定位分批查询、模板写入和文件上传异常。 */
|
||||
/**
|
||||
* 订单导出日志标签,用于定位分批查询、工作簿写入和文件上传异常。
|
||||
*/
|
||||
private static final String LOG_TAG = "硬件订单导出";
|
||||
|
||||
/** 订单导出任务的 Redis Key 业务命名空间。 */
|
||||
/**
|
||||
* 订单导出任务的 Redis Key 业务命名空间。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_REDIS_KEY = "hard:order:export";
|
||||
|
||||
/** 订单导出任务在进度弹窗中的展示名称。 */
|
||||
/**
|
||||
* 订单导出任务在进度弹窗中的展示名称。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_TASK_NAME = "订单记录导出";
|
||||
|
||||
/** 后台取得真实订单数前创建任务使用的占位总数。 */
|
||||
/**
|
||||
* 后台取得真实订单数前创建任务使用的占位总数。
|
||||
*/
|
||||
private static final int ORDER_EXPORT_INITIAL_TOTAL = 1;
|
||||
|
||||
/** 单次订单查询和 SXSSF 内存窗口使用的记录数。 */
|
||||
/**
|
||||
* 单次订单查询和 EasyExcel 流式写入使用的记录数。
|
||||
*/
|
||||
private static final int ORDER_EXPORT_BATCH_SIZE = 200;
|
||||
|
||||
/** 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。 */
|
||||
/**
|
||||
* 单次查询完整用户导出资料的最大数量,当前订单分片不会超过该值。
|
||||
*/
|
||||
private static final long USER_EXPORT_BATCH_SIZE = 500L;
|
||||
|
||||
/** 文件上传及异步任务成功收尾占用的额外进度步数。 */
|
||||
/**
|
||||
* 文件上传及异步任务成功收尾占用的额外进度步数。
|
||||
*/
|
||||
private static final int ORDER_EXPORT_EXTRA_PROGRESS_STEPS = 2;
|
||||
|
||||
/** 订单模板标题行的 Excel 零基行号。 */
|
||||
private static final int ORDER_TITLE_ROW_INDEX = 0;
|
||||
|
||||
/** 订单模板表头行的 Excel 零基行号。 */
|
||||
private static final int ORDER_HEADER_ROW_INDEX = 1;
|
||||
|
||||
/** 订单模板中明细占位行的 Excel 零基行号。 */
|
||||
private static final int ORDER_DETAIL_TEMPLATE_ROW_INDEX = 2;
|
||||
|
||||
/** 订单模板中合计占位行的 Excel 零基行号。 */
|
||||
private static final int ORDER_SUMMARY_TEMPLATE_ROW_INDEX = 3;
|
||||
|
||||
/** 订单明细从模板第三行开始写入。 */
|
||||
private static final int ORDER_DETAIL_START_ROW_INDEX = 2;
|
||||
|
||||
/** 订单明细之后为合计保留一行。 */
|
||||
private static final int ORDER_RESERVED_TAIL_ROW_COUNT = 1;
|
||||
|
||||
/** 既有订单模板在运行时扩展前固定包含的列数。 */
|
||||
private static final int ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT = 12;
|
||||
|
||||
/**
|
||||
* 订单导出的完整列标题,用户资料沿用用户预约导出的基础信息和身份字段口径。
|
||||
* EasyExcel 二级表头占用的固定行数。
|
||||
*/
|
||||
private static final String[] ORDER_EXPORT_HEADERS = {
|
||||
"行号", "商户", "用户编号", "姓名", "邮箱", "部门", "岗位", "手机号", "身份类型",
|
||||
"学号", "学校名称", "班级名称", "监护人手机号", "老师学号", "商品", "支付方式",
|
||||
"用餐时间段", "商品总价", "额外支付", "实际支付", "订单支付时间"
|
||||
};
|
||||
private static final int ORDER_EXPORT_HEADER_ROW_COUNT = 2;
|
||||
|
||||
/**
|
||||
* 各导出列复用的原模板明细样式来源列,保留文本、金额和日期列的既有显示格式。
|
||||
* 全部订单明细之后写入的金额合计行数量。
|
||||
*/
|
||||
private static final int[] ORDER_DETAIL_STYLE_SOURCE_COLUMNS = {
|
||||
0, 1, 0, 2, 3, 4, 4, 3, 4, 4, 4, 4, 4, 4, 5, 6, 7, 8, 9, 10, 11
|
||||
};
|
||||
private static final int ORDER_EXPORT_SUMMARY_ROW_COUNT = 1;
|
||||
|
||||
/**
|
||||
* 各导出列复用的原模板合计样式来源列,合并区末列单独保留右边框。
|
||||
* 单个订单工作表最多允许写入的明细数量。
|
||||
*/
|
||||
private static final int[] ORDER_SUMMARY_STYLE_SOURCE_COLUMNS = {
|
||||
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 6, 8, 9, 10, 11
|
||||
};
|
||||
|
||||
/** 各导出列宽度,单位为 Excel 字符宽度。 */
|
||||
private static final int[] ORDER_EXPORT_COLUMN_WIDTHS = {
|
||||
10, 20, 20, 16, 28, 20, 20, 18, 18, 18, 22, 18, 24, 18, 30, 18, 18, 16, 16, 16, 22
|
||||
};
|
||||
|
||||
/** 订单模板扩展后的固定列数。 */
|
||||
private static final int ORDER_EXPORT_COLUMN_COUNT = ORDER_EXPORT_HEADERS.length;
|
||||
|
||||
/** 商品总价在扩展模板中的 Excel 零基列号。 */
|
||||
private static final int ORDER_TOTAL_PRICE_COLUMN_INDEX = 17;
|
||||
|
||||
/** 额外支付在扩展模板中的 Excel 零基列号。 */
|
||||
private static final int ORDER_EXTRA_PRICE_COLUMN_INDEX = 18;
|
||||
|
||||
/** 实际支付在扩展模板中的 Excel 零基列号。 */
|
||||
private static final int ORDER_PAY_PRICE_COLUMN_INDEX = 19;
|
||||
|
||||
/** 合计文本合并区域的最后一个 Excel 零基列号。 */
|
||||
private static final int ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX = 16;
|
||||
|
||||
/** 单个订单工作表最多允许写入的明细数量。 */
|
||||
private static final int MAX_ORDER_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(
|
||||
ORDER_DETAIL_START_ROW_INDEX, ORDER_RESERVED_TAIL_ROW_COUNT);
|
||||
ORDER_EXPORT_HEADER_ROW_COUNT, ORDER_EXPORT_SUMMARY_ROW_COUNT);
|
||||
|
||||
/** 订单导出使用的既有类路径模板。 */
|
||||
private static final String ORDER_EXPORT_TEMPLATE_PATH = "file/订单交易汇总.xlsx";
|
||||
/**
|
||||
* 订单导出工作表名称。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_SHEET_NAME = "订单记录";
|
||||
|
||||
/** 文件中心保存订单导出文件的业务目录。 */
|
||||
/**
|
||||
* 文件中心保存订单导出文件的业务目录。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_FILE_DIR = "hard-export";
|
||||
|
||||
/** 文件中心区分订单导出文件的业务类型编码。 */
|
||||
/**
|
||||
* 文件中心区分订单导出文件的业务类型编码。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_FILE_TYPE = "HARD_ORDER_EXPORT";
|
||||
|
||||
/** XLSX 文件的标准 MIME 类型。 */
|
||||
/**
|
||||
* XLSX 文件的标准 MIME 类型。
|
||||
*/
|
||||
private static final String ORDER_EXPORT_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
/** 异步订单导出文件名使用的时间格式。 */
|
||||
/**
|
||||
* 异步订单导出文件名使用的时间格式。
|
||||
*/
|
||||
private static final DateTimeFormatter ORDER_EXPORT_FILE_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
/** 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。 */
|
||||
/**
|
||||
* Excel 日期时间字段统一展示格式,避免 EasyExcel 依赖服务端默认格式。
|
||||
*/
|
||||
private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 订单 Mapper,负责在当前导出任务线程内按数据权限分页读取原始订单。
|
||||
*/
|
||||
private final HardOrderMapper hardOrderMapper;
|
||||
|
||||
/** 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。 */
|
||||
/**
|
||||
* 用户身份支持器,负责把身份筛选条件预转换为用户主键范围。
|
||||
*/
|
||||
private final HardUserIdentitySupport userIdentitySupport;
|
||||
|
||||
/** 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。 */
|
||||
/**
|
||||
* 用户远程服务,负责在当前导出任务线程内批量查询完整基础资料和身份资料。
|
||||
*/
|
||||
private final RemoteUserService remoteUserService;
|
||||
|
||||
/** 设备服务,用于冻结当前用户允许查看的设备类型。 */
|
||||
/**
|
||||
* 设备服务,用于冻结当前用户允许查看的设备类型。
|
||||
*/
|
||||
private final HardManagerService hardManagerService;
|
||||
|
||||
/** 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。 */
|
||||
/**
|
||||
* 商户远程服务,用于把单个商户筛选扩展为其现有子商户范围。
|
||||
*/
|
||||
private final RemoteMchService remoteMchService;
|
||||
|
||||
/** 文件中心远程服务,用于持久化异步生成的订单工作簿。 */
|
||||
/**
|
||||
* 文件中心远程服务,用于持久化异步生成的订单工作簿。
|
||||
*/
|
||||
private final RemoteFileService remoteFileService;
|
||||
|
||||
/** 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。 */
|
||||
/**
|
||||
* 通用异步任务执行器,负责恢复租户、安全和 Feign Token 上下文并持久化进度。
|
||||
*/
|
||||
private final AsyncTaskExecutorService asyncTaskExecutorService;
|
||||
|
||||
/**
|
||||
@@ -220,7 +212,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
* 避免部分失败文件被浏览器当作成功结果下载。</p>
|
||||
*
|
||||
* @param response 当前 HTTP 下载响应
|
||||
* @param dto 订单页面当前筛选条件
|
||||
* @param dto 订单页面当前筛选条件
|
||||
*/
|
||||
@Override
|
||||
public void export(HttpServletResponse response, HardOrderDto dto) {
|
||||
@@ -238,7 +230,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
/**
|
||||
* 在异步线程中生成订单工作簿并上传文件中心。
|
||||
*
|
||||
* @param query 已冻结权限和筛选条件的订单查询
|
||||
* @param query 已冻结权限和筛选条件的订单查询
|
||||
* @param reporter 当前异步任务进度报告器
|
||||
* @return 文件中心下载地址和业务文件名
|
||||
*/
|
||||
@@ -264,56 +256,76 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公共模板按页查询订单、流式写入明细和合计,并消费最终 XLSX 临时文件。
|
||||
* 按页查询订单并使用 EasyExcel 流式写入自定义二级表头工作簿。
|
||||
*
|
||||
* @param query 已冻结的订单查询条件
|
||||
* @param firstPage 已用于取得总数的第一页,避免重复查询首批订单
|
||||
* @param total 导出开始时按相同条件统计的订单总数
|
||||
* @param reporter 异步任务进度报告器;同步兼容导出时为空
|
||||
* <p>文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度,
|
||||
* 避免大批量订单长期占用请求线程和堆内存。</p>
|
||||
*
|
||||
* @param query 已冻结的订单查询条件
|
||||
* @param firstPage 已用于取得总数的第一页,避免重复查询首批订单
|
||||
* @param total 导出开始时按相同条件统计的订单总数
|
||||
* @param reporter 异步任务进度报告器;同步兼容导出时为空
|
||||
* @param fileConsumer 完整临时文件消费回调
|
||||
* @param <R> 文件消费结果类型
|
||||
* @param <R> 文件消费结果类型
|
||||
* @return 文件消费回调返回的结果
|
||||
*/
|
||||
private <R> R writeExportWorkbook(HardOrderDto query, Page<OrderExportRow> firstPage, long total,
|
||||
AsyncTaskProgressReporter reporter,
|
||||
BatchExcelExportTemplate.FileConsumer<R> fileConsumer) {
|
||||
Path outputPath = null;
|
||||
try {
|
||||
return BatchExcelExportTemplate.<OrderExportRow, OrderExportTemplateContext, R>builder(total)
|
||||
.batchSize(ORDER_EXPORT_BATCH_SIZE)
|
||||
.dataStartRowIndex(ORDER_DETAIL_START_ROW_INDEX)
|
||||
.reservedTailRowCount(ORDER_RESERVED_TAIL_ROW_COUNT)
|
||||
.temporaryFilePrefix("hard-order-export-")
|
||||
.templateSupplier(() -> ResourceUtil.getStream(ORDER_EXPORT_TEMPLATE_PATH))
|
||||
.templateInitializer(workbook -> prepareTemplate(workbook, query))
|
||||
.pageFetcher((pageNumber, batchSize) -> pageNumber == 1L
|
||||
? safeRecords(firstPage)
|
||||
: safeRecords(fetchExportPage(pageNumber, batchSize, query, false)))
|
||||
.rowWriter((sheet, rowIndex, dataIndex, item, context) -> {
|
||||
context.summary.add(item);
|
||||
writeDetailRow(sheet, rowIndex, dataIndex, item, context);
|
||||
})
|
||||
.footerWriter((sheet, rowIndex, processedTotal, context) ->
|
||||
writeSummaryRow(sheet, rowIndex, context))
|
||||
.progressListener((processedTotal, expectedTotal) -> {
|
||||
if (reporter != null) {
|
||||
reporter.advance("已写入 " + processedTotal + " / " + expectedTotal + " 条订单");
|
||||
}
|
||||
})
|
||||
.fileConsumer(fileConsumer)
|
||||
.execute();
|
||||
outputPath = Files.createTempFile("hard-order-export-", ".xlsx");
|
||||
OrderExportSummary summary = new OrderExportSummary();
|
||||
long processedTotal = 0L;
|
||||
long current = 1L;
|
||||
try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardOrderExportExcelVo.class).build()) {
|
||||
WriteSheet writeSheet = EasyExcel.writerSheet(ORDER_EXPORT_SHEET_NAME).build();
|
||||
Page<OrderExportRow> currentPage = firstPage;
|
||||
while (processedTotal < total) {
|
||||
List<OrderExportRow> records = safeRecords(currentPage);
|
||||
if (records.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
long remaining = total - processedTotal;
|
||||
int recordCount = Math.toIntExact(Math.min(records.size(), remaining));
|
||||
List<OrderExportRow> batch = records.subList(0, recordCount);
|
||||
List<HardOrderExportExcelVo> excelRows = new ArrayList<>(batch.size());
|
||||
for (OrderExportRow item : batch) {
|
||||
summary.add(item);
|
||||
excelRows.add(toExcelRow(item, processedTotal + excelRows.size() + 1L));
|
||||
}
|
||||
excelWriter.write(excelRows, writeSheet);
|
||||
processedTotal += batch.size();
|
||||
if (reporter != null) {
|
||||
reporter.advance("已写入 " + processedTotal + " / " + total + " 条订单");
|
||||
}
|
||||
if (processedTotal >= total) {
|
||||
break;
|
||||
}
|
||||
currentPage = fetchExportPage(++current, ORDER_EXPORT_BATCH_SIZE, query, false);
|
||||
}
|
||||
if (processedTotal != total) {
|
||||
throw new IllegalStateException("订单数据在导出期间发生变化,预期 " + total
|
||||
+ " 条,实际处理 " + processedTotal + " 条");
|
||||
}
|
||||
excelWriter.write(List.of(toSummaryExcelRow(summary)), writeSheet);
|
||||
}
|
||||
return fileConsumer.consume(outputPath);
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("生成订单导出文件失败", exception);
|
||||
} finally {
|
||||
deleteTemporaryExportFile(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定订单导出分页,数据范围和列表页保持为商户部门权限。
|
||||
*
|
||||
* @param current 从一开始的页码
|
||||
* @param size 单批最大订单数
|
||||
* @param query 已冻结的订单筛选条件
|
||||
* @param current 从一开始的页码
|
||||
* @param size 单批最大订单数
|
||||
* @param query 已冻结的订单筛选条件
|
||||
* @param searchCount 是否执行总数查询,仅第一页需要
|
||||
* @return 在当前线程同步装配完整用户资料和商户名称的导出分页
|
||||
*/
|
||||
@@ -340,7 +352,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
Map<Long, UserVO> userMap = fetchExportUserMap(userIds);
|
||||
Map<Long, String> merchantNameMap = fetchMerchantNameMap(merchantIds);
|
||||
return orders.stream().map(order -> new OrderExportRow(order, userMap.get(order.getUserId()),
|
||||
StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName())))
|
||||
StrUtil.blankToDefault(merchantNameMap.get(order.getMerchantId()), order.getMerchantName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -433,211 +445,86 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取模板明细、合计样式并移除占位行,为 SXSSF 连续追加订单腾出空间。
|
||||
* 将一条组合订单转换为二级表头导出对象。
|
||||
*
|
||||
* @param workbook 由既有订单模板创建的可编辑工作簿
|
||||
* @param query 已冻结的订单筛选条件,用于生成标题时间范围
|
||||
* @return 明细和合计写入共享的样式及金额汇总上下文
|
||||
* @param item 当前订单及其完整用户、商户资料
|
||||
* @param lineNumber 当前订单在全部导出结果中的一基序号
|
||||
* @return 已完成枚举翻译、金额和时间格式化的精简导出行
|
||||
*/
|
||||
private OrderExportTemplateContext prepareTemplate(XSSFWorkbook workbook, HardOrderDto query) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row titleRow = sheet.getRow(ORDER_TITLE_ROW_INDEX);
|
||||
Row headerRow = sheet.getRow(ORDER_HEADER_ROW_INDEX);
|
||||
Row detailTemplate = sheet.getRow(ORDER_DETAIL_TEMPLATE_ROW_INDEX);
|
||||
Row summaryTemplate = sheet.getRow(ORDER_SUMMARY_TEMPLATE_ROW_INDEX);
|
||||
if (titleRow == null || headerRow == null || detailTemplate == null || summaryTemplate == null) {
|
||||
throw new IllegalStateException("订单导出模板缺少标题、表头、明细或合计占位行");
|
||||
}
|
||||
extendTemplateColumns(sheet, titleRow, headerRow, detailTemplate, summaryTemplate);
|
||||
CellStyle[] detailStyles = readRowStyles(detailTemplate);
|
||||
CellStyle[] summaryStyles = readRowStyles(summaryTemplate);
|
||||
short detailRowHeight = detailTemplate.getHeight();
|
||||
short summaryRowHeight = summaryTemplate.getHeight();
|
||||
|
||||
titleRow.getCell(0).setCellValue(buildTitle(query));
|
||||
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
|
||||
CellRangeAddress mergedRegion = sheet.getMergedRegion(index);
|
||||
if (mergedRegion.getFirstRow() == ORDER_TITLE_ROW_INDEX
|
||||
|| mergedRegion.getFirstRow() >= ORDER_SUMMARY_TEMPLATE_ROW_INDEX) {
|
||||
sheet.removeMergedRegion(index);
|
||||
}
|
||||
}
|
||||
sheet.addMergedRegion(new CellRangeAddress(ORDER_TITLE_ROW_INDEX, ORDER_TITLE_ROW_INDEX,
|
||||
0, ORDER_EXPORT_COLUMN_COUNT - 1));
|
||||
sheet.removeRow(summaryTemplate);
|
||||
sheet.removeRow(detailTemplate);
|
||||
return new OrderExportTemplateContext(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在内存中把既有十二列表格扩展为用户基础信息、身份信息和订单信息完整列。
|
||||
*
|
||||
* @param sheet 订单模板工作表
|
||||
* @param titleRow 标题模板行
|
||||
* @param headerRow 表头模板行
|
||||
* @param detailTemplate 明细占位行
|
||||
* @param summaryTemplate 合计占位行
|
||||
*/
|
||||
private void extendTemplateColumns(Sheet sheet, Row titleRow, Row headerRow, Row detailTemplate,
|
||||
Row summaryTemplate) {
|
||||
if (ORDER_DETAIL_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT
|
||||
|| ORDER_SUMMARY_STYLE_SOURCE_COLUMNS.length != ORDER_EXPORT_COLUMN_COUNT
|
||||
|| ORDER_EXPORT_COLUMN_WIDTHS.length != ORDER_EXPORT_COLUMN_COUNT) {
|
||||
throw new IllegalStateException("订单导出列配置数量不一致");
|
||||
}
|
||||
Cell titleTemplateCell = titleRow.getCell(0);
|
||||
Cell headerTemplateCell = headerRow.getCell(0);
|
||||
if (titleTemplateCell == null || headerTemplateCell == null) {
|
||||
throw new IllegalStateException("订单导出模板缺少标题或表头样式");
|
||||
}
|
||||
CellStyle titleStyle = titleTemplateCell.getCellStyle();
|
||||
CellStyle headerStyle = headerTemplateCell.getCellStyle();
|
||||
CellStyle[] detailSourceStyles = readOriginalTemplateStyles(detailTemplate);
|
||||
CellStyle[] summarySourceStyles = readOriginalTemplateStyles(summaryTemplate);
|
||||
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
|
||||
Cell titleCell = getOrCreateCell(titleRow, column);
|
||||
titleCell.setCellStyle(titleStyle);
|
||||
Cell headerCell = getOrCreateCell(headerRow, column);
|
||||
headerCell.setCellStyle(headerStyle);
|
||||
headerCell.setCellValue(ORDER_EXPORT_HEADERS[column]);
|
||||
getOrCreateCell(detailTemplate, column)
|
||||
.setCellStyle(detailSourceStyles[ORDER_DETAIL_STYLE_SOURCE_COLUMNS[column]]);
|
||||
getOrCreateCell(summaryTemplate, column)
|
||||
.setCellStyle(summarySourceStyles[ORDER_SUMMARY_STYLE_SOURCE_COLUMNS[column]]);
|
||||
sheet.setColumnWidth(column, ORDER_EXPORT_COLUMN_WIDTHS[column] * 256);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板行单元格,不存在时按指定列创建。
|
||||
*
|
||||
* @param row 模板行
|
||||
* @param column Excel 零基列号
|
||||
* @return 已存在或新建的单元格
|
||||
*/
|
||||
private Cell getOrCreateCell(Row row, int column) {
|
||||
Cell cell = row.getCell(column);
|
||||
return cell == null ? row.createCell(column) : cell;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在扩展列之前读取原十二列表格的样式快照,避免映射过程中覆盖后续样式来源。
|
||||
*
|
||||
* @param row 原模板明细或合计占位行
|
||||
* @return 按原 A-L 列顺序排列的样式快照
|
||||
*/
|
||||
private CellStyle[] readOriginalTemplateStyles(Row row) {
|
||||
CellStyle[] styles = new CellStyle[ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT];
|
||||
for (int column = 0; column < ORDER_ORIGINAL_TEMPLATE_COLUMN_COUNT; column++) {
|
||||
Cell cell = row.getCell(column);
|
||||
if (cell == null) {
|
||||
throw new IllegalStateException("订单导出模板缺少第 " + (column + 1) + " 列样式");
|
||||
}
|
||||
styles[column] = cell.getCellStyle();
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取模板行每一列的样式,确保新增行延续原模板的字体、边框和对齐方式。
|
||||
*
|
||||
* @param row 明细或合计模板行
|
||||
* @return 按 A-U 列顺序排列的样式数组
|
||||
*/
|
||||
private CellStyle[] readRowStyles(Row row) {
|
||||
CellStyle[] styles = new CellStyle[ORDER_EXPORT_COLUMN_COUNT];
|
||||
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
|
||||
Cell cell = row.getCell(column);
|
||||
if (cell != null) {
|
||||
styles[column] = cell.getCellStyle();
|
||||
}
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条订单写入模板明细行,并在写入边界完成业务编码和金额单位转换。
|
||||
*
|
||||
* @param sheet 订单导出工作表
|
||||
* @param rowIndex 当前 Excel 零基行号
|
||||
* @param dataIndex 当前订单在全部导出数据中的零基序号
|
||||
* @param item 当前订单及其完整用户、商户导出资料
|
||||
* @param context 模板样式和跨批次金额汇总上下文
|
||||
*/
|
||||
private void writeDetailRow(SXSSFSheet sheet, int rowIndex, long dataIndex, OrderExportRow item,
|
||||
OrderExportTemplateContext context) {
|
||||
private HardOrderExportExcelVo toExcelRow(OrderExportRow item, long lineNumber) {
|
||||
HardOrderVo order = item.order;
|
||||
UserVO user = item.user;
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeight(context.detailRowHeight);
|
||||
setCell(row, 0, context.detailStyles[0], dataIndex + 1L);
|
||||
setCell(row, 1, context.detailStyles[1], item.merchantName);
|
||||
setCell(row, 2, context.detailStyles[2], formatUserId(order.getUserId()));
|
||||
setCell(row, 3, context.detailStyles[3], resolveUserName(user, order));
|
||||
setCell(row, 4, context.detailStyles[4], user == null ? null : user.getEmail());
|
||||
setCell(row, 5, context.detailStyles[5], user == null ? order.getUserDeptName()
|
||||
: StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName()));
|
||||
setCell(row, 6, context.detailStyles[6], user == null ? null : joinPostNames(user.getPostList()));
|
||||
setCell(row, 7, context.detailStyles[7], user == null ? order.getUserMobile()
|
||||
HardOrderExportExcelVo row = new HardOrderExportExcelVo();
|
||||
row.setLineNumber(Long.toString(lineNumber));
|
||||
row.setOrderType(resolveOrderType(order.getOrderType()));
|
||||
row.setOrderStatus(resolveOrderStatus(order.getOrderStatus()));
|
||||
row.setSaleSourceName(order.getSaleSourceName());
|
||||
row.setCreateTime(formatDateTime(order.getCreateTime()));
|
||||
|
||||
row.setUserName(resolveUserName(user, order));
|
||||
row.setPhone(user == null ? order.getUserMobile()
|
||||
: StrUtil.blankToDefault(user.getPhone(), order.getUserMobile()));
|
||||
setCell(row, 8, context.detailStyles[8], user == null ? null : joinIdentityNames(user.getIdentityTypes()));
|
||||
setCell(row, 9, context.detailStyles[9], user == null || user.getStudentInfo() == null
|
||||
row.setDeptName(user == null ? order.getUserDeptName()
|
||||
: StrUtil.blankToDefault(user.getDeptName(), order.getUserDeptName()));
|
||||
row.setPostNames(user == null ? null : joinPostNames(user.getPostList()));
|
||||
row.setIdentityTypeNames(user == null ? null : joinIdentityNames(user.getIdentityTypes()));
|
||||
row.setStudentNo(user == null || user.getStudentInfo() == null
|
||||
? null : user.getStudentInfo().getStudentNo());
|
||||
setCell(row, 10, context.detailStyles[10], user == null || user.getStudentInfo() == null
|
||||
row.setSchoolName(user == null || user.getStudentInfo() == null
|
||||
? null : user.getStudentInfo().getSchoolName());
|
||||
setCell(row, 11, context.detailStyles[11], user == null || user.getStudentInfo() == null
|
||||
row.setClassName(user == null || user.getStudentInfo() == null
|
||||
? null : user.getStudentInfo().getClassName());
|
||||
setCell(row, 12, context.detailStyles[12], user == null || user.getStudentInfo() == null
|
||||
row.setGuardianPhones(user == null || user.getStudentInfo() == null
|
||||
? null : user.getStudentInfo().getGuardianPhones());
|
||||
setCell(row, 13, context.detailStyles[13], user == null || user.getTeacherInfo() == null
|
||||
row.setTeacherNo(user == null || user.getTeacherInfo() == null
|
||||
? null : user.getTeacherInfo().getTeacherNo());
|
||||
setCell(row, 14, context.detailStyles[14], order.getProductDesc());
|
||||
setCell(row, 15, context.detailStyles[15], resolvePayType(order.getPayType()));
|
||||
setCell(row, 16, context.detailStyles[16], resolveHourType(order.getHourType()));
|
||||
setCell(row, 17, context.detailStyles[17], formatPrice(order.getTotalPrice()));
|
||||
setCell(row, 18, context.detailStyles[18], formatPrice(order.getExtraPrice()));
|
||||
setCell(row, 19, context.detailStyles[19], formatPrice(order.getPayPrice()));
|
||||
setCell(row, 20, context.detailStyles[20], formatDateTime(order.getPayTime()));
|
||||
|
||||
row.setMerchantName(item.merchantName);
|
||||
row.setHardName(order.getHardName());
|
||||
row.setHardType(resolveHardType(order.getHardType()));
|
||||
|
||||
row.setProductDesc(order.getProductDesc());
|
||||
row.setHourType(resolveHourType(order.getHourType()));
|
||||
row.setApptUseDate(order.getApptUseDate());
|
||||
|
||||
row.setPayType(resolvePayType(order.getPayType()));
|
||||
row.setTotalPrice(formatPrice(order.getTotalPrice()));
|
||||
row.setExtraPrice(formatPrice(order.getExtraPrice()));
|
||||
row.setDeliveryPrice(formatPrice(order.getDeliveryPrice()));
|
||||
row.setPayPrice(formatPrice(order.getPayPrice()));
|
||||
row.setOrderActualPrice(formatOrderActualPrice(order.getPayPrice(), order.getRefundPrice()));
|
||||
row.setPayTime(formatDateTime(order.getPayTime()));
|
||||
|
||||
row.setVerificationTime(formatDateTime(order.getVerificationTime()));
|
||||
|
||||
row.setRefundStatus(resolveRefundStatus(order.getRefundStatus()));
|
||||
row.setRefundPrice(formatPrice(order.getRefundPrice()));
|
||||
row.setRefundTime(formatDateTime(order.getRefundTime()));
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在所有订单明细之后写入金额合计,并合并金额列之前的说明区域。
|
||||
* 生成与明细相同结构的金额合计行,确保 EasyExcel 保持二级表头列顺序。
|
||||
*
|
||||
* @param sheet 订单导出工作表
|
||||
* @param rowIndex 合计行的 Excel 零基行号
|
||||
* @param context 模板样式和跨批次金额汇总上下文
|
||||
* @param summary 全部分片累计的订单金额
|
||||
* @return 首列显示“合计”且金额列写入元金额的导出行
|
||||
*/
|
||||
private void writeSummaryRow(SXSSFSheet sheet, int rowIndex, OrderExportTemplateContext context) {
|
||||
Row row = sheet.createRow(rowIndex);
|
||||
row.setHeight(context.summaryRowHeight);
|
||||
for (int column = 0; column < ORDER_EXPORT_COLUMN_COUNT; column++) {
|
||||
setCell(row, column, context.summaryStyles[column], null);
|
||||
}
|
||||
row.getCell(0).setCellValue("合计");
|
||||
row.getCell(ORDER_TOTAL_PRICE_COLUMN_INDEX)
|
||||
.setCellValue(context.summary.formatPrice(context.summary.totalPriceSum).doubleValue());
|
||||
row.getCell(ORDER_EXTRA_PRICE_COLUMN_INDEX)
|
||||
.setCellValue(context.summary.formatPrice(context.summary.extraPriceSum).doubleValue());
|
||||
row.getCell(ORDER_PAY_PRICE_COLUMN_INDEX)
|
||||
.setCellValue(context.summary.formatPrice(context.summary.payPriceSum).doubleValue());
|
||||
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0,
|
||||
ORDER_SUMMARY_MERGE_LAST_COLUMN_INDEX));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户主键按文本写入 Excel,避免雪花主键超过双精度整数范围后发生尾数丢失。
|
||||
*
|
||||
* @param userId UPMS 用户主键,允许为空
|
||||
* @return 十进制文本;输入为空时返回空
|
||||
*/
|
||||
private String formatUserId(Long userId) {
|
||||
return userId == null ? null : userId.toString();
|
||||
private HardOrderExportExcelVo toSummaryExcelRow(OrderExportSummary summary) {
|
||||
HardOrderExportExcelVo row = new HardOrderExportExcelVo();
|
||||
row.setLineNumber("合计");
|
||||
row.setTotalPrice(summary.formatPrice(summary.totalPriceSum));
|
||||
row.setExtraPrice(summary.formatPrice(summary.extraPriceSum));
|
||||
row.setDeliveryPrice(summary.formatPrice(summary.deliveryPriceSum));
|
||||
row.setPayPrice(summary.formatPrice(summary.payPriceSum));
|
||||
row.setOrderActualPrice(summary.formatPrice(summary.payPriceSum.subtract(summary.refundPriceSum)));
|
||||
row.setRefundPrice(summary.formatPrice(summary.refundPriceSum));
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依次按姓名、昵称、登录账号和订单历史展示名确定导出姓名。
|
||||
*
|
||||
* @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空
|
||||
* @param user UPMS 返回的完整用户资料,历史订单用户不存在时为空
|
||||
* @param order 当前订单,用于用户资料缺失时兼容历史展示字段
|
||||
* @return 用户导出姓名;所有来源均为空时返回空文本
|
||||
*/
|
||||
@@ -685,40 +572,57 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单元格设置模板样式和值,空值仍创建单元格以保留完整边框。
|
||||
* 将订单类型协议编码转换为中文描述,未知历史编码原样保留。
|
||||
*
|
||||
* @param row 目标行
|
||||
* @param column 目标列零基下标
|
||||
* @param style 模板样式,允许为空
|
||||
* @param value 单元格展示值,数字保持数字类型,其余按文本写入
|
||||
* @param orderType 数据库和跨端接口使用的订单类型编码
|
||||
* @return 订单类型中文描述、原始未知编码或空值
|
||||
*/
|
||||
private void setCell(Row row, int column, CellStyle style, Object value) {
|
||||
Cell cell = row.createCell(column);
|
||||
if (style != null) {
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
cell.setCellValue(number.doubleValue());
|
||||
} else if (value != null) {
|
||||
cell.setCellValue(value.toString());
|
||||
private String resolveOrderType(String orderType) {
|
||||
if (StrUtil.isBlank(orderType)) {
|
||||
return orderType;
|
||||
}
|
||||
HardOrderTypeEnum value = HardOrderTypeEnum.valueOfByType(orderType);
|
||||
return value == null ? orderType : value.getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单导出标题;仅支付时间范围沿用旧模板的标题展示规则。
|
||||
* 将订单状态协议编码安全转换为中文描述。
|
||||
*
|
||||
* @param query 订单筛选条件
|
||||
* @return 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题
|
||||
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。</p>
|
||||
*
|
||||
* @param orderStatus 数据库和跨端接口使用的订单状态编码
|
||||
* @return 订单状态中文描述、原始未知编码或空值
|
||||
*/
|
||||
private String buildTitle(HardOrderDto query) {
|
||||
if (query.getPayTimeList() != null && query.getPayTimeList().length == 2
|
||||
&& query.getPayTimeList()[0] != null && query.getPayTimeList()[1] != null) {
|
||||
return LocalDateTimeUtil.format(query.getPayTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)
|
||||
+ " 到 "
|
||||
+ LocalDateTimeUtil.format(query.getPayTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)
|
||||
+ "订单交易汇总";
|
||||
private String resolveOrderStatus(String orderStatus) {
|
||||
if (StrUtil.isBlank(orderStatus)) {
|
||||
return orderStatus;
|
||||
}
|
||||
return "订单交易汇总";
|
||||
for (HardOrderStatusEnum value : HardOrderStatusEnum.values()) {
|
||||
if (Objects.equals(value.getType(), orderStatus)) {
|
||||
return value.getDescription();
|
||||
}
|
||||
}
|
||||
return orderStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将设备类型协议编码安全转换为中文描述。
|
||||
*
|
||||
* <p>既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。</p>
|
||||
*
|
||||
* @param hardType 硬件设备类型编码
|
||||
* @return 设备类型中文描述、原始未知编码或空值
|
||||
*/
|
||||
private String resolveHardType(String hardType) {
|
||||
if (StrUtil.isBlank(hardType)) {
|
||||
return hardType;
|
||||
}
|
||||
for (HardTypeEnum value : HardTypeEnum.values()) {
|
||||
if (Objects.equals(value.getType(), hardType)) {
|
||||
return value.getDescription();
|
||||
}
|
||||
}
|
||||
return hardType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -736,17 +640,35 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将餐段协议编码转换为现有枚举中文描述,未知编码沿用旧导出规则输出空文本。
|
||||
* 将餐段协议编码转换为现有枚举中文描述,未知历史编码原样保留。
|
||||
*
|
||||
* @param hourType 用餐时间段协议编码
|
||||
* @return 餐段中文描述、空值或空文本
|
||||
* @return 餐段中文描述、原始未知编码或空值
|
||||
*/
|
||||
private String resolveHourType(String hourType) {
|
||||
if (StrUtil.isBlank(hourType)) {
|
||||
return hourType;
|
||||
}
|
||||
HardHourTypeEnum value = HardHourTypeEnum.valueOfByType(hourType);
|
||||
return value == null ? "" : value.getDescription();
|
||||
return value == null ? hourType : value.getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订单退款状态编码转换为中文名称,未知历史编码原样保留。
|
||||
*
|
||||
* @param refundStatus hard_order.refund_status 中保存的退款状态编码
|
||||
* @return 退款状态中文名称、原始未知编码或空值
|
||||
*/
|
||||
private String resolveRefundStatus(String refundStatus) {
|
||||
if (StrUtil.isBlank(refundStatus)) {
|
||||
return refundStatus;
|
||||
}
|
||||
for (HardRefundStatusEnum value : HardRefundStatusEnum.values()) {
|
||||
if (Objects.equals(value.getStatus(), refundStatus)) {
|
||||
return value.getName();
|
||||
}
|
||||
}
|
||||
return refundStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -761,24 +683,37 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订单支付时间转换为模板使用的标准日期时间文本。
|
||||
* 计算订单实际金额并转换为元,退款为空时按零分处理,允许结果为负数以保留真实差额。
|
||||
*
|
||||
* @param dateTime 订单支付时间,允许为空
|
||||
* @param payPrice 订单实际支付金额,单位为分;允许为空
|
||||
* @param refundPrice 订单已退款金额,单位为分;允许为空
|
||||
* @return 订单实际金额,单位为元并保留两位小数
|
||||
*/
|
||||
private BigDecimal formatOrderActualPrice(Number payPrice, Number refundPrice) {
|
||||
BigDecimal paid = payPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(payPrice.longValue());
|
||||
BigDecimal refunded = refundPrice == null ? BigDecimal.ZERO : BigDecimal.valueOf(refundPrice.longValue());
|
||||
return paid.subtract(refunded).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将订单日期时间转换为 Excel 统一使用的标准文本。
|
||||
*
|
||||
* @param dateTime 订单业务日期时间,允许为空
|
||||
* @return yyyy-MM-dd HH:mm:ss 文本;输入为空时返回空
|
||||
*/
|
||||
private String formatDateTime(LocalDateTime dateTime) {
|
||||
return dateTime == null ? null : LocalDateTimeUtil.format(dateTime, DatePattern.NORM_DATETIME_PATTERN);
|
||||
return dateTime == null ? null : dateTime.format(EXCEL_DATE_TIME_FORMATTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传完整订单工作簿并校验文件中心业务响应。
|
||||
*
|
||||
* @param file 已关闭并可读取的完整 XLSX 临时文件
|
||||
* @param file 已关闭并可读取的完整 XLSX 临时文件
|
||||
* @param fileName 用户下载时使用的文件名
|
||||
* @return 文件中心提供的下载地址
|
||||
* @throws Exception 文件读取或远程上传失败时抛出
|
||||
*/
|
||||
private String uploadExportFile(java.nio.file.Path file, String fileName) throws Exception {
|
||||
private String uploadExportFile(Path file, String fileName) throws Exception {
|
||||
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
|
||||
ORDER_EXPORT_CONTENT_TYPE, Files.readAllBytes(file));
|
||||
R<?> uploadResult = remoteFileService.innerUpload(exportFile, ORDER_EXPORT_FILE_DIR, null,
|
||||
@@ -804,6 +739,22 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理订单导出临时文件;清理失败只记录告警,不覆盖原始导出或上传结果。
|
||||
*
|
||||
* @param outputPath 待删除的临时 XLSX 路径,允许为空
|
||||
*/
|
||||
private void deleteTemporaryExportFile(Path outputPath) {
|
||||
if (outputPath == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(outputPath);
|
||||
} catch (Exception exception) {
|
||||
log.warn("|{}|清理临时文件失败,path:{}", LOG_TAG, outputPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结当前用户允许查看的设备类型、身份用户范围和单商户的子商户范围。
|
||||
*
|
||||
@@ -872,11 +823,13 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
target.setHardIds(copyList(source.getHardIds()));
|
||||
target.setMchIds(copyList(source.getMchIds()));
|
||||
target.setMchTypeCodes(copyList(source.getMchTypeCodes()));
|
||||
target.setMerchantTypeCodes(copyList(source.getMerchantTypeCodes()));
|
||||
target.setOrderStatusCodes(copyList(source.getOrderStatusCodes()));
|
||||
target.setOrderTypeCodes(copyList(source.getOrderTypeCodes()));
|
||||
target.setOrderPayTypeCodes(copyList(source.getOrderPayTypeCodes()));
|
||||
target.setExcludeOrderPayTypeCodes(copyList(source.getExcludeOrderPayTypeCodes()));
|
||||
target.setHardTypeCodes(copyList(source.getHardTypeCodes()));
|
||||
target.setHourTypeCodes(copyList(source.getHourTypeCodes()));
|
||||
target.setRefundStatusCodes(copyList(source.getRefundStatusCodes()));
|
||||
target.setSaleSourceCodes(copyList(source.getSaleSourceCodes()));
|
||||
target.setSaleStatusCodes(copyList(source.getSaleStatusCodes()));
|
||||
@@ -899,7 +852,7 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
* 复制可变列表,保留空值语义并隔离调用方后续修改。
|
||||
*
|
||||
* @param source 原始列表,允许为空
|
||||
* @param <T> 列表元素类型
|
||||
* @param <T> 列表元素类型
|
||||
* @return 独立列表副本;输入为空时返回空
|
||||
*/
|
||||
private <T> List<T> copyList(List<T> source) {
|
||||
@@ -946,62 +899,31 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单导出模板样式和跨批次金额汇总上下文。
|
||||
*/
|
||||
private static final class OrderExportTemplateContext {
|
||||
|
||||
/** 明细行 A-U 列的模板样式。 */
|
||||
private final CellStyle[] detailStyles;
|
||||
|
||||
/** 合计行 A-U 列的模板样式。 */
|
||||
private final CellStyle[] summaryStyles;
|
||||
|
||||
/** 明细模板行高度,单位为 POI twip。 */
|
||||
private final short detailRowHeight;
|
||||
|
||||
/** 合计模板行高度,单位为 POI twip。 */
|
||||
private final short summaryRowHeight;
|
||||
|
||||
/** 跨分页累计的订单金额汇总。 */
|
||||
private final OrderExportSummary summary = new OrderExportSummary();
|
||||
|
||||
/**
|
||||
* 创建订单导出模板上下文。
|
||||
*
|
||||
* @param detailStyles 明细行 A-U 列样式
|
||||
* @param summaryStyles 合计行 A-U 列样式
|
||||
* @param detailRowHeight 明细行高度
|
||||
* @param summaryRowHeight 合计行高度
|
||||
*/
|
||||
private OrderExportTemplateContext(CellStyle[] detailStyles, CellStyle[] summaryStyles,
|
||||
short detailRowHeight, short summaryRowHeight) {
|
||||
this.detailStyles = detailStyles;
|
||||
this.summaryStyles = summaryStyles;
|
||||
this.detailRowHeight = detailRowHeight;
|
||||
this.summaryRowHeight = summaryRowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条订单导出行,组合数据库订单、UPMS 完整用户资料和钱包商户名称。
|
||||
*/
|
||||
private static final class OrderExportRow {
|
||||
|
||||
/** 当前数据权限和筛选条件命中的订单记录。 */
|
||||
/**
|
||||
* 当前数据权限和筛选条件命中的订单记录。
|
||||
*/
|
||||
private final HardOrderVo order;
|
||||
|
||||
/** UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。 */
|
||||
/**
|
||||
* UPMS 返回的完整用户导出资料;历史用户不存在或不可见时为空。
|
||||
*/
|
||||
private final UserVO user;
|
||||
|
||||
/** 钱包服务返回的商户名称;历史商户不存在时为空。 */
|
||||
/**
|
||||
* 钱包服务返回的商户名称;历史商户不存在时为空。
|
||||
*/
|
||||
private final String merchantName;
|
||||
|
||||
/**
|
||||
* 创建订单导出行。
|
||||
*
|
||||
* @param order 当前订单记录
|
||||
* @param user 与订单用户主键匹配的完整用户资料
|
||||
* @param order 当前订单记录
|
||||
* @param user 与订单用户主键匹配的完整用户资料
|
||||
* @param merchantName 与订单商户主键匹配的商户名称
|
||||
*/
|
||||
private OrderExportRow(HardOrderVo order, UserVO user, String merchantName) {
|
||||
@@ -1016,31 +938,49 @@ public class HardOrderExportServiceImpl implements HardOrderExportService {
|
||||
*/
|
||||
private static final class OrderExportSummary {
|
||||
|
||||
/** 商品总价累计值,单位为分。 */
|
||||
/**
|
||||
* 商品总价累计值,单位为分。
|
||||
*/
|
||||
private BigDecimal totalPriceSum = BigDecimal.ZERO;
|
||||
|
||||
/** 额外支付累计值,单位为分。 */
|
||||
/**
|
||||
* 额外支付累计值,单位为分。
|
||||
*/
|
||||
private BigDecimal extraPriceSum = BigDecimal.ZERO;
|
||||
|
||||
/** 实际支付累计值,单位为分。 */
|
||||
/**
|
||||
* 配送费用累计值,单位为分。
|
||||
*/
|
||||
private BigDecimal deliveryPriceSum = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 实际支付累计值,单位为分。
|
||||
*/
|
||||
private BigDecimal payPriceSum = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 累计当前订单的三个金额字段,历史空值按零分处理。
|
||||
* 退款金额累计值,单位为分。
|
||||
*/
|
||||
private BigDecimal refundPriceSum = BigDecimal.ZERO;
|
||||
|
||||
/**
|
||||
* 累计当前订单的商品、额外、配送、实付和退款金额,历史空值按零分处理。
|
||||
*
|
||||
* @param item 当前订单及其导出关联资料
|
||||
*/
|
||||
private void add(OrderExportRow item) {
|
||||
totalPriceSum = addAmount(totalPriceSum, item.order.getTotalPrice());
|
||||
extraPriceSum = addAmount(extraPriceSum, item.order.getExtraPrice());
|
||||
deliveryPriceSum = addAmount(deliveryPriceSum, item.order.getDeliveryPrice());
|
||||
payPriceSum = addAmount(payPriceSum, item.order.getPayPrice());
|
||||
refundPriceSum = addAmount(refundPriceSum, item.order.getRefundPrice());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将非空分金额加入当前累计值。
|
||||
*
|
||||
* @param current 当前累计金额,单位为分
|
||||
* @param amount 待累计金额,单位为分;允许为空
|
||||
* @param amount 待累计金额,单位为分;允许为空
|
||||
* @return 累加后的分金额
|
||||
*/
|
||||
private BigDecimal addAmount(BigDecimal current, Number amount) {
|
||||
|
||||
+17
@@ -52,11 +52,15 @@
|
||||
a.refund_status,
|
||||
a.verification_time,
|
||||
a.apply_cancel_time,
|
||||
a.unfollow_time,
|
||||
a.android_order_code,
|
||||
a.is_offline_order,
|
||||
a.product_desc,
|
||||
a.offline_pay_success,
|
||||
a.address_name,
|
||||
a.shipment_no,
|
||||
a.shipped_time,
|
||||
a.delivered_time,
|
||||
a.create_by,
|
||||
a.create_time,
|
||||
a.update_by,
|
||||
@@ -90,6 +94,13 @@
|
||||
<if test="dto.merchantType != null and dto.merchantType != '' ">
|
||||
and a.merchant_type = #{dto.merchantType}
|
||||
</if>
|
||||
<!-- 页面商户类型多选与服务端注入的 mchTypeCodes 权限条件分别保留,最终按交集筛选。 -->
|
||||
<if test="dto.merchantTypeCodes != null and dto.merchantTypeCodes.size() != 0">
|
||||
and a.merchant_type in
|
||||
<foreach collection="dto.merchantTypeCodes" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.orderType != null and dto.orderType != '' ">
|
||||
and a.order_type = #{dto.orderType}
|
||||
</if>
|
||||
@@ -102,6 +113,12 @@
|
||||
<if test="dto.hourType != null and dto.hourType != '' ">
|
||||
and a.hour_type = #{dto.hourType}
|
||||
</if>
|
||||
<if test="dto.hourTypeCodes != null and dto.hourTypeCodes.size() != 0">
|
||||
and a.hour_type in
|
||||
<foreach collection="dto.hourTypeCodes" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.hourTypeIsNotEmpty != null and dto.hourTypeIsNotEmpty == true ">
|
||||
and ( a.hour_type is not null and a.hour_type not in ('-1') )
|
||||
</if>
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.bsd.pjcloud.thirdparty.dto.pay;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 第三方支付统计查询条件。
|
||||
*
|
||||
* <p>统计始终按支付订单的支付时间归属,时间范围由管理端明确传入,避免空条件扫描全历史支付数据。</p>
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "第三方支付统计查询条件")
|
||||
public class ThirdPayTotalQueryDto {
|
||||
|
||||
/**
|
||||
* 支付所属商户主键;为空表示查询当前权限范围内全部商户。
|
||||
*/
|
||||
@Schema(description = "商户ID")
|
||||
private Long mchId;
|
||||
|
||||
/**
|
||||
* 支付平台类型编码,沿用 mini_platform_type 字典;为空表示全部支付方式。
|
||||
*/
|
||||
@Schema(description = "支付平台类型")
|
||||
private String payPlatformType;
|
||||
|
||||
/**
|
||||
* 支付时间范围,必须包含开始和结束两个时间点,最大跨度一年。
|
||||
*/
|
||||
@Schema(description = "支付时间范围")
|
||||
private LocalDateTime[] payTimeList;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.bsd.pjcloud.thirdparty.vo.pay;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 第三方支付统计的商户与支付方式明细。
|
||||
*
|
||||
* <p>金额字段统一使用分传输;管理端展示时再转换为元。退款快照无法还原金额时,
|
||||
* 对应不可核对数量会增加且不会把未知金额当作零。</p>
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "第三方支付统计明细")
|
||||
public class ThirdPayTotalRowVo {
|
||||
|
||||
/** 商户主键;历史订单缺失商户时允许为空。 */
|
||||
private Long mchId;
|
||||
|
||||
/** 商户名称;远程商户服务不可用时返回商户信息不可用。 */
|
||||
private String mchName;
|
||||
|
||||
/** 支付平台类型编码,未知历史编码原样保留。 */
|
||||
private String payPlatformType;
|
||||
|
||||
/** 支付成功订单数,按支付订单主键去重。 */
|
||||
private Long payOrderCount = 0L;
|
||||
|
||||
/** 支付成功金额,单位分。 */
|
||||
private Long payAmount = 0L;
|
||||
|
||||
/** 退款成功订单数,按支付订单主键去重。 */
|
||||
private Long refundOrderCount = 0L;
|
||||
|
||||
/** 可从退款快照解析出的退款金额,单位分。 */
|
||||
private Long refundAmount = 0L;
|
||||
|
||||
/** 分账成功记录数。 */
|
||||
private Long fzSuccessCount = 0L;
|
||||
|
||||
/** 分账成功金额,单位分。 */
|
||||
private Long fzSuccessAmount = 0L;
|
||||
|
||||
/** 成功退分账记录数。 */
|
||||
private Long fzRefundSuccessCount = 0L;
|
||||
|
||||
/** 可从退分账快照解析出的金额,单位分。 */
|
||||
private Long fzRefundSuccessAmount = 0L;
|
||||
|
||||
/** 分账成功金额扣除已解析退分账金额后的平台净收款,单位分。 */
|
||||
private Long platformNetAmount;
|
||||
|
||||
/** 退分账金额是否全部可由历史快照核对。 */
|
||||
private boolean platformNetAmountComplete = true;
|
||||
|
||||
/** 退款成功但金额无法从历史快照解析的订单数。 */
|
||||
private Long refundAmountUnavailableCount = 0L;
|
||||
|
||||
/** 成功退分账但金额无法从历史快照解析的记录数。 */
|
||||
private Long fzRefundAmountUnavailableCount = 0L;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.bsd.pjcloud.thirdparty.vo.pay;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 第三方支付统计接口返回值。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "第三方支付统计结果")
|
||||
public class ThirdPayTotalStatisticsVo {
|
||||
|
||||
/** 按商户和支付方式分组的明细,未知维度也必须保留。 */
|
||||
private List<ThirdPayTotalRowVo> rows = new ArrayList<>();
|
||||
|
||||
/** 当前查询范围内全部明细的合计。 */
|
||||
private ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo();
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.bsd.pjcloud.thirdparty.vo.pay;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 第三方支付统计范围合计。
|
||||
*
|
||||
* <p>字段口径与明细一致,维度字段为空表示全部商户和支付方式。</p>
|
||||
*/
|
||||
@Schema(description = "第三方支付统计合计")
|
||||
public class ThirdPayTotalSummaryVo extends ThirdPayTotalRowVo {
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.bsd.pjcloud.thirdparty.controller.pay;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
|
||||
import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 第三方支付统计接口。
|
||||
*/
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/third-pay-total")
|
||||
@Tag(description = "third-pay-total", name = "第三方支付统计")
|
||||
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
|
||||
public class ThirdPayTotalController {
|
||||
|
||||
/** 第三方支付统计服务,负责校验范围并完成聚合解析。 */
|
||||
private final ThirdPayTotalService thirdPayTotalService;
|
||||
|
||||
/**
|
||||
* 按商户和支付方式统计支付订单、退款、分账和平台净收款。
|
||||
*
|
||||
* @param query 统计查询条件,支付时间范围必须完整且不超过一年
|
||||
* @return 统一响应包装的统计结果
|
||||
*/
|
||||
@PostMapping("/statistics")
|
||||
@Operation(summary = "第三方支付统计", description = "按商户和支付方式统计支付、退款、分账及平台净收款")
|
||||
@PreAuthorize("@pms.hasPermission('third_thirdPayTotal_view')")
|
||||
public R<ThirdPayTotalStatisticsVo> statistics(@RequestBody ThirdPayTotalQueryDto query) {
|
||||
return R.ok(thirdPayTotalService.statistics(query));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.bsd.pjcloud.thirdparty.mapper.pay;
|
||||
|
||||
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 第三方支付统计数据访问接口。
|
||||
*
|
||||
* <p>支付和分账分别聚合,退款快照单独读取,避免支付订单与多条分账记录直接连接后重复累加金额。</p>
|
||||
*/
|
||||
@Mapper
|
||||
public interface ThirdPayTotalMapper {
|
||||
|
||||
/**
|
||||
* 查询支付成功订单的分组数量与金额。
|
||||
*
|
||||
* @param query 已校验且包含完整支付时间范围的查询条件
|
||||
* @return 按商户和支付方式分组的成功支付聚合;无数据时返回空列表
|
||||
*/
|
||||
List<ThirdPayTotalAggregateRecord> selectPaySuccessAggregates(@Param("query") ThirdPayTotalQueryDto query);
|
||||
|
||||
/**
|
||||
* 查询退款成功订单的历史快照,每个支付订单最多返回一条。
|
||||
*
|
||||
* @param query 已校验且包含完整支付时间范围的查询条件
|
||||
* @return 退款成功订单及三级退款快照,供 Service 按渠道精确解析金额
|
||||
*/
|
||||
List<ThirdPayTotalRefundRecord> selectRefundRecords(@Param("query") ThirdPayTotalQueryDto query);
|
||||
|
||||
/**
|
||||
* 查询分账成功记录的分组数量与金额。
|
||||
*
|
||||
* @param query 已校验且包含完整支付时间范围的查询条件
|
||||
* @return 按关联支付订单商户和支付方式分组的分账成功聚合
|
||||
*/
|
||||
List<ThirdPayTotalAggregateRecord> selectFzSuccessAggregates(@Param("query") ThirdPayTotalQueryDto query);
|
||||
|
||||
/**
|
||||
* 查询成功退分账记录及其快照。
|
||||
*
|
||||
* @param query 已校验且包含完整支付时间范围的查询条件
|
||||
* @return 每条成功退分账记录及其请求、响应快照
|
||||
*/
|
||||
List<ThirdPayTotalFzRefundRecord> selectFzRefundRecords(@Param("query") ThirdPayTotalQueryDto query);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.bsd.pjcloud.thirdparty.service;
|
||||
|
||||
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
|
||||
|
||||
/**
|
||||
* 第三方支付统计服务。
|
||||
*/
|
||||
public interface ThirdPayTotalService {
|
||||
|
||||
/**
|
||||
* 按商户和支付方式查询支付、退款、分账及平台净收款统计。
|
||||
*
|
||||
* @param query 已校验的支付时间、商户和支付方式条件
|
||||
* @return 统计明细及范围合计,历史未知维度不会被丢弃
|
||||
*/
|
||||
ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query);
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
package com.bsd.pjcloud.thirdparty.service.impl.pay;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
|
||||
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper;
|
||||
import com.bsd.pjcloud.thirdparty.service.ThirdPayTotalService;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalStatisticsSupport;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalSummaryVo;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 第三方支付统计服务实现。
|
||||
*
|
||||
* <p>支付成功、退款成功、分账成功和退分账成功分别查询;退款快照只保留当前退款事实,
|
||||
* 因此金额无法还原时记录不可核对数量,并保留已解析的部分金额。</p>
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ThirdPayTotalServiceImpl implements ThirdPayTotalService {
|
||||
|
||||
/** 统计查询 Mapper,负责按支付时间和当前租户数据聚合事实。 */
|
||||
private final ThirdPayTotalMapper thirdPayTotalMapper;
|
||||
|
||||
/** 商户远程服务,用于批量补充统计行的商户名称。 */
|
||||
private final RemoteMchService remoteMchService;
|
||||
|
||||
/** 微信支付平台编码,持久化于 third_pay_order.pay_platform_type。 */
|
||||
private static final String WECHAT_PLATFORM = "10";
|
||||
|
||||
/** 支付宝支付平台编码,持久化于 third_pay_order.pay_platform_type。 */
|
||||
private static final String ALIPAY_PLATFORM = "20";
|
||||
|
||||
/** 退款快照解析优先级,必须先读取最终回调再读取渠道响应和发起请求。 */
|
||||
private static final List<String> WECHAT_REFUND_KEYS = List.of("refund_fee", "settlement_refund_fee", "refund");
|
||||
|
||||
/** 支付宝退款快照中的金额字段优先级,单位为元。 */
|
||||
private static final List<String> ALIPAY_REFUND_KEYS = List.of("refund_fee", "send_back_fee", "refundAmount");
|
||||
|
||||
/**
|
||||
* 统计指定支付时间范围内的四类支付事实,并在内存中合并到商户与支付方式维度。
|
||||
*
|
||||
* @param query 已包含完整日期范围的查询条件
|
||||
* @return 统计明细及合计
|
||||
*/
|
||||
@Override
|
||||
public ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query) {
|
||||
validateQuery(query);
|
||||
Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap = new LinkedHashMap<>();
|
||||
applyAggregates(rowMap, thirdPayTotalMapper.selectPaySuccessAggregates(query), true);
|
||||
applyRefundRecords(rowMap, thirdPayTotalMapper.selectRefundRecords(query));
|
||||
applyAggregates(rowMap, thirdPayTotalMapper.selectFzSuccessAggregates(query), false);
|
||||
applyFzRefundRecords(rowMap, thirdPayTotalMapper.selectFzRefundRecords(query));
|
||||
List<ThirdPayTotalRowVo> rows = new ArrayList<>(rowMap.values());
|
||||
rows.sort(Comparator.comparing(ThirdPayTotalRowVo::getMchId, Comparator.nullsLast(Comparator.naturalOrder()))
|
||||
.thenComparing(ThirdPayTotalRowVo::getPayPlatformType, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
fillMerchantNames(rows);
|
||||
ThirdPayTotalSummaryVo total = summarize(rows);
|
||||
ThirdPayTotalStatisticsVo result = new ThirdPayTotalStatisticsVo();
|
||||
result.setRows(rows);
|
||||
result.setTotal(total);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验时间范围,拒绝空范围和超过一年的全历史扫描。
|
||||
*
|
||||
* @param query 统计查询条件
|
||||
*/
|
||||
void validateQuery(ThirdPayTotalQueryDto query) {
|
||||
if (query == null || query.getPayTimeList() == null || query.getPayTimeList().length != 2
|
||||
|| query.getPayTimeList()[0] == null || query.getPayTimeList()[1] == null) {
|
||||
throw new IllegalArgumentException("支付时间范围必须完整填写");
|
||||
}
|
||||
LocalDateTime start = query.getPayTimeList()[0];
|
||||
LocalDateTime end = query.getPayTimeList()[1];
|
||||
if (start.isAfter(end)) {
|
||||
throw new IllegalArgumentException("支付时间范围开始时间不能晚于结束时间");
|
||||
}
|
||||
if (end.isAfter(start.plusYears(1))) {
|
||||
throw new IllegalArgumentException("支付统计时间范围不能超过一年");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库中的支付成功或分账成功聚合结果写入内存行。
|
||||
*
|
||||
* @param rowMap 统计行索引
|
||||
* @param records SQL 聚合结果
|
||||
* @param paySuccess 是否写入支付成功指标;否则写入分账成功指标
|
||||
*/
|
||||
private void applyAggregates(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
|
||||
List<ThirdPayTotalAggregateRecord> records, boolean paySuccess) {
|
||||
if (records == null) return;
|
||||
for (ThirdPayTotalAggregateRecord record : records) {
|
||||
if (record == null) continue;
|
||||
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
|
||||
if (paySuccess) {
|
||||
row.setPayOrderCount(safeAdd(row.getPayOrderCount(), zeroIfNull(record.getRecordCount())));
|
||||
row.setPayAmount(safeAdd(row.getPayAmount(), zeroIfNull(record.getAmount())));
|
||||
} else {
|
||||
row.setFzSuccessCount(safeAdd(row.getFzSuccessCount(), zeroIfNull(record.getRecordCount())));
|
||||
row.setFzSuccessAmount(safeAdd(row.getFzSuccessAmount(), zeroIfNull(record.getAmount())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归并退款成功订单并解析三级退款快照。
|
||||
*
|
||||
* @param rowMap 统计行索引
|
||||
* @param records 退款成功订单快照列表
|
||||
*/
|
||||
private void applyRefundRecords(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
|
||||
List<ThirdPayTotalRefundRecord> records) {
|
||||
if (records == null) return;
|
||||
for (ThirdPayTotalRefundRecord record : records) {
|
||||
if (record == null) continue;
|
||||
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
|
||||
row.setRefundOrderCount(safeAdd(row.getRefundOrderCount(), 1L));
|
||||
Long amount = parseRefundAmount(record);
|
||||
if (amount == null) {
|
||||
row.setRefundAmountUnavailableCount(safeAdd(row.getRefundAmountUnavailableCount(), 1L));
|
||||
} else {
|
||||
row.setRefundAmount(safeAdd(row.getRefundAmount(), amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归并成功退分账记录,解析未知金额时将净收款标为部分可核对。
|
||||
*
|
||||
* @param rowMap 统计行索引
|
||||
* @param records 成功退分账快照列表
|
||||
*/
|
||||
private void applyFzRefundRecords(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
|
||||
List<ThirdPayTotalFzRefundRecord> records) {
|
||||
if (records == null) return;
|
||||
for (ThirdPayTotalFzRefundRecord record : records) {
|
||||
if (record == null) continue;
|
||||
ThirdPayTotalRowVo row = row(rowMap, record.getMchId(), record.getPayPlatformType());
|
||||
row.setFzRefundSuccessCount(safeAdd(row.getFzRefundSuccessCount(), 1L));
|
||||
Long amount = parseFzRefundAmount(record);
|
||||
if (amount == null) {
|
||||
row.setFzRefundAmountUnavailableCount(safeAdd(row.getFzRefundAmountUnavailableCount(), 1L));
|
||||
row.setPlatformNetAmountComplete(false);
|
||||
} else {
|
||||
row.setFzRefundSuccessAmount(safeAdd(row.getFzRefundSuccessAmount(), amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建单个统计维度行,并初始化平台净收款为已知的分账成功金额。
|
||||
*
|
||||
* @param rowMap 统计行索引
|
||||
* @param mchId 商户主键
|
||||
* @param payPlatformType 支付方式编码
|
||||
* @return 可变统计行
|
||||
*/
|
||||
private ThirdPayTotalRowVo row(Map<ThirdPayTotalStatisticsSupport, ThirdPayTotalRowVo> rowMap,
|
||||
Long mchId, String payPlatformType) {
|
||||
ThirdPayTotalStatisticsSupport key = ThirdPayTotalStatisticsSupport.of(mchId, payPlatformType);
|
||||
return rowMap.computeIfAbsent(key, ignored -> {
|
||||
ThirdPayTotalRowVo value = new ThirdPayTotalRowVo();
|
||||
value.setMchId(mchId);
|
||||
value.setPayPlatformType(key.payPlatformType());
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为每行计算平台净收款;未知退分账金额只影响完整性标记,不被当成零扣除。
|
||||
*
|
||||
* @param row 统计明细行
|
||||
*/
|
||||
private void calculateNetAmount(ThirdPayTotalRowVo row) {
|
||||
row.setPlatformNetAmount(safeAdd(row.getFzSuccessAmount(), -zeroIfNull(row.getFzRefundSuccessAmount())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量补充商户名称,远程失败时不丢弃统计维度。
|
||||
*
|
||||
* @param rows 统计明细
|
||||
*/
|
||||
private void fillMerchantNames(List<ThirdPayTotalRowVo> rows) {
|
||||
if (rows == null || rows.isEmpty()) return;
|
||||
List<Long> mchIds = rows.stream().map(ThirdPayTotalRowVo::getMchId).filter(id -> id != null).distinct().toList();
|
||||
Map<Long, String> merchantMap = new HashMap<>();
|
||||
if (!mchIds.isEmpty()) {
|
||||
try {
|
||||
R<List<MchManageVo>> response = remoteMchService.getByIds(mchIds);
|
||||
if (response != null && response.isOk() && response.getData() != null) {
|
||||
for (MchManageVo merchant : response.getData()) {
|
||||
if (merchant != null && merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) {
|
||||
merchantMap.putIfAbsent(merchant.getId(), merchant.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.warn("第三方支付统计补充商户名称失败,errorType:{}", exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
for (ThirdPayTotalRowVo row : rows) {
|
||||
if (row.getMchId() == null) {
|
||||
row.setMchName("未记录商户");
|
||||
} else {
|
||||
row.setMchName(merchantMap.getOrDefault(row.getMchId(), "商户信息不可用"));
|
||||
}
|
||||
calculateNetAmount(row);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算范围合计,完整性只要有一条明细不可核对就置为 false。
|
||||
*
|
||||
* @param rows 明细列表
|
||||
* @return 合计行
|
||||
*/
|
||||
private ThirdPayTotalSummaryVo summarize(List<ThirdPayTotalRowVo> rows) {
|
||||
ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo();
|
||||
if (rows == null) return total;
|
||||
for (ThirdPayTotalRowVo row : rows) {
|
||||
total.setPayOrderCount(safeAdd(total.getPayOrderCount(), zeroIfNull(row.getPayOrderCount())));
|
||||
total.setPayAmount(safeAdd(total.getPayAmount(), zeroIfNull(row.getPayAmount())));
|
||||
total.setRefundOrderCount(safeAdd(total.getRefundOrderCount(), zeroIfNull(row.getRefundOrderCount())));
|
||||
total.setRefundAmount(safeAdd(total.getRefundAmount(), zeroIfNull(row.getRefundAmount())));
|
||||
total.setFzSuccessCount(safeAdd(total.getFzSuccessCount(), zeroIfNull(row.getFzSuccessCount())));
|
||||
total.setFzSuccessAmount(safeAdd(total.getFzSuccessAmount(), zeroIfNull(row.getFzSuccessAmount())));
|
||||
total.setFzRefundSuccessCount(safeAdd(total.getFzRefundSuccessCount(), zeroIfNull(row.getFzRefundSuccessCount())));
|
||||
total.setFzRefundSuccessAmount(safeAdd(total.getFzRefundSuccessAmount(), zeroIfNull(row.getFzRefundSuccessAmount())));
|
||||
total.setRefundAmountUnavailableCount(safeAdd(total.getRefundAmountUnavailableCount(), zeroIfNull(row.getRefundAmountUnavailableCount())));
|
||||
total.setFzRefundAmountUnavailableCount(safeAdd(total.getFzRefundAmountUnavailableCount(), zeroIfNull(row.getFzRefundAmountUnavailableCount())));
|
||||
total.setPlatformNetAmountComplete(total.isPlatformNetAmountComplete() && row.isPlatformNetAmountComplete());
|
||||
}
|
||||
calculateNetAmount(total);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从退款订单按回调、响应、请求优先级解析退款金额。
|
||||
*
|
||||
* @param record 退款快照记录
|
||||
* @return 退款金额(分);无法解析时返回空
|
||||
*/
|
||||
Long parseRefundAmount(ThirdPayTotalRefundRecord record) {
|
||||
if (record == null) return null;
|
||||
// Arrays.asList 允许历史快照为空;List.of 遇到任一 null 会在解析前直接抛出异常。
|
||||
List<String> snapshots = Arrays.asList(record.getRefundCallbackRes(), record.getRefundRes(), record.getRefundRep());
|
||||
for (String snapshot : snapshots) {
|
||||
Long amount = parsePlatformAmount(snapshot, record.getPayPlatformType());
|
||||
if (amount != null) return amount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从微信或支付宝退分账快照解析金额;支付宝金额先以 BigDecimal 元转分。
|
||||
*
|
||||
* @param record 退分账快照记录
|
||||
* @return 退分账金额(分);无法解析时返回空
|
||||
*/
|
||||
Long parseFzRefundAmount(ThirdPayTotalFzRefundRecord record) {
|
||||
if (record == null) return null;
|
||||
if (WECHAT_PLATFORM.equals(record.getPayPlatformType())) {
|
||||
Long amount = parseWechatFzAmount(record.getFzRefundRes());
|
||||
return amount != null ? amount : parseWechatFzAmount(record.getFzRefundReq());
|
||||
}
|
||||
if (ALIPAY_PLATFORM.equals(record.getPayPlatformType())) {
|
||||
Long amount = parseAlipayRoyaltyAmount(record.getFzRefundReq());
|
||||
return amount != null ? amount : parseAlipayRoyaltyAmount(record.getFzRefundRes());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析普通支付退款快照金额。
|
||||
*
|
||||
* @param snapshot JSON 快照
|
||||
* @param platform 支付平台编码
|
||||
* @return 分金额;JSON 无法识别时为空
|
||||
*/
|
||||
private Long parsePlatformAmount(String snapshot, String platform) {
|
||||
if (StrUtil.isBlank(snapshot)) return null;
|
||||
try {
|
||||
JSONObject object = JSONUtil.parseObj(snapshot);
|
||||
if (WECHAT_PLATFORM.equals(platform)) {
|
||||
return parseFenCandidate(object, WECHAT_REFUND_KEYS);
|
||||
}
|
||||
if (ALIPAY_PLATFORM.equals(platform)) {
|
||||
return parseYuanCandidate(object, ALIPAY_REFUND_KEYS);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.debug("第三方支付统计退款快照解析失败,errorType:{}", exception.getClass().getSimpleName());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析微信退分账响应或请求中的分金额。
|
||||
*
|
||||
* @param snapshot 微信退分账快照
|
||||
* @return 分金额;无法解析时为空
|
||||
*/
|
||||
private Long parseWechatFzAmount(String snapshot) {
|
||||
if (StrUtil.isBlank(snapshot)) return null;
|
||||
try {
|
||||
JSONObject object = JSONUtil.parseObj(snapshot);
|
||||
Object response = findPath(object, "refund", "response");
|
||||
Long amount = parseFenObject(response);
|
||||
if (amount != null) return amount;
|
||||
return parseFenObject(findFirstByKey(object, "amount"));
|
||||
} catch (Exception exception) {
|
||||
log.debug("第三方支付统计微信退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付宝退分账明细中的元金额并精确转换为分。
|
||||
*
|
||||
* @param snapshot 支付宝退分账请求或响应快照
|
||||
* @return 分金额;无法解析时为空
|
||||
*/
|
||||
private Long parseAlipayRoyaltyAmount(String snapshot) {
|
||||
if (StrUtil.isBlank(snapshot)) return null;
|
||||
try {
|
||||
Object root = JSONUtil.parse(snapshot);
|
||||
List<Object> royaltyAmounts = new ArrayList<>();
|
||||
collectAmountsUnderKey(root, Set.of("refundRoyaltys", "refundRoyaltyParameters"), royaltyAmounts);
|
||||
// 历史请求直接保存退款分账明细数组,不带 refundRoyaltyParameters 外层字段。
|
||||
if (royaltyAmounts.isEmpty() && root instanceof JSONArray) {
|
||||
collectValuesByKey(root, "amount", royaltyAmounts);
|
||||
}
|
||||
if (royaltyAmounts.isEmpty()) return null;
|
||||
long total = 0L;
|
||||
for (Object value : royaltyAmounts) {
|
||||
Long fen = yuanToFen(value);
|
||||
if (fen == null) return null;
|
||||
total = safeAdd(total, fen);
|
||||
}
|
||||
return total;
|
||||
} catch (Exception exception) {
|
||||
log.debug("第三方支付统计支付宝退分账快照解析失败,errorType:{}", exception.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定候选字段读取微信分金额。
|
||||
*
|
||||
* @param object JSON 对象
|
||||
* @param keys 候选字段顺序
|
||||
* @return 分金额;无有效字段时为空
|
||||
*/
|
||||
private Long parseFenCandidate(JSONObject object, Collection<String> keys) {
|
||||
for (String key : keys) {
|
||||
Long amount = parseFenObject(findFirstByKey(object, key));
|
||||
if (amount != null) return amount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定候选字段读取支付宝元金额。
|
||||
*
|
||||
* @param object JSON 对象
|
||||
* @param keys 候选字段顺序
|
||||
* @return 分金额;无有效字段时为空
|
||||
*/
|
||||
private Long parseYuanCandidate(JSONObject object, Collection<String> keys) {
|
||||
for (String key : keys) {
|
||||
Long amount = yuanToFen(findFirstByKey(object, key));
|
||||
if (amount != null) return amount;
|
||||
}
|
||||
return yuanToFen(findFirstByKey(object, "amount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将微信候选对象(数字或含 refund 字段的 amount 对象)解析为分。
|
||||
*
|
||||
* @param value JSON 节点
|
||||
* @return 分金额;无效或非正数时为空
|
||||
*/
|
||||
private Long parseFenObject(Object value) {
|
||||
if (value instanceof JSONObject object) {
|
||||
Object refund = findFirstByKey(object, "refund");
|
||||
if (refund != null) return parseFenObject(refund);
|
||||
Object amount = findFirstByKey(object, "amount");
|
||||
if (amount != null) return parseFenObject(amount);
|
||||
}
|
||||
return positiveLong(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把支付宝元金额用 BigDecimal 精确转换为分。
|
||||
*
|
||||
* @param value 元金额节点
|
||||
* @return 分金额;格式错误时为空
|
||||
*/
|
||||
private Long yuanToFen(Object value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
BigDecimal yuan = new BigDecimal(String.valueOf(value).trim());
|
||||
if (yuan.signum() <= 0) return null;
|
||||
// 渠道金额只允许精确到分,出现更小单位时返回不可核对,不能静默四舍五入改变历史金额。
|
||||
return yuan.movePointRight(2).longValueExact();
|
||||
} catch (Exception exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将微信分金额节点解析为正整数分。
|
||||
*
|
||||
* @param value 分金额节点
|
||||
* @return 分金额;格式错误时为空
|
||||
*/
|
||||
private Long positiveLong(Object value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
BigDecimal decimal = new BigDecimal(String.valueOf(value).trim());
|
||||
if (decimal.signum() <= 0 || decimal.stripTrailingZeros().scale() > 0) return null;
|
||||
return decimal.longValueExact();
|
||||
} catch (Exception exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路径读取嵌套 JSON 节点。
|
||||
*
|
||||
* @param root 根对象
|
||||
* @param path 路径字段
|
||||
* @return 节点;路径不存在时为空
|
||||
*/
|
||||
private Object findPath(Object root, String... path) {
|
||||
Object current = root;
|
||||
for (String part : path) {
|
||||
if (!(current instanceof JSONObject object)) return null;
|
||||
current = object.get(part);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先读取第一个指定字段,兼容微信 V2/V3 和渠道包装对象。
|
||||
*
|
||||
* @param root JSON 节点
|
||||
* @param key 字段名
|
||||
* @return 第一个字段值
|
||||
*/
|
||||
private Object findFirstByKey(Object root, String key) {
|
||||
if (root instanceof JSONObject object) {
|
||||
for (Map.Entry<String, Object> entry : object.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(key)) return entry.getValue();
|
||||
}
|
||||
for (Object value : object.values()) {
|
||||
Object result = findFirstByKey(value, key);
|
||||
if (result != null) return result;
|
||||
}
|
||||
} else if (root instanceof JSONArray array) {
|
||||
for (Object value : array) {
|
||||
Object result = findFirstByKey(value, key);
|
||||
if (result != null) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集指定字段下所有 amount 节点,用于多个支付宝退分账明细相加。
|
||||
*
|
||||
* @param root JSON 节点
|
||||
* @param keys 需要展开的明细容器字段
|
||||
* @param result 金额节点输出集合
|
||||
*/
|
||||
private void collectAmountsUnderKey(Object root, Set<String> keys, List<Object> result) {
|
||||
if (root instanceof JSONObject object) {
|
||||
for (Map.Entry<String, Object> entry : object.entrySet()) {
|
||||
if (keys.stream().anyMatch(key -> key.equalsIgnoreCase(entry.getKey()))) {
|
||||
collectValuesByKey(entry.getValue(), "amount", result);
|
||||
}
|
||||
collectAmountsUnderKey(entry.getValue(), keys, result);
|
||||
}
|
||||
} else if (root instanceof JSONArray array) {
|
||||
for (Object value : array) collectAmountsUnderKey(value, keys, result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先收集指定字段值。
|
||||
*
|
||||
* @param root JSON 节点
|
||||
* @param key 字段名
|
||||
* @param result 值输出集合
|
||||
*/
|
||||
private void collectValuesByKey(Object root, String key, List<Object> result) {
|
||||
if (root instanceof JSONObject object) {
|
||||
for (Map.Entry<String, Object> entry : object.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(key)) result.add(entry.getValue());
|
||||
collectValuesByKey(entry.getValue(), key, result);
|
||||
}
|
||||
} else if (root instanceof JSONArray array) {
|
||||
for (Object value : array) collectValuesByKey(value, key, result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将空数值转换为零,统一保护历史 NULL 聚合结果。
|
||||
*
|
||||
* @param value 可能为空的数值
|
||||
* @return 非空数值或零
|
||||
*/
|
||||
private long zeroIfNull(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对金额和次数执行溢出检查,避免 Long 溢出静默污染统计结果。
|
||||
*
|
||||
* @param left 左值
|
||||
* @param right 右值
|
||||
* @return 精确和
|
||||
*/
|
||||
private long safeAdd(long left, long right) {
|
||||
return Math.addExact(left, right);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.bsd.pjcloud.thirdparty.statistics;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* SQL 聚合阶段的商户与支付方式金额结果。
|
||||
*
|
||||
* <p>该对象只承载已经在数据库中按维度聚合的成功记录,金额单位为分,避免把大批成功订单完整加载到 JVM。</p>
|
||||
*/
|
||||
@Data
|
||||
public class ThirdPayTotalAggregateRecord {
|
||||
|
||||
/** 商户主键,历史订单缺失时允许为空。 */
|
||||
private Long mchId;
|
||||
|
||||
/** 支付平台类型编码,未知编码原样传递。 */
|
||||
private String payPlatformType;
|
||||
|
||||
/** 当前聚合维度的记录数量。 */
|
||||
private Long recordCount;
|
||||
|
||||
/** 当前聚合维度的金额,单位分。 */
|
||||
private Long amount;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.bsd.pjcloud.thirdparty.statistics;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 成功退分账记录及其历史快照。
|
||||
*/
|
||||
@Data
|
||||
public class ThirdPayTotalFzRefundRecord {
|
||||
|
||||
/** 分账记录主键,用于明确每一条成功退分账事实。 */
|
||||
private Long id;
|
||||
|
||||
/** 商户主键,来源于关联支付订单。 */
|
||||
private Long mchId;
|
||||
|
||||
/** 支付平台类型编码,来源于关联支付订单。 */
|
||||
private String payPlatformType;
|
||||
|
||||
/** 支付订单号,跨租户关联时与 tenant_id 一起作为连接条件。 */
|
||||
private String payNo;
|
||||
|
||||
/** 微信或支付宝发起退分账请求快照。 */
|
||||
private String fzRefundReq;
|
||||
|
||||
/** 微信或支付宝退分账响应快照。 */
|
||||
private String fzRefundRes;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.bsd.pjcloud.thirdparty.statistics;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 支付退款成功记录及其历史快照。
|
||||
*/
|
||||
@Data
|
||||
public class ThirdPayTotalRefundRecord {
|
||||
|
||||
/** 支付订单主键,用于退款订单去重。 */
|
||||
private Long id;
|
||||
|
||||
/** 商户主键。 */
|
||||
private Long mchId;
|
||||
|
||||
/** 支付平台类型编码。 */
|
||||
private String payPlatformType;
|
||||
|
||||
/** 支付订单号,便于诊断历史数据。 */
|
||||
private String payNo;
|
||||
|
||||
/** 最终退款回调快照,金额解析优先使用。 */
|
||||
private String refundCallbackRes;
|
||||
|
||||
/** 渠道退款响应快照,回调缺失时使用。 */
|
||||
private String refundRes;
|
||||
|
||||
/** 发起退款请求快照,响应缺失时使用。 */
|
||||
private String refundRep;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.bsd.pjcloud.thirdparty.statistics;
|
||||
|
||||
/**
|
||||
* 统计维度键,使用商户和支付方式组成稳定的内存聚合键。
|
||||
*
|
||||
* @param mchId 商户主键,历史订单未记录商户时允许为空
|
||||
* @param payPlatformType 支付平台类型编码,未知历史编码原样保留
|
||||
*/
|
||||
public record ThirdPayTotalStatisticsSupport(Long mchId, String payPlatformType) {
|
||||
|
||||
/**
|
||||
* 创建统计维度键并规范空支付方式,确保数据库 NULL 与空字符串不会产生重复维度。
|
||||
*
|
||||
* @param mchId 商户主键
|
||||
* @param payPlatformType 支付方式编码
|
||||
* @return 规范后的统计维度键
|
||||
*/
|
||||
public static ThirdPayTotalStatisticsSupport of(Long mchId, String payPlatformType) {
|
||||
return new ThirdPayTotalStatisticsSupport(mchId, payPlatformType == null || payPlatformType.isBlank() ? null : payPlatformType);
|
||||
}
|
||||
|
||||
}
|
||||
Vendored
+98
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
|
||||
<mapper namespace="com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper">
|
||||
<sql id="payBaseWhere">
|
||||
p.del_flag = '0'
|
||||
and p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}
|
||||
<if test="query.mchId != null">
|
||||
and p.mch_id = #{query.mchId}
|
||||
</if>
|
||||
<if test="query.payPlatformType != null and query.payPlatformType != ''">
|
||||
and p.pay_platform_type = #{query.payPlatformType}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="selectPaySuccessAggregates"
|
||||
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord">
|
||||
select p.mch_id as mchId,
|
||||
p.pay_platform_type as payPlatformType,
|
||||
count(distinct p.id) as recordCount,
|
||||
coalesce(sum(p.price), 0) as amount
|
||||
from third_pay_order p
|
||||
<where>
|
||||
<include refid="payBaseWhere"/>
|
||||
and p.status = '20'
|
||||
</where>
|
||||
group by p.mch_id, p.pay_platform_type
|
||||
</select>
|
||||
|
||||
<select id="selectRefundRecords"
|
||||
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord">
|
||||
select distinct p.id,
|
||||
p.mch_id as mchId,
|
||||
p.pay_platform_type as payPlatformType,
|
||||
p.pay_no as payNo,
|
||||
p.refund_callback_res as refundCallbackRes,
|
||||
p.refund_res as refundRes,
|
||||
p.refund_rep as refundRep
|
||||
from third_pay_order p
|
||||
<where>
|
||||
<include refid="payBaseWhere"/>
|
||||
and p.status = '200'
|
||||
</where>
|
||||
</select>
|
||||
|
||||
<select id="selectFzSuccessAggregates"
|
||||
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord">
|
||||
select base.mch_id as mchId,
|
||||
base.pay_platform_type as payPlatformType,
|
||||
coalesce(sum(fz.record_count), 0) as recordCount,
|
||||
coalesce(sum(fz.amount), 0) as amount
|
||||
from (
|
||||
select p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
|
||||
from third_pay_order p
|
||||
<where>
|
||||
<include refid="payBaseWhere"/>
|
||||
and p.pay_no is not null
|
||||
</where>
|
||||
group by p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
|
||||
) base
|
||||
inner join (
|
||||
select f.tenant_id,
|
||||
f.pay_no,
|
||||
count(*) as record_count,
|
||||
coalesce(sum(f.price), 0) as amount
|
||||
from third_fz_order f
|
||||
where f.del_flag = '0'
|
||||
and f.status = '20'
|
||||
and f.pay_no is not null
|
||||
group by f.tenant_id, f.pay_no
|
||||
) fz on fz.tenant_id = base.tenant_id and fz.pay_no = base.pay_no
|
||||
group by base.mch_id, base.pay_platform_type
|
||||
</select>
|
||||
|
||||
<select id="selectFzRefundRecords"
|
||||
resultType="com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord">
|
||||
select f.id,
|
||||
base.mch_id as mchId,
|
||||
base.pay_platform_type as payPlatformType,
|
||||
f.pay_no as payNo,
|
||||
f.fz_refund_req as fzRefundReq,
|
||||
f.fz_refund_res as fzRefundRes
|
||||
from (
|
||||
select p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
|
||||
from third_pay_order p
|
||||
<where>
|
||||
<include refid="payBaseWhere"/>
|
||||
and p.pay_no is not null
|
||||
</where>
|
||||
group by p.tenant_id, p.pay_no, p.mch_id, p.pay_platform_type
|
||||
) base
|
||||
inner join third_fz_order f
|
||||
on f.tenant_id = base.tenant_id
|
||||
and f.pay_no = base.pay_no
|
||||
and f.del_flag = '0'
|
||||
and f.status = '200'
|
||||
</select>
|
||||
</mapper>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.bsd.pjcloud.thirdparty.mapper.pay;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 第三方支付统计 SQL 契约测试,防止后续维护引入支付与分账扇出或移除租户连接条件。
|
||||
*/
|
||||
class ThirdPayTotalMapperSqlContractTest {
|
||||
|
||||
/** 第三方支付统计 MyBatis 映射资源路径。 */
|
||||
private static final String MAPPER_RESOURCE = "mapper/ThirdPayTotalMapper.xml";
|
||||
|
||||
/** 测试启动时读取的统计 Mapper XML。 */
|
||||
private static String mapperXml;
|
||||
|
||||
/**
|
||||
* 从测试类路径读取本轮实际参与编译的 Mapper XML。
|
||||
*
|
||||
* @throws IOException 资源读取失败时抛出
|
||||
*/
|
||||
@BeforeAll
|
||||
static void loadMapperXml() throws IOException {
|
||||
try (InputStream input = ThirdPayTotalMapperSqlContractTest.class.getClassLoader()
|
||||
.getResourceAsStream(MAPPER_RESOURCE)) {
|
||||
Assertions.assertNotNull(input, "第三方支付统计 Mapper XML 必须进入运行时类路径");
|
||||
mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付成功聚合不得连接分账表,避免一个支付订单因多条分账记录重复累加金额。
|
||||
*/
|
||||
@Test
|
||||
void paySuccessAggregateShouldNotJoinProfitSharingRows() {
|
||||
String sql = statement("selectPaySuccessAggregates");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(sql.contains("count(distinct p.id)")),
|
||||
() -> Assertions.assertTrue(sql.contains("p.status = '20'")),
|
||||
() -> Assertions.assertFalse(sql.contains("third_fz_order")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款快照必须独立读取并在 Service 解析,不允许 SQL 展开 JSON 后制造重复退款事实。
|
||||
*/
|
||||
@Test
|
||||
void refundRecordsShouldRemainIndependentFromJsonExpansion() {
|
||||
String sql = statement("selectRefundRecords");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(sql.contains("select distinct p.id")),
|
||||
() -> Assertions.assertTrue(sql.contains("p.status = '200'")),
|
||||
() -> Assertions.assertFalse(sql.toLowerCase().contains("json_extract")),
|
||||
() -> Assertions.assertFalse(sql.toLowerCase().contains("json_table")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分账聚合必须先按租户和支付单号收敛,再通过相同二元键连接支付基础集合。
|
||||
*/
|
||||
@Test
|
||||
void profitSharingAggregateShouldJoinByTenantAndPayNoAfterGrouping() {
|
||||
String sql = statement("selectFzSuccessAggregates");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(sql.contains("group by f.tenant_id, f.pay_no")),
|
||||
() -> Assertions.assertTrue(sql.contains("fz.tenant_id = base.tenant_id")),
|
||||
() -> Assertions.assertTrue(sql.contains("fz.pay_no = base.pay_no")),
|
||||
() -> Assertions.assertTrue(sql.contains("f.status = '20'")),
|
||||
() -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功退分账查询同样必须限制租户、支付单号、软删除和状态,不能跨租户串单。
|
||||
*/
|
||||
@Test
|
||||
void profitSharingRefundShouldKeepTenantAndSoftDeleteConditions() {
|
||||
String sql = statement("selectFzRefundRecords");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(sql.contains("f.tenant_id = base.tenant_id")),
|
||||
() -> Assertions.assertTrue(sql.contains("f.pay_no = base.pay_no")),
|
||||
() -> Assertions.assertTrue(sql.contains("f.del_flag = '0'")),
|
||||
() -> Assertions.assertTrue(sql.contains("f.status = '200'")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部查询必须复用支付时间、商户、支付方式和支付订单软删除条件。
|
||||
*/
|
||||
@Test
|
||||
void baseWhereShouldKeepRequiredFilters() {
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertTrue(mapperXml.contains("p.del_flag = '0'")),
|
||||
() -> Assertions.assertTrue(mapperXml.contains("p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]}")),
|
||||
() -> Assertions.assertTrue(mapperXml.contains("p.mch_id = #{query.mchId}")),
|
||||
() -> Assertions.assertTrue(mapperXml.contains("p.pay_platform_type = #{query.payPlatformType}")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取指定 ID 的 select 片段,兼容元素属性换行。
|
||||
*
|
||||
* @param id MyBatis 查询 ID
|
||||
* @return 对应查询 XML 片段
|
||||
*/
|
||||
private static String statement(String id) {
|
||||
int idIndex = mapperXml.indexOf("id=\"" + id + "\"");
|
||||
int start = mapperXml.lastIndexOf("<select", idIndex);
|
||||
int end = mapperXml.indexOf("</select>", idIndex);
|
||||
Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 查询:" + id);
|
||||
return mapperXml.substring(start, end);
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
package com.bsd.pjcloud.thirdparty.service.impl.pay;
|
||||
|
||||
import com.bsd.pjcloud.common.core.util.R;
|
||||
import com.bsd.pjcloud.thirdparty.dto.pay.ThirdPayTotalQueryDto;
|
||||
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdPayTotalMapper;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalAggregateRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalFzRefundRecord;
|
||||
import com.bsd.pjcloud.thirdparty.statistics.ThirdPayTotalRefundRecord;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalRowVo;
|
||||
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayTotalStatisticsVo;
|
||||
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
|
||||
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 第三方支付统计服务测试,覆盖历史快照解析、范围校验、聚合完整性和商户服务降级。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ThirdPayTotalServiceImplTest {
|
||||
|
||||
/** 支付统计 Mapper Mock,分别返回支付、退款、分账和退分账事实。 */
|
||||
@Mock
|
||||
private ThirdPayTotalMapper thirdPayTotalMapper;
|
||||
|
||||
/** 商户远程服务 Mock,用于验证名称补充和异常降级。 */
|
||||
@Mock
|
||||
private RemoteMchService remoteMchService;
|
||||
|
||||
/** 被测第三方支付统计服务。 */
|
||||
@InjectMocks
|
||||
private ThirdPayTotalServiceImpl service;
|
||||
|
||||
/**
|
||||
* 支付时间必须完整、正序且跨度不超过一年,防止空条件扫描全历史数据。
|
||||
*/
|
||||
@Test
|
||||
void validateQueryShouldRejectInvalidPayTimeRange() {
|
||||
ThirdPayTotalQueryDto missing = new ThirdPayTotalQueryDto();
|
||||
ThirdPayTotalQueryDto incomplete = new ThirdPayTotalQueryDto();
|
||||
incomplete.setPayTimeList(new LocalDateTime[]{LocalDateTime.of(2026, 1, 1, 0, 0), null});
|
||||
ThirdPayTotalQueryDto reversed = query(LocalDateTime.of(2026, 2, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
ThirdPayTotalQueryDto overOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 1, 1, 0, 0, 1));
|
||||
ThirdPayTotalQueryDto exactlyOneYear = query(LocalDateTime.of(2025, 1, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(null)),
|
||||
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(missing)),
|
||||
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(incomplete)),
|
||||
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(reversed)),
|
||||
() -> Assertions.assertThrows(IllegalArgumentException.class, () -> service.validateQuery(overOneYear)),
|
||||
() -> Assertions.assertDoesNotThrow(() -> service.validateQuery(exactlyOneYear)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信退款应兼容 V2 字段、V3 amount.refund,并严格遵循回调优先于响应和请求的顺序。
|
||||
*/
|
||||
@Test
|
||||
void parseRefundAmountShouldSupportWechatV2AndV3Snapshots() {
|
||||
ThirdPayTotalRefundRecord v2 = refundRecord("10");
|
||||
v2.setRefundCallbackRes("{\"refund_fee\":\"125\"}");
|
||||
v2.setRefundRes("{\"refund_fee\":999}");
|
||||
ThirdPayTotalRefundRecord v3 = refundRecord("10");
|
||||
v3.setRefundRes("{\"amount\":{\"total\":1000,\"refund\":234}}");
|
||||
|
||||
Assertions.assertEquals(125L, service.parseRefundAmount(v2));
|
||||
Assertions.assertEquals(234L, service.parseRefundAmount(v3));
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付宝退款金额按元使用 BigDecimal 精确转分,超过分精度或损坏快照必须标记为不可解析。
|
||||
*/
|
||||
@Test
|
||||
void parseRefundAmountShouldConvertAlipayYuanPrecisely() {
|
||||
ThirdPayTotalRefundRecord precise = refundRecord("20");
|
||||
precise.setRefundCallbackRes("{\"refundAmount\":\"12.34\"}");
|
||||
ThirdPayTotalRefundRecord detail = refundRecord("20");
|
||||
detail.setRefundRes("{\"refund_detail_item_list\":[{\"amount\":\"0.07\"}]}");
|
||||
ThirdPayTotalRefundRecord subFen = refundRecord("20");
|
||||
subFen.setRefundRep("{\"refundAmount\":\"1.005\"}");
|
||||
ThirdPayTotalRefundRecord invalid = refundRecord("20");
|
||||
invalid.setRefundRep("not-json");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertEquals(1234L, service.parseRefundAmount(precise)),
|
||||
() -> Assertions.assertEquals(7L, service.parseRefundAmount(detail)),
|
||||
() -> Assertions.assertNull(service.parseRefundAmount(subFen)),
|
||||
() -> Assertions.assertNull(service.parseRefundAmount(invalid)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 退分账解析应兼容微信结构化响应和支付宝多条分账退款明细,且不能读取普通整单退款金额。
|
||||
*/
|
||||
@Test
|
||||
void parseFzRefundAmountShouldReadOnlyRoyaltyRefundAmounts() {
|
||||
ThirdPayTotalFzRefundRecord wechat = fzRefundRecord("10");
|
||||
wechat.setFzRefundRes("{\"refund\":{\"response\":{\"amount\":300}}}");
|
||||
ThirdPayTotalFzRefundRecord alipayRequest = fzRefundRecord("20");
|
||||
alipayRequest.setFzRefundReq("[{\"amount\":\"1.20\"},{\"amount\":\"0.03\"}]");
|
||||
ThirdPayTotalFzRefundRecord alipayResponse = fzRefundRecord("20");
|
||||
alipayResponse.setFzRefundRes("{\"response\":{\"refundRoyaltys\":[{\"amount\":\"2.50\"}]}}");
|
||||
ThirdPayTotalFzRefundRecord unrelatedAmount = fzRefundRecord("20");
|
||||
unrelatedAmount.setFzRefundRes("{\"refundAmount\":\"10.00\",\"amount\":\"10.00\"}");
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertEquals(300L, service.parseFzRefundAmount(wechat)),
|
||||
() -> Assertions.assertEquals(123L, service.parseFzRefundAmount(alipayRequest)),
|
||||
() -> Assertions.assertEquals(250L, service.parseFzRefundAmount(alipayResponse)),
|
||||
() -> Assertions.assertNull(service.parseFzRefundAmount(unrelatedAmount)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 四类事实应在商户与支付方式维度合并,未知退分账金额保留次数并使净收款标为部分可核对。
|
||||
*/
|
||||
@Test
|
||||
void statisticsShouldMergeIndependentFactsAndMarkIncompleteNetAmount() {
|
||||
ThirdPayTotalQueryDto query = validQuery();
|
||||
when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 1000L)));
|
||||
ThirdPayTotalRefundRecord parsedRefund = refundRecord("10");
|
||||
parsedRefund.setMchId(1L);
|
||||
parsedRefund.setRefundCallbackRes("{\"amount\":{\"refund\":200}}");
|
||||
ThirdPayTotalRefundRecord unknownRefund = refundRecord("10");
|
||||
unknownRefund.setMchId(1L);
|
||||
when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of(parsedRefund, unknownRefund));
|
||||
when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of(aggregate(1L, "10", 2L, 300L)));
|
||||
ThirdPayTotalFzRefundRecord parsedFzRefund = fzRefundRecord("10");
|
||||
parsedFzRefund.setMchId(1L);
|
||||
parsedFzRefund.setFzRefundReq("{\"amount\":100}");
|
||||
ThirdPayTotalFzRefundRecord unknownFzRefund = fzRefundRecord("10");
|
||||
unknownFzRefund.setMchId(1L);
|
||||
when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of(parsedFzRefund, unknownFzRefund));
|
||||
MchManageVo merchant = new MchManageVo();
|
||||
merchant.setId(1L);
|
||||
merchant.setName("测试商户");
|
||||
when(remoteMchService.getByIds(List.of(1L))).thenReturn(R.ok(List.of(merchant)));
|
||||
|
||||
ThirdPayTotalStatisticsVo result = service.statistics(query);
|
||||
ThirdPayTotalRowVo row = result.getRows().get(0);
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertEquals("测试商户", row.getMchName()),
|
||||
() -> Assertions.assertEquals(2L, row.getPayOrderCount()),
|
||||
() -> Assertions.assertEquals(1000L, row.getPayAmount()),
|
||||
() -> Assertions.assertEquals(2L, row.getRefundOrderCount()),
|
||||
() -> Assertions.assertEquals(200L, row.getRefundAmount()),
|
||||
() -> Assertions.assertEquals(1L, row.getRefundAmountUnavailableCount()),
|
||||
() -> Assertions.assertEquals(2L, row.getFzSuccessCount()),
|
||||
() -> Assertions.assertEquals(300L, row.getFzSuccessAmount()),
|
||||
() -> Assertions.assertEquals(2L, row.getFzRefundSuccessCount()),
|
||||
() -> Assertions.assertEquals(100L, row.getFzRefundSuccessAmount()),
|
||||
() -> Assertions.assertEquals(1L, row.getFzRefundAmountUnavailableCount()),
|
||||
() -> Assertions.assertEquals(200L, row.getPlatformNetAmount()),
|
||||
() -> Assertions.assertFalse(row.isPlatformNetAmountComplete()),
|
||||
() -> Assertions.assertEquals(row.getPlatformNetAmount(), result.getTotal().getPlatformNetAmount()),
|
||||
() -> Assertions.assertFalse(result.getTotal().isPlatformNetAmountComplete()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户远程调用异常时应保留原商户 ID 和未知支付方式编码,避免历史统计行静默丢失。
|
||||
*/
|
||||
@Test
|
||||
void statisticsShouldKeepRowsWhenMerchantServiceFails() {
|
||||
ThirdPayTotalQueryDto query = validQuery();
|
||||
when(thirdPayTotalMapper.selectPaySuccessAggregates(query)).thenReturn(List.of(aggregate(9L, "99", 1L, 500L)));
|
||||
when(thirdPayTotalMapper.selectRefundRecords(query)).thenReturn(List.of());
|
||||
when(thirdPayTotalMapper.selectFzSuccessAggregates(query)).thenReturn(List.of());
|
||||
when(thirdPayTotalMapper.selectFzRefundRecords(query)).thenReturn(List.of());
|
||||
when(remoteMchService.getByIds(List.of(9L))).thenThrow(new IllegalStateException("remote unavailable"));
|
||||
|
||||
ThirdPayTotalRowVo row = service.statistics(query).getRows().get(0);
|
||||
|
||||
Assertions.assertAll(
|
||||
() -> Assertions.assertEquals(9L, row.getMchId()),
|
||||
() -> Assertions.assertEquals("商户信息不可用", row.getMchName()),
|
||||
() -> Assertions.assertEquals("99", row.getPayPlatformType()),
|
||||
() -> Assertions.assertEquals(500L, row.getPayAmount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造合法的最近统计范围。
|
||||
*
|
||||
* @return 包含完整时间范围的查询条件
|
||||
*/
|
||||
private ThirdPayTotalQueryDto validQuery() {
|
||||
return query(LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59, 59));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造指定开始和结束时间的统计查询。
|
||||
*
|
||||
* @param start 支付开始时间
|
||||
* @param end 支付结束时间
|
||||
* @return 支付统计查询条件
|
||||
*/
|
||||
private ThirdPayTotalQueryDto query(LocalDateTime start, LocalDateTime end) {
|
||||
ThirdPayTotalQueryDto query = new ThirdPayTotalQueryDto();
|
||||
query.setPayTimeList(new LocalDateTime[]{start, end});
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 SQL 聚合返回值。
|
||||
*
|
||||
* @param mchId 商户主键
|
||||
* @param platform 支付平台编码
|
||||
* @param count 记录数
|
||||
* @param amount 金额,单位分
|
||||
* @return 聚合记录
|
||||
*/
|
||||
private ThirdPayTotalAggregateRecord aggregate(Long mchId, String platform, Long count, Long amount) {
|
||||
ThirdPayTotalAggregateRecord record = new ThirdPayTotalAggregateRecord();
|
||||
record.setMchId(mchId);
|
||||
record.setPayPlatformType(platform);
|
||||
record.setRecordCount(count);
|
||||
record.setAmount(amount);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造支付退款快照记录。
|
||||
*
|
||||
* @param platform 支付平台编码
|
||||
* @return 退款记录
|
||||
*/
|
||||
private ThirdPayTotalRefundRecord refundRecord(String platform) {
|
||||
ThirdPayTotalRefundRecord record = new ThirdPayTotalRefundRecord();
|
||||
record.setPayPlatformType(platform);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造退分账快照记录。
|
||||
*
|
||||
* @param platform 支付平台编码
|
||||
* @return 退分账记录
|
||||
*/
|
||||
private ThirdPayTotalFzRefundRecord fzRefundRecord(String platform) {
|
||||
ThirdPayTotalFzRefundRecord record = new ThirdPayTotalFzRefundRecord();
|
||||
record.setPayPlatformType(platform);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user