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