From 5910c5ab7c241fa95d34d5af108f6e12bcac05bf Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 18 Aug 2026 20:15:07 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E9=A2=84=E7=BA=A6=E7=BB=9F=E8=AE=A1=E5=92=8C=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pjcloud-common/pjcloud-common-excel/README.md | 72 + .../stream/BatchExcelExportTemplate.java | 617 ++++++++ .../order/HardCanteenUserApptQueryDto.java | 59 + .../vo/order/HardCanteenUserApptOrderVo.java | 46 + .../api/vo/order/HardCanteenUserApptVo.java | 255 ++++ .../excel/HardCanteenUserApptExcelVo.java | 136 ++ pjcloud-hardware/pjcloud-hardware-biz/pom.xml | 8 + .../order/HardCanteenApptTotalController.java | 106 +- .../hard/mapper/order/HardOrderMapper.java | 11 + .../service/HardCanteenApptTotalService.java | 52 + .../HardCanteenApptTotalServiceImpl.java | 1282 ++++++++++++++++- .../bsd/pjcloud/hard/task/ExecutorConfig.java | 19 + .../mapper/order/HardOrderMapper.xml | 11 + .../admin/api/feign/RemoteUserService.java | 42 + .../com/bsd/pjcloud/admin/api/vo/UserVO.java | 3 +- .../api/vo/base/BaseUserDeptInterface.java | 150 +- .../vo/base/BaseUserIdentityInterface.java | 85 ++ .../admin/controller/SysUserController.java | 31 + .../pjcloud/admin/mapper/SysUserMapper.java | 10 + .../pjcloud/admin/service/SysUserService.java | 20 + .../service/impl/SysUserServiceImpl.java | 47 + .../main/resources/mapper/SysUserMapper.xml | 46 + pjcloud-wallet/pjcloud-wallet-biz/pom.xml | 4 + .../impl/WalletManageExportServiceImpl.java | 409 +++++- 24 files changed, 3446 insertions(+), 75 deletions(-) create mode 100644 pjcloud-common/pjcloud-common-excel/README.md create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityInterface.java diff --git a/pjcloud-common/pjcloud-common-excel/README.md b/pjcloud-common/pjcloud-common-excel/README.md new file mode 100644 index 000000000..b51d06a48 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/README.md @@ -0,0 +1,72 @@ +# 通用 Excel 模块 + +`pjcloud-common-excel` 提供 Excel 导入、普通导出以及大数据量 XLSX 分批流式导出能力。 + +## 分批流式导出 + +`BatchExcelExportTemplate` 适用于单次导出不能把全部查询结果和全部工作表行同时保留在 JVM 堆中的场景。它统一负责: + +- 根据预统计数量循环分页查询; +- 使用 `SXSSFWorkbook` 把超出内存窗口的工作表行刷新到磁盘; +- 校验分页实际数量与预统计数量一致; +- 校验 Excel 2007 单工作表行数上限; +- 释放模板流、工作簿、SXSSF 临时 XML 和最终 XLSX 临时文件; +- 通过回调接入业务进度、汇总行和文件上传逻辑。 + +业务模块先增加公共 Excel 依赖: + +```xml + + com.bsd + pjcloud-common-excel + +``` + +典型调用方式: + +```java +String fileUrl = BatchExcelExportTemplate.builder(total) + .batchSize(1000) + .dataStartRowIndex(2) + .reservedTailRowCount(1) + .temporaryFilePrefix("user-export-") + .templateSupplier(() -> ResourceUtil.getStream("file/user.xlsx")) + .templateInitializer(workbook -> prepareTemplate(workbook)) + .pageFetcher((pageNumber, batchSize) -> { + Page page = new Page<>(pageNumber, batchSize, false); + return userService.pageForExport(page, query).getRecords(); + }) + .rowWriter((sheet, rowIndex, dataIndex, item, context) -> + writeRow(sheet, rowIndex, dataIndex, item, context)) + .footerWriter((sheet, rowIndex, processedTotal, context) -> + writeSummary(sheet, rowIndex, context)) + .progressListener((processedTotal, expectedTotal) -> + reporter.advance("已整理 " + processedTotal + " / " + expectedTotal + " 条")) + .fileConsumer(path -> upload(Files.readAllBytes(path), fileName)) + .execute(); +``` + +## 与异步任务组合 + +公共 Excel 模块不依赖 `pjcloud-common-async-task`。业务层通过 `progressListener` 把每批完成事件转给 +`AsyncTaskProgressReporter`,通过 `fileConsumer` 上传完整文件并返回 `AsyncTaskResult` 所需的地址。 + +异步任务总步数可统一计算: + +```java +int progressTotal = BatchExcelExportTemplate.calculateProgressTotal(total, batchSize, 2); +reporter.updateTotal(progressTotal, "已查询 " + total + " 条数据,将分批生成文件"); +``` + +额外的 `2` 由业务流程定义,例如文件上传一步和异步任务成功收尾一步。公共模板每完成一个查询批次只通知一次, +不会按明细行高频写 Redis。 + +## 调用约束 + +- 预统计和分页查询必须使用完全相同的筛选条件、租户上下文和数据权限。 +- 分页 SQL 必须有稳定排序;排序字段存在重复值时,需要追加唯一字段,避免翻页时漏行或重复。 +- 导出期间数据发生增删会触发数量不一致异常,业务方应提示用户重新导出。 +- `dataStartRowIndex` 和 `reservedTailRowCount` 都按 Excel 零基行号语义配置。 +- `fileConsumer` 收到的路径只在回调期间有效,不得交给其他线程延迟读取。 +- 默认关闭共享字符串表以控制内存;只有确认重复文本占比高且内存可控时才启用。 +- 单个工作表仍受 XLSX 的 1,048,576 行上限约束;超过上限时应按业务条件拆分文件或扩展为多工作表导出。 diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java new file mode 100644 index 000000000..1240086fc --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/stream/BatchExcelExportTemplate.java @@ -0,0 +1,617 @@ +package com.bsd.pjcloud.common.excel.stream; + +import org.apache.poi.ss.SpreadsheetVersion; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +/** + * 基于 XLSX 模板的通用分批流式导出模板。 + * + *

该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供 + * 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心, + * 可由不同业务按需组合。

