diff --git a/db/20260823_add_hard_order_verification_time.sql b/db/20260823_add_hard_order_verification_time.sql index 344c3c96b..b44212059 100644 --- a/db/20260823_add_hard_order_verification_time.sql +++ b/db/20260823_add_hard_order_verification_time.sql @@ -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; + + diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java index 732377b4e..612703ddb 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardOrderDto.java @@ -38,6 +38,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private List mchTypeCodes; + /** 页面选择的商户类型编码集合;与服务端注入的商户类型权限范围同时生效。 */ + private List merchantTypeCodes; + private List orderStatusCodes; private LocalDateTime[] createTimeList; @@ -82,6 +85,9 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn private List hardTypeCodes; + /** 用餐时间段字典编码集合;为空表示不限制餐段。 */ + private List hourTypeCodes; + private List refundStatusCodes; /** diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java new file mode 100644 index 000000000..8f4a4c03f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardOrderExportExcelVo.java @@ -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; + +/** + * 硬件订单明细导出行,按订单业务域组织二级表头。 + * + *

仅保留业务核对所需的用户、身份、订单、支付、核销和退款信息,隐藏内部主键及审计字段; + * 金额字段统一使用元。该对象仅用于导出展示,不参与数据库持久化和接口入参。

+ */ +@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; +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index c770ec0a8..ebf7559f7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -86,7 +86,7 @@ public class HardOrderController { private final HardOrderSyncService hardOrderSyncService; private final HardCanteenTotalService canteenTotalService; - /** 订单导出服务,负责异步任务、分批模板写入和文件中心上传。 */ + /** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */ private final HardOrderExportService hardOrderExportService; private final HardOrderMapper hardOrderMapper; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java index 037c8de27..66f3c8d94 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderExportService.java @@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse; /** * 硬件订单导出服务。 * - *

该服务独立承担导出条件冻结、数据权限、分批查询、模板写入、进度上报和文件中心上传, + *

该服务独立承担导出条件冻结、数据权限、分批查询、自定义表头写入、进度上报和文件中心上传, * 订单查询服务只保留订单业务查询职责。

*/ public interface HardOrderExportService { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java index a0da9b174..93a4b2211 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderExportServiceImpl.java @@ -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; * 硬件订单导出服务实现。 * *

导出前冻结当前用户可见的设备类型、商户范围和身份筛选,后台任务按稳定顺序分页读取订单, - * 并在同一任务线程同步补充完整用户资料和商户名称。使用 SXSSF 保留原订单模板样式, - * 完成后上传文件中心并返回下载地址。

+ * 并在同一任务线程同步补充完整用户资料和商户名称。使用 EasyExcel 自定义二级表头分片写入, + * 完成后上传文件中心并返回下载地址,不再依赖固定 XLSX 模板。

*/ @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 { * 避免部分失败文件被浏览器当作成功结果下载。

* * @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 异步任务进度报告器;同步兼容导出时为空 + *

文件先写入临时路径,只有工作簿完整关闭后才交给上传或 HTTP 响应回调;每批写入后上报一次进度, + * 避免大批量订单长期占用请求线程和堆内存。

+ * + * @param query 已冻结的订单查询条件 + * @param firstPage 已用于取得总数的第一页,避免重复查询首批订单 + * @param total 导出开始时按相同条件统计的订单总数 + * @param reporter 异步任务进度报告器;同步兼容导出时为空 * @param fileConsumer 完整临时文件消费回调 - * @param 文件消费结果类型 + * @param 文件消费结果类型 * @return 文件消费回调返回的结果 */ private R writeExportWorkbook(HardOrderDto query, Page firstPage, long total, AsyncTaskProgressReporter reporter, BatchExcelExportTemplate.FileConsumer fileConsumer) { + Path outputPath = null; try { - return BatchExcelExportTemplate.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 currentPage = firstPage; + while (processedTotal < total) { + List records = safeRecords(currentPage); + if (records.isEmpty()) { + break; + } + long remaining = total - processedTotal; + int recordCount = Math.toIntExact(Math.min(records.size(), remaining)); + List batch = records.subList(0, recordCount); + List 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 userMap = fetchExportUserMap(userIds); Map 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 包含支付时间范围的订单交易汇总标题,未选择范围时返回固定标题 + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配,确保历史脏数据仍可导出。

+ * + * @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; + } + + /** + * 将设备类型协议编码安全转换为中文描述。 + * + *

既有枚举解析方法对未知编码会抛异常,因此导出侧遍历枚举匹配并保留历史编码。

+ * + * @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 列表元素类型 + * @param 列表元素类型 * @return 独立列表副本;输入为空时返回空 */ private List copyList(List 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) { diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml index 0afa5b1f4..ec7fddf7c 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml @@ -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 @@ and a.merchant_type = #{dto.merchantType} + + + and a.merchant_type in + + #{v} + + and a.order_type = #{dto.orderType} @@ -102,6 +113,12 @@ and a.hour_type = #{dto.hourType} + + and a.hour_type in + + #{v} + + and ( a.hour_type is not null and a.hour_type not in ('-1') ) diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java new file mode 100644 index 000000000..8dc07289c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/dto/pay/ThirdPayTotalQueryDto.java @@ -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; + +/** + * 第三方支付统计查询条件。 + * + *

统计始终按支付订单的支付时间归属,时间范围由管理端明确传入,避免空条件扫描全历史支付数据。

+ */ +@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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java new file mode 100644 index 000000000..52a21a3b1 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalRowVo.java @@ -0,0 +1,60 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 第三方支付统计的商户与支付方式明细。 + * + *

金额字段统一使用分传输;管理端展示时再转换为元。退款快照无法还原金额时, + * 对应不可核对数量会增加且不会把未知金额当作零。

+ */ +@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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java new file mode 100644 index 000000000..5de713d05 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalStatisticsVo.java @@ -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 rows = new ArrayList<>(); + + /** 当前查询范围内全部明细的合计。 */ + private ThirdPayTotalSummaryVo total = new ThirdPayTotalSummaryVo(); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java new file mode 100644 index 000000000..60ea2308d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/pay/ThirdPayTotalSummaryVo.java @@ -0,0 +1,12 @@ +package com.bsd.pjcloud.thirdparty.vo.pay; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 第三方支付统计范围合计。 + * + *

字段口径与明细一致,维度字段为空表示全部商户和支付方式。

+ */ +@Schema(description = "第三方支付统计合计") +public class ThirdPayTotalSummaryVo extends ThirdPayTotalRowVo { +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java new file mode 100644 index 000000000..8a7eebfc2 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/pay/ThirdPayTotalController.java @@ -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 statistics(@RequestBody ThirdPayTotalQueryDto query) { + return R.ok(thirdPayTotalService.statistics(query)); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java new file mode 100644 index 000000000..abe14c7d6 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapper.java @@ -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; + +/** + * 第三方支付统计数据访问接口。 + * + *

支付和分账分别聚合,退款快照单独读取,避免支付订单与多条分账记录直接连接后重复累加金额。

+ */ +@Mapper +public interface ThirdPayTotalMapper { + + /** + * 查询支付成功订单的分组数量与金额。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 按商户和支付方式分组的成功支付聚合;无数据时返回空列表 + */ + List selectPaySuccessAggregates(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询退款成功订单的历史快照,每个支付订单最多返回一条。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 退款成功订单及三级退款快照,供 Service 按渠道精确解析金额 + */ + List selectRefundRecords(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询分账成功记录的分组数量与金额。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 按关联支付订单商户和支付方式分组的分账成功聚合 + */ + List selectFzSuccessAggregates(@Param("query") ThirdPayTotalQueryDto query); + + /** + * 查询成功退分账记录及其快照。 + * + * @param query 已校验且包含完整支付时间范围的查询条件 + * @return 每条成功退分账记录及其请求、响应快照 + */ + List selectFzRefundRecords(@Param("query") ThirdPayTotalQueryDto query); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java new file mode 100644 index 000000000..81bed485f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/ThirdPayTotalService.java @@ -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); +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java new file mode 100644 index 000000000..78def2670 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImpl.java @@ -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; + +/** + * 第三方支付统计服务实现。 + * + *

支付成功、退款成功、分账成功和退分账成功分别查询;退款快照只保留当前退款事实, + * 因此金额无法还原时记录不可核对数量,并保留已解析的部分金额。

+ */ +@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 WECHAT_REFUND_KEYS = List.of("refund_fee", "settlement_refund_fee", "refund"); + + /** 支付宝退款快照中的金额字段优先级,单位为元。 */ + private static final List ALIPAY_REFUND_KEYS = List.of("refund_fee", "send_back_fee", "refundAmount"); + + /** + * 统计指定支付时间范围内的四类支付事实,并在内存中合并到商户与支付方式维度。 + * + * @param query 已包含完整日期范围的查询条件 + * @return 统计明细及合计 + */ + @Override + public ThirdPayTotalStatisticsVo statistics(ThirdPayTotalQueryDto query) { + validateQuery(query); + Map 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 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 rowMap, + List 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 rowMap, + List 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 rowMap, + List 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 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 rows) { + if (rows == null || rows.isEmpty()) return; + List mchIds = rows.stream().map(ThirdPayTotalRowVo::getMchId).filter(id -> id != null).distinct().toList(); + Map merchantMap = new HashMap<>(); + if (!mchIds.isEmpty()) { + try { + R> 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 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 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 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 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 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 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 keys, List result) { + if (root instanceof JSONObject object) { + for (Map.Entry 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 result) { + if (root instanceof JSONObject object) { + for (Map.Entry 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); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java new file mode 100644 index 000000000..412d7d092 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalAggregateRecord.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.thirdparty.statistics; + +import lombok.Data; + +/** + * SQL 聚合阶段的商户与支付方式金额结果。 + * + *

该对象只承载已经在数据库中按维度聚合的成功记录,金额单位为分,避免把大批成功订单完整加载到 JVM。

+ */ +@Data +public class ThirdPayTotalAggregateRecord { + + /** 商户主键,历史订单缺失时允许为空。 */ + private Long mchId; + + /** 支付平台类型编码,未知编码原样传递。 */ + private String payPlatformType; + + /** 当前聚合维度的记录数量。 */ + private Long recordCount; + + /** 当前聚合维度的金额,单位分。 */ + private Long amount; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java new file mode 100644 index 000000000..a70257bb5 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalFzRefundRecord.java @@ -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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java new file mode 100644 index 000000000..ad3aa02c1 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalRefundRecord.java @@ -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; +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java new file mode 100644 index 000000000..197ce978d --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/statistics/ThirdPayTotalStatisticsSupport.java @@ -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); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml new file mode 100644 index 000000000..e9622617c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/mapper/ThirdPayTotalMapper.xml @@ -0,0 +1,98 @@ + + + + + + p.del_flag = '0' + and p.pay_time between #{query.payTimeList[0]} and #{query.payTimeList[1]} + + and p.mch_id = #{query.mchId} + + + and p.pay_platform_type = #{query.payPlatformType} + + + + + + + + + + + diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java new file mode 100644 index 000000000..787701101 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/mapper/pay/ThirdPayTotalMapperSqlContractTest.java @@ -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("", idIndex); + Assertions.assertTrue(idIndex >= 0 && start >= 0 && end > idIndex, "缺少 MyBatis 查询:" + id); + return mapperXml.substring(start, end); + } +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java new file mode 100644 index 000000000..bc291d7ac --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/pay/ThirdPayTotalServiceImplTest.java @@ -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; + } +}