+ */ +public final class BatchExcelExportTemplate { + + /** Excel 2007 单个工作表支持的最大行数。 */ + private static final int XLSX_MAX_ROW_COUNT = SpreadsheetVersion.EXCEL2007.getMaxRows(); + + /** 默认分页大小和 SXSSF 内存行窗口大小。 */ + private static final int DEFAULT_BATCH_SIZE = 1000; + + /** 默认临时文件名前缀,必须满足 JDK 至少三个字符的约束。 */ + private static final String DEFAULT_TEMPORARY_FILE_PREFIX = "batch-excel-export-"; + + /** 公共导出模板日志记录器,仅记录临时文件清理失败等基础设施异常。 */ + private static final Logger LOGGER = LoggerFactory.getLogger(BatchExcelExportTemplate.class); + + /** + * 工具类不允许实例化。 + */ + private BatchExcelExportTemplate() { + } + + /** + * 创建一次分批导出的链式配置器。 + * + * @param expectedTotal 导出前按相同筛选条件统计的明细总数,必须大于零 + * @param 分页查询返回的业务行类型 + * @param 模板初始化后供逐行及尾部写入共享的上下文类型 + * @param 最终文件消费回调的返回类型 + * @return 尚未执行的导出配置器 + */ + public static Builder builder(long expectedTotal) { + return new Builder<>(expectedTotal); + } + + /** + * 根据总记录数和分页大小计算实际查询批次数。 + * + * @param expectedTotal 导出前预统计的明细总数,必须大于零 + * @param batchSize 单次分页查询大小,必须大于零 + * @return 向上取整后的批次数 + */ + public static int calculateBatchCount(long expectedTotal, int batchSize) { + validatePositive(expectedTotal, "导出总数必须大于零"); + validatePositive(batchSize, "导出批大小必须大于零"); + long batchCount = (expectedTotal - 1) / batchSize + 1; + if (batchCount > Integer.MAX_VALUE) { + throw new IllegalArgumentException("导出批次数超过整数上限"); + } + return (int) batchCount; + } + + /** + * 计算当前 XLSX 工作表在指定布局下最多可写入的明细行数。 + * + * @param dataStartRowIndex 明细开始写入的 Excel 零基行号 + * @param reservedTailRowCount 明细之后为合计、备注等内容预留的行数 + * @return 不超过 Excel 2007 单工作表上限的最大明细行数 + */ + public static int calculateMaxDetailRowCount(int dataStartRowIndex, int reservedTailRowCount) { + if (dataStartRowIndex < 0) { + throw new IllegalArgumentException("导出明细起始行不得小于零"); + } + if (reservedTailRowCount < 0) { + throw new IllegalArgumentException("导出尾部预留行数不得小于零"); + } + long maxDetailRowCount = (long) XLSX_MAX_ROW_COUNT - dataStartRowIndex - reservedTailRowCount; + if (maxDetailRowCount < 0) { + throw new IllegalArgumentException("导出固定行和尾部预留行超过单个工作表上限"); + } + return (int) maxDetailRowCount; + } + + /** + * 计算异步任务可使用的总进度步数。 + * + *

每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。

+ * + * @param expectedTotal 导出前预统计的明细总数,必须大于零 + * @param batchSize 单次分页查询大小,必须大于零 + * @param extraSteps 文件上传等额外步骤数量,不得小于零 + * @return 批次数与额外步骤之和 + */ + public static int calculateProgressTotal(long expectedTotal, int batchSize, int extraSteps) { + if (extraSteps < 0) { + throw new IllegalArgumentException("导出额外进度步数不得小于零"); + } + long progressTotal = (long) calculateBatchCount(expectedTotal, batchSize) + extraSteps; + if (progressTotal > Integer.MAX_VALUE) { + throw new IllegalArgumentException("导出进度总步数超过整数上限"); + } + return (int) progressTotal; + } + + /** + * 校验正整数参数。 + * + * @param value 待校验数值 + * @param message 参数非法时使用的异常信息 + */ + private static void validatePositive(long value, String message) { + if (value <= 0) { + throw new IllegalArgumentException(message); + } + } + + /** + * 打开模板输入流的回调,返回的流由公共导出模板负责关闭。 + */ + @FunctionalInterface + public interface TemplateSupplier { + + /** + * 打开本次导出使用的 XLSX 模板。 + * + * @return 非空的模板输入流 + * @throws Exception 模板不存在或无法读取时抛出 + */ + InputStream open() throws Exception; + } + + /** + * 模板初始化回调,用于读取样式、替换标题和移除占位行。 + * + * @param 后续逐行和尾部写入共享的上下文类型 + */ + @FunctionalInterface + public interface TemplateInitializer { + + /** + * 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。 + * + * @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理 + * @return 后续写入共享的业务上下文,允许为空 + * @throws Exception 模板结构不符合业务约束时抛出 + */ + C initialize(XSSFWorkbook workbook) throws Exception; + } + + /** + * 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。 + * + * @param 业务行类型 + */ + @FunctionalInterface + public interface PageFetcher { + + /** + * 查询指定页的数据。 + * + * @param pageNumber 从一开始的页码 + * @param batchSize 单页最大记录数 + * @return 当前页记录;无记录时可返回空列表或空值 + * @throws Exception 数据查询失败时抛出 + */ + List fetch(long pageNumber, int batchSize) throws Exception; + } + + /** + * 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。 + * + * @param 业务行类型 + * @param 模板共享上下文类型 + */ + @FunctionalInterface + public interface RowWriter { + + /** + * 将一条业务记录写入指定工作表行。 + * + * @param sheet SXSSF 流式工作表 + * @param rowIndex Excel 零基行号 + * @param dataIndex 当前记录在全部明细中的零基序号 + * @param item 当前业务记录 + * @param context 模板初始化产生的共享上下文,允许为空 + * @throws Exception 数据转换或行写入失败时抛出 + */ + void write(SXSSFSheet sheet, int rowIndex, long dataIndex, T item, C context) throws Exception; + } + + /** + * 明细写完后的尾部写入回调,可用于合计、签章或备注行。 + * + * @param 模板共享上下文类型 + */ + @FunctionalInterface + public interface FooterWriter { + + /** + * 从预留尾部区域的第一行开始写入业务内容。 + * + * @param sheet SXSSF 流式工作表 + * @param firstFooterRowIndex 尾部区域第一行的 Excel 零基行号 + * @param processedTotal 已成功写入的明细总数 + * @param context 模板初始化产生的共享上下文,允许为空 + * @throws Exception 尾部内容写入失败时抛出 + */ + void write(SXSSFSheet sheet, int firstFooterRowIndex, long processedTotal, C context) throws Exception; + } + + /** + * 每批数据完整写入后的进度通知回调。 + */ + @FunctionalInterface + public interface ProgressListener { + + /** + * 报告已完成写入的明细数量。 + * + * @param processedTotal 已完成写入的明细总数 + * @param expectedTotal 导出前预统计的明细总数 + * @throws Exception 进度上报失败且需要终止导出时抛出 + */ + void onBatchCompleted(long processedTotal, long expectedTotal) throws Exception; + } + + /** + * 完整 XLSX 临时文件的消费回调,可用于上传文件中心或移动到业务目录。 + * + * @param 文件消费结果类型 + */ + @FunctionalInterface + public interface FileConsumer { + + /** + * 在临时文件仍然存在时消费完整 XLSX 文件。 + * + * @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有 + * @return 文件上传地址等业务结果 + * @throws Exception 文件读取、上传或移动失败时抛出 + */ + R consume(Path file) throws Exception; + } + + /** + * 单次分批流式导出的链式配置器。 + * + *

配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。

+ * + * @param 分页查询返回的业务行类型 + * @param 模板共享上下文类型 + * @param 完整文件消费结果类型 + */ + public static final class Builder { + + /** 导出前按相同筛选条件统计的明细总数。 */ + private final long expectedTotal; + + /** 单次分页查询大小,同时作为 SXSSF 内存行窗口大小。 */ + private int batchSize = DEFAULT_BATCH_SIZE; + + /** 明细开始写入的 Excel 零基行号。 */ + private int dataStartRowIndex; + + /** 明细之后为合计、备注等内容预留的行数。 */ + private int reservedTailRowCount; + + /** 目标工作表的零基下标。 */ + private int sheetIndex; + + /** 最终 XLSX 临时文件的文件名前缀。 */ + private String temporaryFilePrefix = DEFAULT_TEMPORARY_FILE_PREFIX; + + /** 是否压缩 SXSSF 生成的工作表临时 XML 文件,以磁盘计算换取较低临时空间占用。 */ + private boolean compressTemporaryFiles = true; + + /** 是否使用共享字符串表;默认关闭以避免大量唯一文本占用堆内存。 */ + private boolean useSharedStringsTable; + + /** 业务提供的模板输入流工厂。 */ + private TemplateSupplier templateSupplier; + + /** 业务提供的模板初始化回调。 */ + private TemplateInitializer templateInitializer; + + /** 业务提供的分页查询回调。 */ + private PageFetcher pageFetcher; + + /** 业务提供的逐行写入回调。 */ + private RowWriter rowWriter; + + /** 可选的业务尾部写入回调。 */ + private FooterWriter footerWriter; + + /** 可选的批次进度通知回调。 */ + private ProgressListener progressListener; + + /** 业务提供的完整文件消费回调。 */ + private FileConsumer fileConsumer; + + /** + * 创建单次导出配置器。 + * + * @param expectedTotal 导出前预统计的明细总数 + */ + private Builder(long expectedTotal) { + this.expectedTotal = expectedTotal; + } + + /** + * 设置分页大小和 SXSSF 内存行窗口大小。 + * + * @param batchSize 正整数批大小 + * @return 当前配置器 + */ + public Builder batchSize(int batchSize) { + this.batchSize = batchSize; + return this; + } + + /** + * 设置明细开始写入的 Excel 零基行号。 + * + * @param dataStartRowIndex 非负的零基行号 + * @return 当前配置器 + */ + public Builder dataStartRowIndex(int dataStartRowIndex) { + this.dataStartRowIndex = dataStartRowIndex; + return this; + } + + /** + * 设置明细之后必须预留的尾部行数。 + * + * @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数 + * @return 当前配置器 + */ + public Builder reservedTailRowCount(int reservedTailRowCount) { + this.reservedTailRowCount = reservedTailRowCount; + return this; + } + + /** + * 设置目标工作表下标。 + * + * @param sheetIndex 非负的工作表零基下标 + * @return 当前配置器 + */ + public Builder sheetIndex(int sheetIndex) { + this.sheetIndex = sheetIndex; + return this; + } + + /** + * 设置最终 XLSX 临时文件的文件名前缀。 + * + * @param temporaryFilePrefix 至少三个字符的文件名前缀 + * @return 当前配置器 + */ + public Builder temporaryFilePrefix(String temporaryFilePrefix) { + this.temporaryFilePrefix = temporaryFilePrefix; + return this; + } + + /** + * 设置是否压缩 SXSSF 的临时 XML 文件。 + * + * @param compressTemporaryFiles 是否启用临时 XML 压缩 + * @return 当前配置器 + */ + public Builder compressTemporaryFiles(boolean compressTemporaryFiles) { + this.compressTemporaryFiles = compressTemporaryFiles; + return this; + } + + /** + * 设置是否启用共享字符串表。 + * + * @param useSharedStringsTable 是否把字符串集中写入共享表 + * @return 当前配置器 + */ + public Builder useSharedStringsTable(boolean useSharedStringsTable) { + this.useSharedStringsTable = useSharedStringsTable; + return this; + } + + /** + * 设置模板输入流工厂。 + * + * @param templateSupplier 每次执行时打开一个新模板流的回调 + * @return 当前配置器 + */ + public Builder templateSupplier(TemplateSupplier templateSupplier) { + this.templateSupplier = templateSupplier; + return this; + } + + /** + * 设置模板初始化回调。 + * + * @param templateInitializer 模板样式读取和占位内容清理回调 + * @return 当前配置器 + */ + public Builder templateInitializer(TemplateInitializer templateInitializer) { + this.templateInitializer = templateInitializer; + return this; + } + + /** + * 设置分页查询回调。 + * + * @param pageFetcher 按页码和批大小查询业务数据的回调 + * @return 当前配置器 + */ + public Builder pageFetcher(PageFetcher pageFetcher) { + this.pageFetcher = pageFetcher; + return this; + } + + /** + * 设置逐行写入回调。 + * + * @param rowWriter 将单条业务数据写入工作表的回调 + * @return 当前配置器 + */ + public Builder rowWriter(RowWriter rowWriter) { + this.rowWriter = rowWriter; + return this; + } + + /** + * 设置可选的尾部写入回调。 + * + * @param footerWriter 明细写完后写入合计、备注等内容的回调 + * @return 当前配置器 + */ + public Builder footerWriter(FooterWriter footerWriter) { + this.footerWriter = footerWriter; + return this; + } + + /** + * 设置可选的批次进度通知回调。 + * + * @param progressListener 每批数据完整写入后的通知回调 + * @return 当前配置器 + */ + public Builder progressListener(ProgressListener progressListener) { + this.progressListener = progressListener; + return this; + } + + /** + * 设置最终 XLSX 文件消费回调。 + * + * @param fileConsumer 上传、移动或读取最终临时文件的回调 + * @return 当前配置器 + */ + public Builder fileConsumer(FileConsumer fileConsumer) { + this.fileConsumer = fileConsumer; + return this; + } + + /** + * 执行分批查询、流式写入和最终文件消费。 + * + * @return 文件消费回调返回的业务结果 + * @throws Exception 模板读取、查询、写入、进度上报或文件消费失败时抛出 + */ + public R execute() throws Exception { + validate(); + Path outputPath = null; + try { + outputPath = Files.createTempFile(temporaryFilePrefix, ".xlsx"); + writeWorkbook(outputPath); + return fileConsumer.consume(outputPath); + } + finally { + deleteTemporaryFile(outputPath); + } + } + + /** + * 校验所有基础参数和必需回调,避免进入写入流程后才暴露配置错误。 + */ + private void validate() { + validatePositive(expectedTotal, "导出总数必须大于零"); + validatePositive(batchSize, "导出批大小必须大于零"); + if (dataStartRowIndex < 0) { + throw new IllegalArgumentException("导出明细起始行不得小于零"); + } + if (reservedTailRowCount < 0) { + throw new IllegalArgumentException("导出尾部预留行数不得小于零"); + } + if (sheetIndex < 0) { + throw new IllegalArgumentException("导出工作表下标不得小于零"); + } + if (temporaryFilePrefix == null || temporaryFilePrefix.length() < 3) { + throw new IllegalArgumentException("导出临时文件名前缀不得少于三个字符"); + } + int maxDetailRowCount = calculateMaxDetailRowCount(dataStartRowIndex, reservedTailRowCount); + if (expectedTotal > maxDetailRowCount) { + throw new IllegalArgumentException("导出明细数超过当前工作表可容纳的 " + maxDetailRowCount + " 条"); + } + Objects.requireNonNull(templateSupplier, "导出模板输入流工厂不能为空"); + Objects.requireNonNull(templateInitializer, "导出模板初始化回调不能为空"); + Objects.requireNonNull(pageFetcher, "导出分页查询回调不能为空"); + Objects.requireNonNull(rowWriter, "导出逐行写入回调不能为空"); + Objects.requireNonNull(fileConsumer, "导出文件消费回调不能为空"); + } + + /** + * 创建并写出 SXSSF 工作簿,同时保证其工作表临时 XML 被释放。 + * + * @param outputPath 最终 XLSX 临时文件路径 + * @throws Exception 模板、查询或写入任一环节失败时抛出 + */ + private void writeWorkbook(Path outputPath) throws Exception { + try (InputStream template = Objects.requireNonNull(templateSupplier.open(), "导出模板输入流不能为空"); + XSSFWorkbook templateWorkbook = new XSSFWorkbook(template)) { + C context = templateInitializer.initialize(templateWorkbook); + SXSSFWorkbook workbook = new SXSSFWorkbook(templateWorkbook, batchSize, compressTemporaryFiles, + useSharedStringsTable); + workbook.setShouldCalculateSheetDimensions(true); + try (workbook; OutputStream output = Files.newOutputStream(outputPath)) { + writeRows(workbook.getSheetAt(sheetIndex), context); + workbook.write(output); + } + finally { + // close 关闭压缩包句柄,dispose 负责删除 SXSSF 为各工作表创建的临时 XML。 + workbook.dispose(); + } + } + } + + /** + * 按页读取并逐行写入数据,最后校验实际数量与预统计数量一致。 + * + * @param sheet 目标 SXSSF 工作表 + * @param context 模板初始化产生的共享上下文 + * @throws Exception 查询、行写入或尾部写入失败时抛出 + */ + private void writeRows(SXSSFSheet sheet, C context) throws Exception { + long processedTotal = 0; + long pageNumber = 1; + while (true) { + List rows = pageFetcher.fetch(pageNumber++, batchSize); + if (rows == null || rows.isEmpty()) { + break; + } + if (rows.size() > batchSize) { + throw new IllegalStateException("导出分页查询返回数量超过配置的批大小"); + } + if (processedTotal + rows.size() > expectedTotal) { + throw dataChangedException(processedTotal + rows.size()); + } + for (T item : rows) { + int rowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal); + rowWriter.write(sheet, rowIndex, processedTotal, item, context); + processedTotal++; + } + if (progressListener != null) { + progressListener.onBatchCompleted(processedTotal, expectedTotal); + } + if (rows.size() < batchSize && processedTotal < expectedTotal) { + break; + } + } + if (processedTotal != expectedTotal) { + throw dataChangedException(processedTotal); + } + if (footerWriter != null) { + int footerRowIndex = Math.toIntExact((long) dataStartRowIndex + processedTotal); + footerWriter.write(sheet, footerRowIndex, processedTotal, context); + } + } + + /** + * 创建导出期间数据变化异常,统一向业务方暴露预期数量和实际数量。 + * + * @param actualTotal 当前已读取或即将写入的实际数量 + * @return 可直接抛出的状态异常 + */ + private IllegalStateException dataChangedException(long actualTotal) { + return new IllegalStateException("导出数据在执行期间发生变化,预期 " + expectedTotal + " 条,实际 " + + actualTotal + " 条"); + } + + /** + * 删除公共模板创建的最终 XLSX 临时文件,清理失败仅记录告警,避免覆盖原始业务结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporaryFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } + catch (Exception exception) { + LOGGER.warn("|批量Excel导出|清理最终临时文件失败,path:{}", outputPath, exception); + } + } + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java new file mode 100644 index 000000000..e8384b42d --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java @@ -0,0 +1,59 @@ +package com.bsd.pjcloud.hard.api.dto.order; + +import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +/** + * 食堂用户预约查询条件。 + * + *

查询以用户全集为主数据源,再关联指定用餐日期范围内的有效预约订单,并按用户和日期逐日生成结果, + * 因此每天没有预约记录的用户也会返回。

+ */ +@Data +@Schema(description = "食堂用户预约查询条件") +public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface { + + /** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */ + @NotNull(message = "用餐日期范围不能为空") + @Size(min = 2, max = 2, message = "用餐日期范围必须包含开始和结束日期") + @Schema(description = "用餐日期范围,固定为开始日期和结束日期", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDate[] apptUseDateList; + + /** 用户姓名、登录账号或手机号的模糊查询关键字;为空时不限制。 */ + @Schema(description = "用户姓名、登录账号或手机号") + private String nameOrPhone; + + /** 部门树选中的部门主键集合;为空时使用当前操作者完整的数据权限范围。 */ + @Schema(description = "部门主键集合") + private List deptIds; + + /** 是否包含所选部门;true 为包含,false 为排除,空值按包含处理。 */ + @Schema(description = "是否包含所选部门") + private Boolean isInclude; + + /** 食堂商户主键;为空时查询所有食堂的预约。 */ + @Schema(description = "食堂商户主键") + private Long merchantId; + + /** 用餐时间段字典编码;为空时查询日期范围内的所有餐段。 */ + @Schema(description = "用餐时间段字典编码") + private String hourType; + + /** 订餐状态筛选,支持 RESERVED(已订餐)和 NOT_RESERVED(未订餐);为空时不限制。 */ + @Schema(description = "订餐状态:RESERVED-已订餐,NOT_RESERVED-未订餐") + private String reservationStatus; + + /** 核销状态筛选,支持 NONE、UNVERIFIED、PARTIAL、VERIFIED;为空时不限制。 */ + @Schema(description = "核销状态:NONE-无需核销,UNVERIFIED-待核销,PARTIAL-部分核销,VERIFIED-已核销") + private String verificationStatus; + + /** 指定用户主键集合,仅用于导出当前勾选人员;仍会叠加当前操作者的数据权限。 */ + @Schema(description = "指定导出的用户主键集合") + private List userIds; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java new file mode 100644 index 000000000..dac4aa9c2 --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptOrderVo.java @@ -0,0 +1,46 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户指定日期内的一笔有效食堂预约订单明细。 + */ +@Data +@Schema(description = "食堂用户预约订单明细") +public class HardCanteenUserApptOrderVo { + + /** 预约订单主键,用于同一用户多笔预约的稳定标识。 */ + @Schema(description = "预约订单主键") + private Long id; + + /** 用餐时间段字典编码,来源于订单快照。 */ + @Schema(description = "用餐时间段字典编码") + private String hourType; + + /** 下单时保存的商品描述;历史数据未记录时为空。 */ + @Schema(description = "订餐内容") + private String productDesc; + + /** 预约所属食堂名称;商户信息不可用时为空。 */ + @Schema(description = "食堂名称") + private String merchantName; + + /** 订单商品总价,单位为分;历史数据为空时由服务层按零元参与汇总。 */ + @Schema(description = "订单商品总价,单位为分") + private Integer totalPrice; + + /** 订单状态协议编码,5 表示待核销,20 表示已核销。 */ + @Schema(description = "订单状态协议编码") + private String orderStatus; + + /** 订单最近更新时间;已核销订单中作为核销时间展示。 */ + @Schema(description = "核销时间") + private LocalDateTime updateTime; + + /** 订单最近更新人;已核销订单中作为核销操作人展示。 */ + @Schema(description = "核销操作人") + private String updateBy; +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java new file mode 100644 index 000000000..032c03ccb --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardCanteenUserApptVo.java @@ -0,0 +1,255 @@ +package com.bsd.pjcloud.hard.api.vo.order; + +import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto; +import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserDeptInterface; +import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 用户和用餐日期维度的食堂预约汇总。 + */ +@Data +@Schema(description = "食堂用户预约汇总") +public class HardCanteenUserApptVo implements BaseUserDeptInterface, BaseUserIdentityInterface { + + /** 系统用户主键,来源于 UPMS 权限用户列表。 */ + @Schema(description = "用户主键") + private Long userId; + + /** 页面展示姓名,依次取姓名、昵称和登录账号。 */ + @Schema(description = "用户姓名") + private String userName; + + /** 用户登录账号,用于人员识别和导出。 */ + @Schema(description = "登录账号") + private String username; + + /** 用户手机号;未维护时为空。 */ + @Schema(description = "手机号") + private String phone; + + /** 用户头像地址;未维护时为空。 */ + @Schema(description = "头像地址") + private String avatar; + + /** 用户当前所属部门名称;未维护时为空。 */ + @Schema(description = "部门名称") + private String deptName; + + /** 用户当前所属部门主键;未分配部门时为空。 */ + @Schema(description = "部门主键") + private Long deptId; + + /** 用户昵称,完整用户导出分页返回;普通列表轻量查询时可为空。 */ + @Schema(description = "用户昵称") + private String nickname; + + /** 用户性别协议编码:0 未知、1 男、2 女;普通列表轻量查询时可为空。 */ + @Schema(description = "用户性别协议编码") + private Integer gender; + + /** 用户邮箱;未维护或普通列表轻量查询时为空。 */ + @Schema(description = "用户邮箱") + private String email; + + /** 用户账号创建时间;普通列表轻量查询时可为空。 */ + @Schema(description = "用户创建时间") + private LocalDateTime createTime; + + /** 用户岗位列表,仅完整用户导出分页返回;没有岗位时为空集合或空值。 */ + @Schema(description = "用户岗位列表") + private List postList; + + /** 用户角色列表,仅完整用户导出分页返回;没有角色时为空集合或空值。 */ + @Schema(description = "用户角色列表") + private List roleList; + + /** 用户编制协议编码,完整用户导出分页返回;未维护时为空。 */ + @Schema(description = "用户编制协议编码") + private String staffingType; + + /** 用户账号锁定标记:0 启用、9 禁用;完整用户导出分页返回。 */ + @Schema(description = "用户账号锁定标记") + private String lockFlag; + + /** 用户拥有的身份协议编码,来源于 UPMS 用户身份批量查询;没有身份时为空集合。 */ + @Schema(description = "用户身份类型集合") + private List identityTypes; + + /** 学生身份扩展资料;用户没有学生身份时为空。 */ + @Schema(description = "学生身份扩展信息") + private SysUserStudentDto studentInfo; + + /** 老师身份扩展资料;用户没有老师身份时为空。 */ + @Schema(description = "老师身份扩展信息") + private SysUserTeacherDto teacherInfo; + + /** 本行对应的用餐日期。 */ + @Schema(description = "用餐日期") + private LocalDate apptUseDate; + + /** 订餐状态,RESERVED 表示当前筛选口径存在有效预约,NOT_RESERVED 表示不存在。 */ + @Schema(description = "订餐状态") + private String reservationStatus; + + /** 整体核销状态,取值为 NONE、UNVERIFIED、PARTIAL 或 VERIFIED。 */ + @Schema(description = "整体核销状态") + private String verificationStatus; + + /** 用户在本行日期及当前食堂、餐段筛选范围内的有效预约商品总价,单位为分。 */ + @Schema(description = "当日有效预约商品总价,单位为分") + private Long totalPrice; + + /** 用户在指定日期、食堂和餐段筛选范围内的有效预约订单。 */ + @Schema(description = "有效预约订单") + private List orders; + + /** + * 获取页面展示姓名。 + * + *

类中同时存在 {@code userName} 和 {@code username},两组访问器均需显式声明,避免 Lombok + * 因 JavaBean 方法名相近而跳过其中一组。

+ * + * @return 页面展示姓名;用户资料均未维护时由服务层返回兜底文案 + */ + public String getUserName() { + return userName; + } + + /** + * 设置页面展示姓名。 + * + * @param userName 服务层按姓名、昵称和登录账号顺序解析出的展示姓名 + */ + public void setUserName(String userName) { + this.userName = userName; + } + + /** + * 获取用户登录账号。 + * + *

显式声明该访问器以保证接口序列化和业务赋值均使用 {@code username} 字段。

+ * + * @return 用户登录账号;未维护时为空 + */ + public String getUsername() { + return username; + } + + /** + * 设置用户登录账号。 + * + * @param username UPMS 返回的用户登录账号,未维护时可为空 + */ + public void setUsername(String username) { + this.username = username; + } + + /** + * 获取公共用户信息组件使用的标准姓名字段。 + * + * @return 服务层解析后的用户展示姓名 + */ + @Override + public String getName() { + return userName; + } + + /** + * 设置公共用户信息组件使用的标准姓名字段。 + * + * @param name 服务层解析后的用户展示姓名 + */ + @Override + public void setName(String name) { + this.userName = name; + } + + /** + * 获取历史用户部门契约中的手机号字段。 + * + * @return 用户手机号;未绑定时为空 + */ + @Override + public String getUserMobile() { + return phone; + } + + /** + * 设置历史用户部门契约中的手机号字段。 + * + * @param userMobile 用户手机号,未绑定时可为空 + */ + @Override + public void setUserMobile(String userMobile) { + this.phone = userMobile; + } + + /** + * 获取历史用户部门契约中的头像字段。 + * + * @return 用户头像地址;未维护时为空 + */ + @Override + public String getUserAvatar() { + return avatar; + } + + /** + * 设置历史用户部门契约中的头像字段。 + * + * @param userAvatar 用户头像地址,未维护时可为空 + */ + @Override + public void setUserAvatar(String userAvatar) { + this.avatar = userAvatar; + } + + /** + * 获取历史用户部门契约中的部门主键字段。 + * + * @return 用户当前所属部门主键;未分配部门时为空 + */ + @Override + public Long getUserDeptId() { + return deptId; + } + + /** + * 设置历史用户部门契约中的部门主键字段。 + * + * @param userDeptId 用户当前所属部门主键,未分配部门时可为空 + */ + @Override + public void setUserDeptId(Long userDeptId) { + this.deptId = userDeptId; + } + + /** + * 获取历史用户部门契约中的部门名称字段。 + * + * @return 用户当前所属部门名称;未分配部门时为空 + */ + @Override + public String getUserDeptName() { + return deptName; + } + + /** + * 设置历史用户部门契约中的部门名称字段。 + * + * @param userDeptName 用户当前所属部门名称,未分配部门时可为空 + */ + @Override + public void setUserDeptName(String userDeptName) { + this.deptName = userDeptName; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java new file mode 100644 index 000000000..c4cb50a6b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/excel/HardCanteenUserApptExcelVo.java @@ -0,0 +1,136 @@ +package com.bsd.pjcloud.hard.api.vo.order.excel; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.format.NumberFormat; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 用户和用餐日期维度的食堂预约导出行。 + */ +@Data +@ColumnWidth(22) +public class HardCanteenUserApptExcelVo { + + /** 系统用户主键,作为同名用户的稳定识别信息。 */ + @ExcelProperty({"基础信息", "用户编号"}) + private Long userId; + + /** 用户姓名,便于按人员核对预约结果。 */ + @ExcelProperty({"基础信息", "姓名"}) + private String userName; + + /** 用户邮箱;未维护时为空。 */ + @ExcelProperty({"基础信息", "邮箱"}) + private String email; + + /** 用户当前所属部门名称。 */ + @ExcelProperty({"组织与账号", "部门名称"}) + private String deptName; + + /** 用户岗位名称,多个岗位使用中文逗号分隔。 */ + @ExcelProperty({"组织与账号", "岗位名称"}) + private String postNames; + + /** 用户默认身份使用的手机号;未维护时为空。 */ + @ExcelProperty({"默认身份", "手机号"}) + private String phone; + + /** 用户拥有的身份中文名称,多个身份使用中文逗号分隔。 */ + @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 LocalDate apptUseDate; + + /** 是否存在符合当前食堂、餐段筛选的有效预约。 */ + @ExcelProperty({"预约信息", "是否订餐"}) + private String reservationStatus; + + /** 有效预约涉及的餐段名称,多个值使用中文分号分隔。 */ + @ExcelProperty({"预约信息", "用餐时间段"}) + private String hourTypes; + + /** 有效预约的商品描述,多个值使用中文分号分隔。 */ + @ExcelProperty({"预约信息", "订餐内容"}) + private String productDescriptions; + + /** 有效预约所属食堂,多个值使用中文分号分隔。 */ + @ExcelProperty({"预约信息", "食堂"}) + private String merchantNames; + + /** 早餐预约状态;查询排除早餐时显示“未查询”。 */ + @ExcelProperty({"餐段预约", "早餐"}) + private String breakfastReservation; + + /** 午餐预约状态;查询排除午餐时显示“未查询”。 */ + @ExcelProperty({"餐段预约", "午餐"}) + private String lunchReservation; + + /** 晚餐预约状态;查询排除晚餐时显示“未查询”。 */ + @ExcelProperty({"餐段预约", "晚餐"}) + private String dinnerReservation; + + /** 夜间餐预约状态;查询排除夜间餐时显示“未查询”。 */ + @ExcelProperty({"餐段预约", "夜间餐"}) + private String lateNightReservation; + + /** 各餐段有效预约商品总价,按餐段输出元金额并使用中文分号分隔。 */ + @ExcelProperty({"金额统计", "餐段总金额(元)"}) + private String mealSegmentTotalPrices; + + /** 用户本行日期内有效预约商品总价,单位为元并保留两位小数。 */ + @ExcelProperty({"金额统计", "当日总金额(元)"}) + @NumberFormat("#,##0.00") + private BigDecimal totalPrice; + + /** 用户当天有效预约的整体核销状态。 */ + @ExcelProperty({"核销信息", "核销状态"}) + private String verificationStatus; + + /** 已核销订单的核销时间,多个值使用中文分号分隔。 */ + @ExcelProperty({"核销信息", "核销时间"}) + private String verificationTimes; + + /** + * 获取导出行中的用户姓名。 + * + * @return 导出展示的用户姓名 + */ + public String getUserName() { + return userName; + } + + /** + * 设置导出行中的用户姓名。 + * + * @param userName 服务层解析出的用户展示姓名 + */ + public void setUserName(String userName) { + this.userName = userName; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml index a69771160..12a4eb9cd 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/pom.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/pom.xml @@ -118,6 +118,14 @@ com.bsd pjcloud-common-idempotent + + com.bsd + pjcloud-common-async-task + + + com.bsd + pjcloud-common-excel + com.bsd pjcloud-supermarket-api diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java index f956218d4..f31c16ca1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java @@ -3,16 +3,20 @@ package com.bsd.pjcloud.hard.controller.order; import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; -import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.annotation.ResponseExcel; +import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.service.PjcloudUser; import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; -import com.bsd.pjcloud.hard.api.dto.order.HardCarDto; +import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; -import com.bsd.pjcloud.hard.api.vo.order.HardCarVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; import com.bsd.pjcloud.hard.service.HardCanteenTotalService; import com.bsd.pjcloud.wallet.api.dto.MchManageDto; @@ -21,14 +25,20 @@ import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDate; import java.util.List; /** @@ -41,16 +51,31 @@ import java.util.List; @SecurityRequirement(name = HttpHeaders.AUTHORIZATION) public class HardCanteenApptTotalController { + /** + * 食堂预约统计服务,负责菜品和用户维度的预约聚合。 + */ private final HardCanteenApptTotalService canteenApptTotalService; - /** 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 */ + /** + * 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。 + */ private final HardCanteenTotalService canteenTotalService; + /** + * 商户远程服务,用于限定 App 食堂范围。 + */ private final RemoteMchService remoteMchService; /* ************************************* start app start ************************************* */ + /** + * 分页查询 App 食堂菜品预约统计。 + * + * @param page 分页参数 + * @param dto 预约订单筛选条件 + * @return App 可见食堂范围内的菜品预约统计 + */ @Operation(summary = "App食堂菜品预约统计", description = "App食堂菜品预约统计") @ApiCryptoAes @PostMapping("/app/proudct-reserv-total") @@ -88,6 +113,13 @@ public class HardCanteenApptTotalController { return R.ok(canteenTotalService.listByDept(dto, user, null)); } + /** + * 分页查询后台食堂菜品预约统计。 + * + * @param page 分页参数 + * @param dto 预约订单筛选条件 + * @return 菜品预约统计分页 + */ @Operation(summary = "食堂菜品预约统计", description = "食堂菜品预约统计") @PostMapping("/proudct-reserv-total") public R> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) { @@ -103,4 +135,70 @@ public class HardCanteenApptTotalController { return R.ok(canteenApptTotalService.productReservTotal(page, dto, null)); } + /** + * 分页查询用户在指定用餐日期范围内每天的预约情况。 + * + *

接口以当前操作者数据权限内的全部用户为主数据源,每个用户在范围内每天生成一行, + * 因此没有预约日志的人员和日期也会返回;日期范围最多为两个自然月。

+ * + * @param page 用户和日期维度的分页参数 + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 包含每天未预约用户的预约分页 + */ + @Operation(summary = "用户预约查询", description = "按用户和日期逐日查询预约及核销情况,包含未预约用户,最多查询两个月") + @ApiCryptoAes + @PostMapping("/user-reserv-page") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')") + public R> userReservationPage(@ParameterObject Page page, @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.userReservationPage(page, query)); + } + + /** + * 查询权限范围内指定用户某一天的全部有效食堂预约。 + * + * @param userId 用户预约列表行中的系统用户主键 + * @param apptUseDate 用餐日期,格式为 yyyy-MM-dd + * @return 不受列表食堂、餐段筛选影响的当日预约明细 + */ + @Operation(summary = "用户预约详情", description = "查询指定用户某一天各餐段的全部有效食堂预约") + @ApiCryptoAes + @GetMapping("/user-reserv-detail") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')") + public R> userReservationDetail( + @RequestParam Long userId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate apptUseDate) { + return R.ok(canteenApptTotalService.userReservationDetail(userId, apptUseDate)); + } + + /** + * 提交用户每日预约异步导出任务。 + * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 供前端轮询的初始任务进度,成功终态包含文件下载地址 + */ + @Operation(summary = "用户预约查询异步导出", description = "分批查询并流式生成用户每日预约 Excel") + @SysLog("用户预约查询异步导出") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_export')") + @PostMapping("/user-reserv-export-task") + public R submitUserReservationExport( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return R.ok(canteenApptTotalService.submitUserReservationExport(query), "用户预约导出任务已提交"); + } + + /** + * 导出用户在指定用餐日期范围内每天的预约情况。 + * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件;携带 userIds 时仅导出勾选用户 + * @return 用户和日期维度的 Excel 数据,包含每天未预约用户 + */ + @Operation(summary = "用户预约查询导出", description = "逐日导出用户预约及核销情况,包含未预约用户,最多查询两个月") + @SysLog("用户预约查询导出") + @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_export')") + @ResponseExcel(name = "用户预约查询") + @PostMapping("/user-reserv-export") + public List userReservationExport( + @Valid @RequestBody HardCanteenUserApptQueryDto query) { + return canteenApptTotalService.userReservationExport(query); + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java index bcf822aad..c87698d73 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java @@ -134,6 +134,17 @@ public interface HardOrderMapper extends PjcloudBaseMapper { List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope); + /** + * 按订单筛选条件查询去重后的预约用户主键。 + * + *

用于已订餐及核销状态查询先定位候选用户,再交由 UPMS 应用用户关键字、部门和数据权限, + * 避免导出完整资料时扫描与预约条件无关的全部用户。

+ * + * @param dto 预约日期、食堂、餐段、订单类型和有效状态等筛选条件 + * @return 当前租户内命中预约订单的去重用户主键,按主键升序排列 + */ + List listReservationUserIds(@Param("dto") HardOrderDto dto); + Long count(HardOrderDto dto, DataScope dataScope); List listSb(@Param("dto") HardOrderDto dto, DataScope dataScope); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java index a7b429eab..072e28b6f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCanteenApptTotalService.java @@ -1,12 +1,64 @@ package com.bsd.pjcloud.hard.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; +import java.time.LocalDate; +import java.util.List; + +/** + * 食堂预约统计服务,提供菜品聚合以及用户和日期维度的预约查询。 + */ public interface HardCanteenApptTotalService { + /** + * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 + * + * @param page 分页参数 + * @param dto 预约订单筛选条件 + * @param dataScope 数据权限范围,空值表示不追加数据范围 SQL + * @return 菜品预约分页统计 + */ Page productReservTotal(Page page, HardOrderDto dto, DataScope dataScope); + /** + * 分页查询用户在指定日期范围内每天的预约情况,包含每天没有预约订单的权限范围用户。 + * + * @param page 用户和日期维度的分页参数 + * @param query 用户、日期范围和预约状态筛选条件 + * @return 用户每日预约分页结果 + */ + Page userReservationPage(Page page, HardCanteenUserApptQueryDto query); + + /** + * 查询权限范围内指定用户某一天的全部有效食堂预约,不继承列表的食堂和餐段筛选。 + * + * @param userId 列表行中的系统用户主键 + * @param apptUseDate 需要查看的用餐日期 + * @return 当天各餐段的有效预约订单;没有预约时返回空列表 + */ + List userReservationDetail(Long userId, LocalDate apptUseDate); + + /** + * 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。 + * + * @param query 用户、日期范围和预约状态筛选条件 + * @return 供前端轮询的初始任务进度 + */ + AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query); + + /** + * 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。 + * + * @param query 用户、日期范围和预约状态筛选条件 + * @return 用户和日期维度的 Excel 导出行 + */ + List userReservationExport(HardCanteenUserApptQueryDto query); } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 1c471c272..ee5ac55ca 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -1,22 +1,70 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.util.ObjectUtil; +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; +import com.bsd.pjcloud.admin.api.entity.SysDictItem; +import com.bsd.pjcloud.admin.api.entity.SysPost; +import com.bsd.pjcloud.admin.api.feign.RemoteDictService; +import com.bsd.pjcloud.admin.api.feign.RemoteFileService; +import com.bsd.pjcloud.admin.api.feign.RemoteUserService; +import com.bsd.pjcloud.admin.api.vo.UserVO; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; +import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; +import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; +import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; +import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; +import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; +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.dto.order.HardCanteenUserApptQueryDto; import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto; import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo; +import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; +import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; +import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; +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.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; /** - * 食堂统计 + * 食堂预约统计服务实现。 + * + *

用户预约查询始终以 UPMS 返回的数据权限用户为主表,再关联 Hardware 预约订单,避免遗漏没有预约日志的人员。

*/ @Validated @Slf4j @@ -24,8 +72,111 @@ import java.time.LocalDate; @Service public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalService { + /** UPMS 用户全量扫描的单页数量,避免状态筛选和导出产生过大的单次响应。 */ + private static final long USER_BATCH_SIZE = 500L; + + /** 单次订单查询的用户主键数量,避免 SQL IN 条件过长。 */ + private static final int ORDER_USER_BATCH_SIZE = 500; + + /** 用户预约导出任务的 Redis Key 命名空间。 */ + private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export"; + + /** 用户预约导出任务在统一进度弹窗中的展示名称。 */ + private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出"; + + /** 查询实际权限用户数量前创建异步任务使用的占位总数。 */ + private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1; + + /** 用户预约导出每批读取的权限用户数,单批最多展开为该人数乘以查询自然日数。 */ + private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200; + + /** 文件上传和异步任务成功收尾占用的额外进度步数。 */ + private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2; + + /** 用户预约导出的二级表头行数,第一行是业务分组,第二行是具体字段。 */ + private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2; + + /** 用户预约导出工作表二级表头之后允许写入的最大明细行数。 */ + private static final int MAX_USER_RESERVATION_EXPORT_ROWS = + BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT, 0); + + /** 用户预约导出工作表名称。 */ + private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询"; + + /** 文件中心保存用户预约导出文件的业务目录。 */ + private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export"; + + /** 文件中心区分用户预约导出文件的类型编码。 */ + private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT"; + + /** 用户预约 Excel 文件上传时使用的标准 MIME 类型。 */ + private static final String EXCEL_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /** 异步导出文件名使用的时间格式,避免连续任务产生同名文件。 */ + private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ + private static final long MAX_QUERY_MONTHS = 2L; + + /** 页面协议中的已订餐状态。 */ + private static final String RESERVATION_STATUS_RESERVED = "RESERVED"; + + /** 页面协议中的未订餐状态。 */ + private static final String RESERVATION_STATUS_NOT_RESERVED = "NOT_RESERVED"; + + /** 页面协议中的无需核销状态,表示当前筛选口径没有有效预约。 */ + private static final String VERIFICATION_STATUS_NONE = "NONE"; + + /** 页面协议中的全部待核销状态。 */ + private static final String VERIFICATION_STATUS_UNVERIFIED = "UNVERIFIED"; + + /** 页面协议中的部分预约已核销状态。 */ + private static final String VERIFICATION_STATUS_PARTIAL = "PARTIAL"; + + /** 页面协议中的全部已核销状态。 */ + private static final String VERIFICATION_STATUS_VERIFIED = "VERIFIED"; + + /** 允许的订餐状态筛选值,用于拒绝未知协议值。 */ + private static final Set RESERVATION_STATUS_VALUES = Set.of( + RESERVATION_STATUS_RESERVED, RESERVATION_STATUS_NOT_RESERVED); + + /** 允许的核销状态筛选值,用于拒绝未知协议值。 */ + private static final Set VERIFICATION_STATUS_VALUES = Set.of( + VERIFICATION_STATUS_NONE, VERIFICATION_STATUS_UNVERIFIED, + VERIFICATION_STATUS_PARTIAL, VERIFICATION_STATUS_VERIFIED); + + /** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */ + private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); + + /** Excel 中核销时间的统一展示格式。 */ + private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** 用餐时间段字典类型,属于 PC、App 与后端共同使用的既有协议。 */ + private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; + + /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; + /** 预约订单查询 Mapper,只读取当前租户内已由用户数据权限限定的人员订单。 */ + private final HardOrderMapper hardOrderMapper; + + /** 用户远程服务,负责返回当前操作者数据权限范围内的用户分页。 */ + private final RemoteUserService remoteUserService; + + /** 字典远程服务,用于在 Excel 中完整列出已配置和未预约的餐段。 */ + private final RemoteDictService remoteDictService; + + /** 商户远程服务,用于批量补齐预约所属食堂名称。 */ + private final RemoteMchService remoteMchService; + + /** 通用异步任务执行器,负责恢复租户、安全及 Feign 上下文并持久化导出进度。 */ + private final AsyncTaskExecutorService asyncTaskExecutorService; + + /** 文件中心远程服务,用于保存异步生成的用户预约 Excel。 */ + private final RemoteFileService remoteFileService; + /** * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 * @@ -50,4 +201,1133 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return pageList; } + /** + * 分页查询用户在指定日期范围内每天的预约情况。 + * + *

没有订餐或核销状态筛选时,只加载当前“用户 × 日期”分页涉及的用户;已订餐及核销状态先按订单定位 + * 候选用户,未订餐和无需核销仍遍历权限用户,随后生成逐日记录并筛选,保证结果总数和翻页准确。

+ * + * @param page 用户和日期维度的分页参数 + * @param query 用户、日期范围和预约状态筛选条件 + * @return 包含每天未预约用户的预约分页结果 + */ + @Override + public Page userReservationPage(Page page, HardCanteenUserApptQueryDto query) { + validateQuery(query); + long current = Math.max(page.getCurrent(), 1L); + long size = Math.max(page.getSize(), 1L); + List queryDates = getQueryDates(query); + if (hasJoinedStatusFilter(query)) { + HardCanteenUserApptQueryDto pageQuery = copyUserReservationQuery(query); + if (!narrowQueryToReservationCandidates(pageQuery)) { + return paginateRows(current, size, List.of()); + } + return paginateRows(current, size, listAllFilteredRows(pageQuery, queryDates)); + } + return pageRowsWithoutJoinedStatusFilter(current, size, query, queryDates); + } + + /** + * 查询权限范围内指定用户某一天的全部有效食堂预约。 + * + *

详情查询只使用用户和用餐日期,不继承列表上的食堂、餐段及状态筛选,保证弹窗能看到当天完整记录; + * 查询订单前先通过 UPMS 权限用户分页确认该用户仍在当前操作者的数据权限内。

+ * + * @param userId 列表行中的系统用户主键 + * @param apptUseDate 需要查看的用餐日期 + * @return 当天全部有效食堂预约;没有预约时返回空列表 + */ + @Override + public List userReservationDetail(Long userId, LocalDate apptUseDate) { + if (userId == null) { + throw new IllegalArgumentException("用户主键不能为空"); + } + if (apptUseDate == null) { + throw new IllegalArgumentException("用餐日期不能为空"); + } + HardCanteenUserApptQueryDto query = new HardCanteenUserApptQueryDto(); + query.setApptUseDateList(new LocalDate[]{apptUseDate, apptUseDate}); + query.setUserIds(List.of(userId)); + Page userPage = fetchUserPage(1L, 1L, query); + boolean permitted = userPage.getRecords() != null && userPage.getRecords().stream() + .anyMatch(user -> Objects.equals(user.getUserId(), userId)); + if (!permitted) { + throw new IllegalArgumentException("用户不存在或不在当前数据权限范围内"); + } + List orders = fetchReservationOrders(List.of(userId), query); + Map merchantNameMap = fetchMerchantNameMap(orders); + return orders.stream().map(order -> toOrderVo(order, merchantNameMap)).toList(); + } + + /** + * 提交用户每日预约异步导出任务。 + * + *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 + * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。

+ * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 已创建任务的初始进度 + */ + @Override + public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query); + return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY, + USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL, + reporter -> exportUserReservations(querySnapshot, reporter)); + } + + /** + * 查询用户预约 Excel 数据。 + * + * @param query 用户、日期范围和预约状态筛选条件 + * @return 用户和日期维度的导出行 + */ + @Override + public List userReservationExport(HardCanteenUserApptQueryDto query) { + validateQuery(query); + HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query); + if (!narrowQueryToReservationCandidates(exportQuery)) { + return List.of(); + } + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + return listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery)).stream() + .map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap)).toList(); + } + + /** + * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。 + * + * @param query 后台线程独占的用户预约筛选条件 + * @param reporter 当前异步任务进度报告器 + * @return 文件中心下载地址和业务文件名 + */ + private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query, + AsyncTaskProgressReporter reporter) { + if (!narrowQueryToReservationCandidates(query)) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + long userTotal = firstUserPage.getTotal(); + if (userTotal <= 0L) { + throw new IllegalStateException("没有符合当前条件的用户预约数据"); + } + validateUserReservationExportRowCapacity(userTotal, query); + int progressTotal = calculateUserReservationExportProgressTotal(userTotal); + reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件"); + + String fileName = "用户预约查询_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx"; + Path outputPath = null; + try { + outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx"); + long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal, + reporter); + String fileUrl = uploadUserReservationWorkbook(outputPath, fileName); + reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条"); + return AsyncTaskResult.file(fileUrl, fileName); + } catch (IllegalStateException exception) { + throw exception; + } catch (Exception exception) { + log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception); + throw new IllegalStateException("生成用户预约导出文件失败", exception); + } finally { + deleteTemporaryExportFile(outputPath); + } + } + + /** + * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。 + * + *

订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放, + * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。

+ * + * @param outputPath 最终上传前使用的临时 XLSX 文件 + * @param query 已冻结的筛选条件 + * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用 + * @param userTotal 任务开始时取得的权限用户总数 + * @param reporter 当前异步任务进度报告器 + * @return 实际写入 Excel 的用户日期行数 + */ + private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query, + Page firstUserPage, long userTotal, + AsyncTaskProgressReporter reporter) { + List queryDates = getQueryDates(query); + Map mealSegmentLabelMap = fetchMealSegmentLabelMap(); + long processedUserTotal = 0L; + long exportedRowTotal = 0L; + long current = 1L; + + try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) { + WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build(); + Page userPage = firstUserPage; + while (processedUserTotal < userTotal) { + List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords(); + if (pageRecords.isEmpty()) { + break; + } + int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal)); + List batchUsers = pageRecords.subList(0, remainingUserCount); + List batchRows = filterRows( + buildRows(batchUsers, query, queryDates), query).stream() + .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList(); + long nextExportedRowTotal = exportedRowTotal + batchRows.size(); + if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + if (!batchRows.isEmpty()) { + excelWriter.write(batchRows, writeSheet); + } + exportedRowTotal = nextExportedRowTotal; + processedUserTotal += batchUsers.size(); + reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 " + + exportedRowTotal + " 条预约记录"); + if (processedUserTotal >= userTotal) { + break; + } + userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query); + } + } + + if (processedUserTotal != userTotal) { + throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal + " 位,实际处理 " + + processedUserTotal + " 位"); + } + if (exportedRowTotal == 0L) { + throw new IllegalStateException("没有符合当前筛选条件的用户预约记录"); + } + return exportedRowTotal; + } + + /** + * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。 + * + * @param userTotal 当前筛选条件下的权限用户总数 + * @return 统一进度组件使用的总步数 + */ + private int calculateUserReservationExportProgressTotal(long userTotal) { + return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE, + USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS); + } + + /** + * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。 + * + *

存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。

+ * + * @param userTotal 当前用户筛选和数据权限范围内的用户总数 + * @param query 已冻结的日期和预约状态筛选条件 + */ + private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) { + if (hasJoinedStatusFilter(query)) { + return; + } + long rowTotal; + try { + rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size()); + } catch (ArithmeticException exception) { + throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception); + } + if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) { + throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出"); + } + } + + /** + * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。 + * + * @param outputPath 已完成写入的临时文件 + * @param fileName 用户下载时使用的业务文件名 + * @return 文件中心提供的下载地址 + * @throws Exception 临时文件读取或远程上传失败时抛出 + */ + private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception { + MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName), + EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath)); + R uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null, + USER_RESERVATION_EXPORT_FILE_TYPE); + if (uploadResult == null || !uploadResult.isOk() + || !(uploadResult.getData() instanceof Map resultData)) { + throw new IllegalStateException("文件中心未返回有效的用户预约导出结果"); + } + Object resultUrl = resultData.get("url"); + if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) { + throw new IllegalStateException("文件中心未返回用户预约导出下载地址"); + } + return resultUrl.toString(); + } + + /** + * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。 + * + * @param source 已通过基础校验的请求筛选条件 + * @return 后台线程独占的完整筛选条件副本 + */ + private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { + HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); + target.setApptUseDateList(source.getApptUseDateList() == null + ? null : source.getApptUseDateList().clone()); + target.setNameOrPhone(source.getNameOrPhone()); + target.setDeptIds(copyList(source.getDeptIds())); + target.setIsInclude(source.getIsInclude()); + target.setMerchantId(source.getMerchantId()); + target.setHourType(source.getHourType()); + target.setReservationStatus(source.getReservationStatus()); + target.setVerificationStatus(source.getVerificationStatus()); + target.setUserIds(copyList(source.getUserIds())); + return target; + } + + /** + * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * + * @param source 请求对象中的可变列表 + * @param 列表元素类型 + * @return 空值保持为空,非空列表返回独立副本 + */ + private List copyList(List source) { + return source == null ? null : new ArrayList<>(source); + } + + /** + * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * + * @param fileName 用户看到的 UTF-8 文件名 + * @return 文件中心上传接口使用的原始文件名 + */ + private String encodeUploadOriginalFilename(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + + /** + * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。 + * + * @param outputPath 待删除的临时文件路径,允许为空 + */ + private void deleteTemporaryExportFile(Path outputPath) { + if (outputPath == null) { + return; + } + try { + Files.deleteIfExists(outputPath); + } catch (Exception exception) { + log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + } + } + + /** + * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。 + * + * @param query 待校验的查询条件 + */ + private void validateQuery(HardCanteenUserApptQueryDto query) { + if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2 + || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) { + throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期"); + } + LocalDate startDate = query.getApptUseDateList()[0]; + LocalDate endDate = query.getApptUseDateList()[1]; + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("用餐结束日期不能早于开始日期"); + } + if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) { + throw new IllegalArgumentException("用餐日期范围最多查询两个月"); + } + if (StrUtil.isNotBlank(query.getReservationStatus()) + && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) { + throw new IllegalArgumentException("订餐状态不正确"); + } + if (StrUtil.isNotBlank(query.getVerificationStatus()) + && !VERIFICATION_STATUS_VALUES.contains(query.getVerificationStatus())) { + throw new IllegalArgumentException("核销状态不正确"); + } + } + + /** + * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。 + * + * @param query 已通过日期范围校验的查询条件 + * @return 按日期升序排列的查询日期 + */ + private List getQueryDates(HardCanteenUserApptQueryDto query) { + LocalDate endDate = query.getApptUseDateList()[1]; + List dates = new ArrayList<>(); + LocalDate currentDate = query.getApptUseDateList()[0]; + while (!currentDate.isAfter(endDate)) { + dates.add(currentDate); + if (currentDate.equals(endDate)) { + break; + } + currentDate = currentDate.plusDays(1L); + } + return dates; + } + + /** + * 判断查询是否需要在用户与预约订单关联后再进行筛选。 + * + * @param query 查询条件 + * @return 存在订餐或核销状态条件时返回 true + */ + private boolean hasJoinedStatusFilter(HardCanteenUserApptQueryDto query) { + return StrUtil.isNotBlank(query.getReservationStatus()) || StrUtil.isNotBlank(query.getVerificationStatus()); + } + + /** + * 判断当前筛选结果是否必然存在有效预约订单。 + * + *

已订餐以及待核销、部分核销、已核销都可以先从订单表反查候选用户;未订餐和无需核销属于 + * “订单不存在”条件,仍需以全部权限用户为主表执行左关联。

+ * + * @param query 预约状态和核销状态筛选条件 + * @return 可以先按预约订单收窄用户范围时返回 true + */ + private boolean requiresExistingReservation(HardCanteenUserApptQueryDto query) { + return Objects.equals(query.getReservationStatus(), RESERVATION_STATUS_RESERVED) + || (StrUtil.isNotBlank(query.getVerificationStatus()) + && !Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_NONE)); + } + + /** + * 对必然存在预约的列表或导出条件先查询候选用户,并写回独立查询快照。 + * + *

候选 ID 只负责缩小 UPMS 查询范围,姓名、部门和当前操作者的数据权限仍由 UPMS 最终过滤; + * 原查询已指定用户时,订单查询也会先应用该范围,因此不会扩大导出对象。

+ * + * @param query 当前列表或导出流程独占、允许写入候选用户主键的查询快照 + * @return 无需预筛或存在候选用户时返回 true,确定没有匹配预约时返回 false + */ + private boolean narrowQueryToReservationCandidates(HardCanteenUserApptQueryDto query) { + if (!requiresExistingReservation(query)) { + return true; + } + List candidateUserIds = hardOrderMapper.listReservationUserIds( + buildReservationOrderQuery(query, copyList(query.getUserIds()))); + if (candidateUserIds == null || candidateUserIds.isEmpty()) { + return false; + } + query.setUserIds(candidateUserIds.stream().filter(Objects::nonNull).distinct().toList()); + return !query.getUserIds().isEmpty(); + } + + /** + * 获取全部权限用户并在关联订单后执行状态筛选。 + * + * @param query 查询条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 符合用户和预约状态条件的全部行 + */ + private List listAllFilteredRows(HardCanteenUserApptQueryDto query, + List queryDates) { + List users = fetchAllUsers(query); + return filterRows(buildRows(users, query, queryDates), query); + } + + /** + * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 + * + *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

+ * + * @param query 查询条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 携带完整用户主资料和身份资料的预约导出行 + */ + private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, + List queryDates) { + List users = fetchAllExportUsers(query); + return filterRows(buildRows(users, query, queryDates), query); + } + + /** + * 在没有关联状态筛选时,按“用户在前、日期在后”的稳定顺序查询当前结果页。 + * + *

先根据用户总数计算“用户 × 日期”总行数,再只加载当前页覆盖的用户,避免普通翻页展开全部权限用户。

+ * + * @param current 当前页码 + * @param size 每页用户日期行数 + * @param query 用户和预约查询条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 未应用订餐或核销状态筛选的用户日期分页 + */ + private Page pageRowsWithoutJoinedStatusFilter(long current, long size, + HardCanteenUserApptQueryDto query, + List queryDates) { + long offset; + try { + offset = Math.multiplyExact(current - 1L, size); + } catch (ArithmeticException exception) { + Page countPage = fetchUserPage(1L, 1L, query); + return emptyUserReservationPage(current, size, countPage.getTotal(), queryDates.size()); + } + + long dateCount = queryDates.size(); + long requestedEndExclusive; + try { + requestedEndExclusive = Math.addExact(offset, size); + } catch (ArithmeticException exception) { + requestedEndExclusive = Long.MAX_VALUE; + } + long startUserIndex = offset / dateCount; + long requestedEndUserIndexExclusive = (requestedEndExclusive - 1L) / dateCount + 1L; + long userPageSize = Math.min(USER_BATCH_SIZE, + Math.max(1L, requestedEndUserIndexExclusive - startUserIndex)); + long anchorUserPageCurrent = startUserIndex / userPageSize + 1L; + Page anchorUserPage = fetchUserPage(anchorUserPageCurrent, userPageSize, query); + long totalRows; + try { + totalRows = Math.multiplyExact(anchorUserPage.getTotal(), dateCount); + } catch (ArithmeticException exception) { + throw new IllegalStateException("用户预约查询结果数量过大", exception); + } + Page result = new Page<>(current, size, totalRows); + result.setRecords(List.of()); + if (totalRows == 0L) { + return result; + } + + if (offset >= totalRows) { + return result; + } + + long rowCount = Math.min(size, totalRows - offset); + long pageEndExclusive = offset + rowCount; + long endUserIndexExclusive = (pageEndExclusive - 1L) / dateCount + 1L; + List users = fetchUserSlice(startUserIndex, endUserIndexExclusive, query, + anchorUserPageCurrent, userPageSize, anchorUserPage); + int firstUserDateOffset = Math.toIntExact(offset % dateCount); + List rows = buildRows(users, query, queryDates).stream() + .skip(firstUserDateOffset).limit(rowCount).toList(); + result.setRecords(rows); + return result; + } + + /** + * 创建无法定位有效记录时的空预约分页,并保留可计算的用户日期总数。 + * + * @param current 当前页码 + * @param size 每页用户日期行数 + * @param userTotal 符合用户条件的总人数 + * @param dateCount 查询范围包含的自然日数量 + * @return 不含记录但保留总数的分页结果 + */ + private Page emptyUserReservationPage(long current, long size, long userTotal, + int dateCount) { + long totalRows; + try { + totalRows = Math.multiplyExact(userTotal, dateCount); + } catch (ArithmeticException exception) { + throw new IllegalStateException("用户预约查询结果数量过大", exception); + } + Page result = new Page<>(current, size, totalRows); + result.setRecords(List.of()); + return result; + } + + /** + * 按用户结果的全局下标区间读取连续用户,复用已获取的锚点分页并按需补查相邻页。 + * + * @param startIndex 起始用户下标,包含该位置且从零开始 + * @param endIndexExclusive 结束用户下标,不包含该位置 + * @param query 用户筛选条件 + * @param anchorCurrent 已用于获取用户总数的锚点页码 + * @param userPageSize 用户分页每页人数,按当前结果页实际覆盖人数计算且不超过批量上限 + * @param anchorUserPage 已用于获取总数的锚点用户分页 + * @return 指定下标区间内按 UPMS 原顺序排列的用户 + */ + private List fetchUserSlice(long startIndex, long endIndexExclusive, + HardCanteenUserApptQueryDto query, long anchorCurrent, + long userPageSize, Page anchorUserPage) { + if (startIndex >= endIndexExclusive) { + return List.of(); + } + List users = new ArrayList<>(); + long current = startIndex / userPageSize + 1L; + while ((current - 1L) * userPageSize < endIndexExclusive) { + Page userPage = current == anchorCurrent + ? anchorUserPage : fetchUserPage(current, userPageSize, query); + List records = userPage.getRecords() == null ? List.of() : userPage.getRecords(); + if (records.isEmpty()) { + break; + } + long pageStartIndex = (current - 1L) * userPageSize; + int fromIndex = Math.toIntExact(Math.max(0L, startIndex - pageStartIndex)); + int toIndex = Math.toIntExact(Math.min(records.size(), endIndexExclusive - pageStartIndex)); + if (fromIndex < toIndex) { + users.addAll(records.subList(fromIndex, toIndex)); + } + current++; + } + return users; + } + + /** + * 调用 UPMS 的数据权限分页接口查询一页用户。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param query 用户筛选条件 + * @return 含准确总数的用户分页 + */ + private Page fetchUserPage(long current, long size, HardCanteenUserApptQueryDto query) { + R> response = remoteUserService.getScopeBasicUserPage(current, size, + buildUserQuery(query)); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.isBlank(message) ? "查询权限用户列表失败" : message); + } + return response.getData(); + } + + /** + * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param query 用户筛选条件 + * @return 含准确总数和完整导出资料的用户分页 + */ + private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) { + R> response = remoteUserService.getScopeExportUserPage(current, size, + buildUserQuery(query)); + if (response == null || !response.isOk() || response.getData() == null) { + String message = response == null ? null : response.getMsg(); + throw new IllegalStateException(StrUtil.isBlank(message) ? "查询用户导出资料失败" : message); + } + return response.getData(); + } + + /** + * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。 + * + * @param query 用户预约查询条件 + * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象 + */ + private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) { + UserDTO userQuery = new UserDTO(); + userQuery.setNameOrPhone(query.getNameOrPhone()); + userQuery.setDeptIds(copyList(query.getDeptIds())); + userQuery.setIsInclude(query.getIsInclude()); + userQuery.setUserIds(copyList(query.getUserIds())); + return userQuery; + } + + /** + * 分页遍历当前操作者数据权限内的全部用户。 + * + * @param query 用户筛选条件 + * @return 权限范围内的全部用户 + */ + private List fetchAllUsers(HardCanteenUserApptQueryDto query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + Page page = fetchUserPage(current, USER_BATCH_SIZE, query); + List records = page.getRecords() == null ? List.of() : page.getRecords(); + users.addAll(records); + total = page.getTotal(); + if (records.isEmpty()) { + break; + } + current++; + } while (users.size() < total); + return users; + } + + /** + * 分页遍历完整用户导出资料,供兼容同步导出使用。 + * + *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

+ * + * @param query 用户筛选条件 + * @return 权限范围内携带角色岗位和身份资料的全部用户 + */ + private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) { + List users = new ArrayList<>(); + long current = 1L; + long total; + do { + Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query); + List records = page.getRecords() == null ? List.of() : page.getRecords(); + users.addAll(records); + total = page.getTotal(); + if (records.isEmpty()) { + break; + } + current++; + } while (users.size() < total); + return users; + } + + /** + * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。 + * + * @param users 权限用户列表 + * @param query 日期范围、食堂和餐段筛选条件 + * @param queryDates 查询范围内按升序排列的全部日期 + * @return 按用户顺序、日期升序排列的预约行 + */ + private List buildRows(List users, HardCanteenUserApptQueryDto query, + List queryDates) { + if (users == null || users.isEmpty()) { + return List.of(); + } + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + Map merchantNameMap = fetchMerchantNameMap(orders); + Map>> orderMap = orders.stream() + .filter(order -> order.getUserId() != null && order.getApptUseDate() != null) + .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new, + Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new, + Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList())))); + + return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> { + Map> userOrderMap = + orderMap.getOrDefault(user.getUserId(), Map.of()); + List userOrders = userOrderMap.getOrDefault(date, List.of()); + HardCanteenUserApptVo row = new HardCanteenUserApptVo(); + row.setUserId(user.getUserId()); + row.setUserName(resolveUserName(user)); + row.setUsername(user.getUsername()); + row.setPhone(user.getPhone()); + row.setAvatar(user.getAvatar()); + row.setDeptId(user.getDeptId()); + row.setDeptName(user.getDeptName()); + row.setNickname(user.getNickname()); + row.setGender(user.getGender()); + row.setEmail(user.getEmail()); + row.setCreateTime(user.getCreateTime()); + row.setPostList(user.getPostList()); + row.setRoleList(user.getRoleList()); + row.setStaffingType(user.getStaffingType()); + row.setLockFlag(user.getLockFlag()); + // UPMS 的权限用户分页已批量补齐身份资料,这里直接复制,避免按用户再次远程查询。 + row.setIdentityTypes(user.getIdentityTypes()); + row.setStudentInfo(user.getStudentInfo()); + row.setTeacherInfo(user.getTeacherInfo()); + row.setApptUseDate(date); + row.setReservationStatus(userOrders.isEmpty() + ? RESERVATION_STATUS_NOT_RESERVED : RESERVATION_STATUS_RESERVED); + row.setVerificationStatus(resolveVerificationStatus(userOrders)); + row.setTotalPrice(calculateTotalPrice(userOrders)); + row.setOrders(userOrders); + return row; + })).toList(); + } + + /** + * 按用户主键分批查询指定日期范围内的有效食堂预约订单。 + * + * @param userIds 权限范围内的用户主键 + * @param query 日期范围、食堂和餐段筛选条件 + * @return 当前租户内符合条件的有效预约订单 + */ + private List fetchReservationOrders(List userIds, HardCanteenUserApptQueryDto query) { + if (userIds.isEmpty()) { + return List.of(); + } + List orders = new ArrayList<>(); + for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) { + int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size()); + HardOrderDto orderQuery = buildReservationOrderQuery(query, + new ArrayList<>(userIds.subList(start, end))); + orders.addAll(hardOrderMapper.scopeList(orderQuery, null)); + } + return orders; + } + + /** + * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 + * + *

统一复用可保证预查询命中的用户与最终写入 Excel 的有效预约口径一致,避免日期、食堂、餐段、 + * 订单状态或预约关联条件在两次查询间发生偏差。

+ * + * @param query 用户预约页面提交的日期、食堂和餐段筛选条件 + * @param userIds 需要限制的用户主键;为空时查询当前租户内全部候选预约用户 + * @return 可直接交给订单 Mapper 的完整预约条件 + */ + private HardOrderDto buildReservationOrderQuery(HardCanteenUserApptQueryDto query, List userIds) { + HardOrderDto orderQuery = new HardOrderDto(); + orderQuery.setUserIds(userIds); + orderQuery.setMerchantType(MerchantTypeEnum.CANTEEN.getType()); + orderQuery.setMerchantId(query.getMerchantId()); + orderQuery.setHourType(query.getHourType()); + orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES); + orderQuery.setApptIdOrApptRuleIdNotNull(true); + orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{ + query.getApptUseDateList()[0].atStartOfDay(), + query.getApptUseDateList()[1].atTime(LocalTime.MAX) + }); + return orderQuery; + } + + /** + * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。 + * + * @param orders 有效预约订单 + * @return 商户主键到食堂名称的映射 + */ + private Map fetchMerchantNameMap(List orders) { + List merchantIds = orders.stream().map(HardOrderVo::getMerchantId) + .filter(Objects::nonNull).distinct().toList(); + if (merchantIds.isEmpty()) { + return Map.of(); + } + try { + R> response = remoteMchService.getByIds(merchantIds); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用户预约食堂名称失败,保留预约订单并使用空名称"); + return Map.of(); + } + return response.getData().stream().filter(merchant -> + merchant.getId() != null && StrUtil.isNotBlank(merchant.getName())) + .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用户预约食堂名称异常,保留预约订单并使用空名称,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将订单查询对象转换为页面所需的最小预约明细。 + * + * @param order 有效预约订单 + * @param merchantNameMap 食堂名称映射 + * @return 预约明细 + */ + private HardCanteenUserApptOrderVo toOrderVo(HardOrderVo order, Map merchantNameMap) { + HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo(); + detail.setId(order.getId()); + detail.setHourType(order.getHourType()); + detail.setProductDesc(order.getProductDesc()); + detail.setMerchantName(merchantNameMap.get(order.getMerchantId())); + detail.setTotalPrice(order.getTotalPrice()); + detail.setOrderStatus(order.getOrderStatus()); + detail.setUpdateTime(order.getUpdateTime()); + detail.setUpdateBy(order.getUpdateBy()); + return detail; + } + + /** + * 汇总预约明细的商品总价,历史空金额按零分计算。 + * + * @param orders 同一用户、同一用餐日期和当前查询口径下的有效预约明细 + * @return 商品总价,单位为分;没有预约时返回零 + */ + private long calculateTotalPrice(List orders) { + return orders.stream().map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + } + + /** + * 依次按姓名、昵称和登录账号确定页面展示名。 + * + * @param user 用户信息 + * @return 非空展示名;用户资料均未维护时返回“未命名用户” + */ + private String resolveUserName(UserVO user) { + if (StrUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return StrUtil.isBlank(user.getUsername()) ? "未命名用户" : user.getUsername(); + } + + /** + * 根据有效预约明细计算用户整体核销状态。 + * + * @param orders 用户在当前筛选口径下的有效预约明细 + * @return NONE、UNVERIFIED、PARTIAL 或 VERIFIED + */ + private String resolveVerificationStatus(List orders) { + if (orders.isEmpty()) { + return VERIFICATION_STATUS_NONE; + } + long verifiedCount = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).count(); + if (verifiedCount == 0L) { + return VERIFICATION_STATUS_UNVERIFIED; + } + return verifiedCount == orders.size() ? VERIFICATION_STATUS_VERIFIED : VERIFICATION_STATUS_PARTIAL; + } + + /** + * 按订餐和核销状态筛选已完成左关联的用户行。 + * + * @param rows 用户预约行 + * @param query 状态筛选条件 + * @return 符合状态条件的行 + */ + private List filterRows(List rows, + HardCanteenUserApptQueryDto query) { + return rows.stream().filter(row -> + (StrUtil.isBlank(query.getReservationStatus()) + || Objects.equals(query.getReservationStatus(), row.getReservationStatus())) + && (StrUtil.isBlank(query.getVerificationStatus()) + || Objects.equals(query.getVerificationStatus(), row.getVerificationStatus()))) + .toList(); + } + + /** + * 对关联状态筛选后的用户行执行内存分页。 + * + * @param current 当前页码 + * @param size 每页数量 + * @param rows 全部筛选结果 + * @return 标准 MyBatis-Plus 分页结果 + */ + private Page paginateRows(long current, long size, + List rows) { + long offset = (current - 1L) * size; + int fromIndex = offset >= rows.size() ? rows.size() : Math.toIntExact(offset); + long endOffset = Math.min(offset + size, rows.size()); + int toIndex = Math.max(fromIndex, Math.toIntExact(endOffset)); + Page result = new Page<>(current, size, rows.size()); + result.setRecords(rows.subList(fromIndex, toIndex)); + return result; + } + + /** + * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。 + * + * @param row 用户预约汇总 + * @param query 原始查询条件,用于无订单时保留所选餐段 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return Excel 导出行 + */ + private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + List orders = row.getOrders(); + List verifiedOrders = orders.stream().filter(order -> + Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList(); + HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo(); + excel.setUserId(row.getUserId()); + excel.setUserName(row.getUserName()); + excel.setEmail(row.getEmail()); + excel.setPhone(row.getPhone()); + excel.setDeptName(row.getDeptName()); + excel.setPostNames(joinPostNames(row.getPostList())); + excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes())); + if (row.getStudentInfo() != null) { + excel.setStudentNo(row.getStudentInfo().getStudentNo()); + excel.setSchoolName(row.getStudentInfo().getSchoolName()); + excel.setClassName(row.getStudentInfo().getClassName()); + excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones()); + } + if (row.getTeacherInfo() != null) { + excel.setTeacherNo(row.getTeacherInfo().getTeacherNo()); + } + excel.setApptUseDate(row.getApptUseDate()); + excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED) + ? "已订餐" : "未订餐"); + excel.setHourTypes(orders.isEmpty() + ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段" + : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap)) + : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(), + mealSegmentLabelMap))); + excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING)); + excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING)); + excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT)); + excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT)); + excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap)); + excel.setTotalPrice(centsToYuan(row.getTotalPrice())); + excel.setProductDescriptions(joinDistinct(orders, order -> + StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc())); + excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName)); + excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus())); + excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null + ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime()))); + return excel; + } + + /** + * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。 + * + * @return 餐段编码到展示标签的有序映射 + */ + private Map fetchMealSegmentLabelMap() { + try { + R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); + if (response == null || !response.isOk() || response.getData() == null) { + log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码"); + return Map.of(); + } + return response.getData().stream().filter(item -> + StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, + (left, right) -> left, LinkedHashMap::new)); + } catch (Exception exception) { + log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage()); + return Map.of(); + } + } + + /** + * 将餐段协议编码转换为导出文案,并兼容历史空编码。 + * + * @param hourType 订单或查询条件中的餐段协议编码 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段” + */ + private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) { + if (StrUtil.isBlank(hourType)) { + return "未标记餐段"; + } + return mealSegmentLabelMap.getOrDefault(hourType, hourType); + } + + /** + * 生成一个固定餐段的预约状态。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段 + * @param hourType 固定餐段协议枚举 + * @return “已订N单”“未订餐”或“未查询” + */ + private String buildMealSegmentReservation(List orders, + HardCanteenUserApptQueryDto query, + HardHourTypeEnum hourType) { + if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) { + return "未查询"; + } + long orderCount = orders.stream().filter(order -> + Objects.equals(hourType.getType(), order.getHourType())).count(); + return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单"; + } + + /** + * 生成各餐段商品总价摘要,未订餐段明确输出零元。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件,选择餐段时只生成该餐段金额 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 例如“早餐:0.00元;午餐:18.50元”的摘要 + */ + private String buildMealSegmentTotalPrices(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap); + if (segments.isEmpty()) { + return "全部餐段:0.00元"; + } + return segments.entrySet().stream().map(entry -> { + long totalPrice = orders.stream().filter(order -> + Objects.equals(entry.getKey(), order.getHourType())) + .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + return entry.getValue() + ":" + formatYuan(totalPrice); + }).collect(Collectors.joining(";")); + } + + /** + * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。 + * + * @param orders 用户当天有效预约明细 + * @param query 原始查询条件 + * @param mealSegmentLabelMap 餐段编码到字典标签的映射 + * @return 保持字典顺序并补齐历史编码的餐段映射 + */ + private Map resolveMealSegments(List orders, + HardCanteenUserApptQueryDto query, + Map mealSegmentLabelMap) { + Map segments = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(query.getHourType())) { + segments.put(query.getHourType(), + mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType())); + } else { + segments.putAll(mealSegmentLabelMap); + orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank) + .forEach(hourType -> segments.putIfAbsent(hourType, hourType)); + } + return segments; + } + + /** + * 提取、去空、去重并连接预约明细字段。 + * + * @param orders 预约明细列表 + * @param mapper 字段提取函数 + * @return 使用中文分号连接的字段值;没有有效值时返回空字符串 + */ + private String joinDistinct(List orders, + Function mapper) { + return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct() + .collect(Collectors.joining(";")); + } + + /** + * 将整数分转换为精确的元金额。 + * + * @param price 商品总价,单位为分;为空时按零计算 + * @return 以元为单位且固定两位小数精度的金额 + */ + private BigDecimal centsToYuan(Long price) { + return BigDecimal.valueOf(price == null ? 0L : price, 2); + } + + /** + * 将整数分格式化为带元单位的两位小数文案。 + * + * @param price 商品总价,单位为分 + * @return 例如“18.50元”的金额文案 + */ + private String formatYuan(long price) { + return centsToYuan(price).toPlainString() + "元"; + } + + /** + * 拼接用户岗位名称,空岗位列表输出空字符串。 + * + * @param posts 完整用户导出分页返回的岗位列表 + * @return 以中文逗号分隔的岗位名称 + */ + private String joinPostNames(List posts) { + if (posts == null || posts.isEmpty()) { + return ""; + } + return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName) + .filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 按用户身份协议顺序拼接身份中文名称。 + * + * @param identityTypes UPMS 返回的用户身份协议编码 + * @return 以中文逗号分隔的身份名称;未知历史编码保留原值 + */ + private String joinIdentityNames(List identityTypes) { + if (identityTypes == null || identityTypes.isEmpty()) { + return ""; + } + return identityTypes.stream().map(identityType -> { + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + return identity == null ? identityType : identity.getDescription(); + }).filter(StrUtil::isNotBlank).collect(Collectors.joining(",")); + } + + /** + * 将核销状态协议值转换为导出文案。 + * + * @param status 核销状态协议值 + * @return 中文核销状态 + */ + private String verificationStatusText(String status) { + if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) { + return "已核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) { + return "部分核销"; + } + if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) { + return "待核销"; + } + return "无需核销"; + } + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/task/ExecutorConfig.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/task/ExecutorConfig.java index 3fac42679..890727897 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/task/ExecutorConfig.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/task/ExecutorConfig.java @@ -2,16 +2,30 @@ package com.bsd.pjcloud.hard.task; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +/** + * 硬件服务线程池配置。 + *

硬件业务异步调用继续使用原有线程池,带安全上下文的任务使用独立固定线程池。

+ */ @Configuration public class ExecutorConfig { + /** + * 创建硬件业务默认异步线程池。 + *

公共异步任务模块会额外注册专用线程池,因此将原有硬件线程池标记为主候选,确保公共核心 + * {@code AsyncConfiguration} 仍将全局 {@code @Async} 调用交给该线程池;公共异步任务自身通过 Bean 名称 + * 显式选择专用线程池,不受主候选设置影响。

+ * + * @return 用于硬件业务异步调用的线程池 + */ @Bean + @Primary public ThreadPoolTaskExecutor hardTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); @@ -22,6 +36,11 @@ public class ExecutorConfig { return executor; } + /** + * 创建能够把当前 Spring Security 上下文传递到工作线程的执行器。 + * + * @return 包装固定线程池的安全上下文执行器 + */ @Bean public DelegatingSecurityContextExecutor delegatingSecurityContextExecutor() { ExecutorService executorService = Executors.newFixedThreadPool(10); 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 8e59846ee..989dd6dcd 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 @@ -282,6 +282,17 @@ + + + + + +