feat:增加人员预约统计和导出

This commit is contained in:
dzc committed 2026-08-18 20:15:07 +08:00
1 parent cb5fdc54d8
commit 5910c5ab7c
24 files changed
+3446 -75

No files matched your search

@@ -0,0 +1,72 @@
# 通用 Excel 模块
`pjcloud-common-excel` 提供 Excel 导入、普通导出以及大数据量 XLSX 分批流式导出能力。
## 分批流式导出
`BatchExcelExportTemplate` 适用于单次导出不能把全部查询结果和全部工作表行同时保留在 JVM 堆中的场景。它统一负责:
- 根据预统计数量循环分页查询;
- 使用 `SXSSFWorkbook` 把超出内存窗口的工作表行刷新到磁盘;
- 校验分页实际数量与预统计数量一致;
- 校验 Excel 2007 单工作表行数上限;
- 释放模板流、工作簿、SXSSF 临时 XML 和最终 XLSX 临时文件;
- 通过回调接入业务进度、汇总行和文件上传逻辑。
业务模块先增加公共 Excel 依赖:
```xml
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-excel</artifactId>
</dependency>
```
典型调用方式:
```java
String fileUrl = BatchExcelExportTemplate.<ExportRow, ExportContext, String>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<ExportRow> 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 行上限约束;超过上限时应按业务条件拆分文件或扩展为多工作表导出。
@@ -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 模板的通用分批流式导出模板。
*
* <p>该模板统一管理分页循环、预统计数量校验、SXSSF 临时文件和最终 XLSX 临时文件,业务方只需提供
* 模板初始化、分页查询、逐行写入、尾部写入、进度通知和文件消费回调。该类不依赖异步任务或文件中心,
* 可由不同业务按需组合。</p>
*/
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 <T> 分页查询返回的业务行类型
* @param <C> 模板初始化后供逐行及尾部写入共享的上下文类型
* @param <R> 最终文件消费回调的返回类型
* @return 尚未执行的导出配置器
*/
public static <T, C, R> Builder<T, C, R> 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;
}
/**
* 计算异步任务可使用的总进度步数。
*
* <p>每个查询批次占一步,文件上传、异步任务成功落库等非批次动作由业务方通过额外步数声明。</p>
*
* @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 <C> 后续逐行和尾部写入共享的上下文类型
*/
@FunctionalInterface
public interface TemplateInitializer<C> {
/**
* 在 XSSFWorkbook 转换为 SXSSFWorkbook 前初始化模板。
*
* @param workbook 可编辑的模板工作簿,生命周期由公共导出模板管理
* @return 后续写入共享的业务上下文,允许为空
* @throws Exception 模板结构不符合业务约束时抛出
*/
C initialize(XSSFWorkbook workbook) throws Exception;
}
/**
* 分页数据查询回调,业务查询必须使用稳定且唯一的排序规则。
*
* @param <T> 业务行类型
*/
@FunctionalInterface
public interface PageFetcher<T> {
/**
* 查询指定页的数据。
*
* @param pageNumber 从一开始的页码
* @param batchSize 单页最大记录数
* @return 当前页记录;无记录时可返回空列表或空值
* @throws Exception 数据查询失败时抛出
*/
List<T> fetch(long pageNumber, int batchSize) throws Exception;
}
/**
* 单行写入回调,业务可在此补充序号、格式化字段和累计跨批次汇总。
*
* @param <T> 业务行类型
* @param <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface RowWriter<T, C> {
/**
* 将一条业务记录写入指定工作表行。
*
* @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 <C> 模板共享上下文类型
*/
@FunctionalInterface
public interface FooterWriter<C> {
/**
* 从预留尾部区域的第一行开始写入业务内容。
*
* @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 <R> 文件消费结果类型
*/
@FunctionalInterface
public interface FileConsumer<R> {
/**
* 在临时文件仍然存在时消费完整 XLSX 文件。
*
* @param file 临时 XLSX 文件,只在回调执行期间有效,不得异步持有
* @return 文件上传地址等业务结果
* @throws Exception 文件读取、上传或移动失败时抛出
*/
R consume(Path file) throws Exception;
}
/**
* 单次分批流式导出的链式配置器。
*
* <p>配置器是有状态且非线程安全的,应在每次导出时新建并仅由当前线程使用。</p>
*
* @param <T> 分页查询返回的业务行类型
* @param <C> 模板共享上下文类型
* @param <R> 完整文件消费结果类型
*/
public static final class Builder<T, C, R> {
/** 导出前按相同筛选条件统计的明细总数。 */
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<C> templateInitializer;
/** 业务提供的分页查询回调。 */
private PageFetcher<T> pageFetcher;
/** 业务提供的逐行写入回调。 */
private RowWriter<T, C> rowWriter;
/** 可选的业务尾部写入回调。 */
private FooterWriter<C> footerWriter;
/** 可选的批次进度通知回调。 */
private ProgressListener progressListener;
/** 业务提供的完整文件消费回调。 */
private FileConsumer<R> fileConsumer;
/**
* 创建单次导出配置器。
*
* @param expectedTotal 导出前预统计的明细总数
*/
private Builder(long expectedTotal) {
this.expectedTotal = expectedTotal;
}
/**
* 设置分页大小和 SXSSF 内存行窗口大小。
*
* @param batchSize 正整数批大小
* @return 当前配置器
*/
public Builder<T, C, R> batchSize(int batchSize) {
this.batchSize = batchSize;
return this;
}
/**
* 设置明细开始写入的 Excel 零基行号。
*
* @param dataStartRowIndex 非负的零基行号
* @return 当前配置器
*/
public Builder<T, C, R> dataStartRowIndex(int dataStartRowIndex) {
this.dataStartRowIndex = dataStartRowIndex;
return this;
}
/**
* 设置明细之后必须预留的尾部行数。
*
* @param reservedTailRowCount 合计、备注等尾部内容占用的非负行数
* @return 当前配置器
*/
public Builder<T, C, R> reservedTailRowCount(int reservedTailRowCount) {
this.reservedTailRowCount = reservedTailRowCount;
return this;
}
/**
* 设置目标工作表下标。
*
* @param sheetIndex 非负的工作表零基下标
* @return 当前配置器
*/
public Builder<T, C, R> sheetIndex(int sheetIndex) {
this.sheetIndex = sheetIndex;
return this;
}
/**
* 设置最终 XLSX 临时文件的文件名前缀。
*
* @param temporaryFilePrefix 至少三个字符的文件名前缀
* @return 当前配置器
*/
public Builder<T, C, R> temporaryFilePrefix(String temporaryFilePrefix) {
this.temporaryFilePrefix = temporaryFilePrefix;
return this;
}
/**
* 设置是否压缩 SXSSF 的临时 XML 文件。
*
* @param compressTemporaryFiles 是否启用临时 XML 压缩
* @return 当前配置器
*/
public Builder<T, C, R> compressTemporaryFiles(boolean compressTemporaryFiles) {
this.compressTemporaryFiles = compressTemporaryFiles;
return this;
}
/**
* 设置是否启用共享字符串表。
*
* @param useSharedStringsTable 是否把字符串集中写入共享表
* @return 当前配置器
*/
public Builder<T, C, R> useSharedStringsTable(boolean useSharedStringsTable) {
this.useSharedStringsTable = useSharedStringsTable;
return this;
}
/**
* 设置模板输入流工厂。
*
* @param templateSupplier 每次执行时打开一个新模板流的回调
* @return 当前配置器
*/
public Builder<T, C, R> templateSupplier(TemplateSupplier templateSupplier) {
this.templateSupplier = templateSupplier;
return this;
}
/**
* 设置模板初始化回调。
*
* @param templateInitializer 模板样式读取和占位内容清理回调
* @return 当前配置器
*/
public Builder<T, C, R> templateInitializer(TemplateInitializer<C> templateInitializer) {
this.templateInitializer = templateInitializer;
return this;
}
/**
* 设置分页查询回调。
*
* @param pageFetcher 按页码和批大小查询业务数据的回调
* @return 当前配置器
*/
public Builder<T, C, R> pageFetcher(PageFetcher<T> pageFetcher) {
this.pageFetcher = pageFetcher;
return this;
}
/**
* 设置逐行写入回调。
*
* @param rowWriter 将单条业务数据写入工作表的回调
* @return 当前配置器
*/
public Builder<T, C, R> rowWriter(RowWriter<T, C> rowWriter) {
this.rowWriter = rowWriter;
return this;
}
/**
* 设置可选的尾部写入回调。
*
* @param footerWriter 明细写完后写入合计、备注等内容的回调
* @return 当前配置器
*/
public Builder<T, C, R> footerWriter(FooterWriter<C> footerWriter) {
this.footerWriter = footerWriter;
return this;
}
/**
* 设置可选的批次进度通知回调。
*
* @param progressListener 每批数据完整写入后的通知回调
* @return 当前配置器
*/
public Builder<T, C, R> progressListener(ProgressListener progressListener) {
this.progressListener = progressListener;
return this;
}
/**
* 设置最终 XLSX 文件消费回调。
*
* @param fileConsumer 上传、移动或读取最终临时文件的回调
* @return 当前配置器
*/
public Builder<T, C, R> fileConsumer(FileConsumer<R> 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<T> 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);
}
}
}
}
@@ -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;
/**
* 食堂用户预约查询条件。
*
* <p>查询以用户全集为主数据源,再关联指定用餐日期范围内的有效预约订单,并按用户和日期逐日生成结果,
* 因此每天没有预约记录的用户也会返回。</p>
*/
@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<Long> 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<Long> userIds;
}
@@ -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;
}
@@ -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<SysPost> postList;
/** 用户角色列表,仅完整用户导出分页返回;没有角色时为空集合或空值。 */
@Schema(description = "用户角色列表")
private List<SysRole> roleList;
/** 用户编制协议编码,完整用户导出分页返回;未维护时为空。 */
@Schema(description = "用户编制协议编码")
private String staffingType;
/** 用户账号锁定标记:0 启用、9 禁用;完整用户导出分页返回。 */
@Schema(description = "用户账号锁定标记")
private String lockFlag;
/** 用户拥有的身份协议编码,来源于 UPMS 用户身份批量查询;没有身份时为空集合。 */
@Schema(description = "用户身份类型集合")
private List<String> 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<HardCanteenUserApptOrderVo> orders;
/**
* 获取页面展示姓名。
*
* <p>类中同时存在 {@code userName} 和 {@code username},两组访问器均需显式声明,避免 Lombok
* 因 JavaBean 方法名相近而跳过其中一组。</p>
*
* @return 页面展示姓名;用户资料均未维护时由服务层返回兜底文案
*/
public String getUserName() {
return userName;
}
/**
* 设置页面展示姓名。
*
* @param userName 服务层按姓名、昵称和登录账号顺序解析出的展示姓名
*/
public void setUserName(String userName) {
this.userName = userName;
}
/**
* 获取用户登录账号。
*
* <p>显式声明该访问器以保证接口序列化和业务赋值均使用 {@code username} 字段。</p>
*
* @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;
}
}
@@ -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;
}
}
@@ -118,6 +118,14 @@
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-idempotent</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-excel</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-supermarket-api</artifactId>
@@ -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<Page<HardCanteenProductTotalVo>> productReservTotal(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
@@ -103,4 +135,70 @@ public class HardCanteenApptTotalController {
return R.ok(canteenApptTotalService.productReservTotal(page, dto, null));
}
/**
* 分页查询用户在指定用餐日期范围内每天的预约情况。
*
* <p>接口以当前操作者数据权限内的全部用户为主数据源,每个用户在范围内每天生成一行,
* 因此没有预约日志的人员和日期也会返回;日期范围最多为两个自然月。</p>
*
* @param page 用户和日期维度的分页参数
* @param query 用户、日期范围、食堂、餐段和状态筛选条件
* @return 包含每天未预约用户的预约分页
*/
@Operation(summary = "用户预约查询", description = "按用户和日期逐日查询预约及核销情况,包含未预约用户,最多查询两个月")
@ApiCryptoAes
@PostMapping("/user-reserv-page")
@PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')")
public R<Page<HardCanteenUserApptVo>> 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<List<HardCanteenUserApptOrderVo>> 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<AsyncTaskProgressVO> 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<HardCanteenUserApptExcelVo> userReservationExport(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
return canteenApptTotalService.userReservationExport(query);
}
}
@@ -134,6 +134,17 @@ public interface HardOrderMapper extends PjcloudBaseMapper<HardOrderEntity> {
List<HardOrderVo> scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
/**
* 按订单筛选条件查询去重后的预约用户主键。
*
* <p>用于已订餐及核销状态查询先定位候选用户,再交由 UPMS 应用用户关键字、部门和数据权限,
* 避免导出完整资料时扫描与预约条件无关的全部用户。</p>
*
* @param dto 预约日期、食堂、餐段、订单类型和有效状态等筛选条件
* @return 当前租户内命中预约订单的去重用户主键,按主键升序排列
*/
List<Long> listReservationUserIds(@Param("dto") HardOrderDto dto);
Long count(HardOrderDto dto, DataScope dataScope);
List<HardOrderSbVo> listSb(@Param("dto") HardOrderDto dto, DataScope dataScope);
@@ -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<HardCanteenProductTotalVo> productReservTotal(Page page, HardOrderDto dto, DataScope dataScope);
/**
* 分页查询用户在指定日期范围内每天的预约情况,包含每天没有预约订单的权限范围用户。
*
* @param page 用户和日期维度的分页参数
* @param query 用户、日期范围和预约状态筛选条件
* @return 用户每日预约分页结果
*/
Page<HardCanteenUserApptVo> userReservationPage(Page page, HardCanteenUserApptQueryDto query);
/**
* 查询权限范围内指定用户某一天的全部有效食堂预约,不继承列表的食堂和餐段筛选。
*
* @param userId 列表行中的系统用户主键
* @param apptUseDate 需要查看的用餐日期
* @return 当天各餐段的有效预约订单;没有预约时返回空列表
*/
List<HardCanteenUserApptOrderVo> userReservationDetail(Long userId, LocalDate apptUseDate);
/**
* 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。
*
* @param query 用户、日期范围和预约状态筛选条件
* @return 供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
/**
* 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。
*
* @param query 用户、日期范围和预约状态筛选条件
* @return 用户和日期维度的 Excel 导出行
*/
List<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
}
@@ -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;
/**
* 硬件服务线程池配置。
* <p>硬件业务异步调用继续使用原有线程池,带安全上下文的任务使用独立固定线程池。</p>
*/
@Configuration
public class ExecutorConfig {
/**
* 创建硬件业务默认异步线程池。
* <p>公共异步任务模块会额外注册专用线程池,因此将原有硬件线程池标记为主候选,确保公共核心
* {@code AsyncConfiguration} 仍将全局 {@code @Async} 调用交给该线程池;公共异步任务自身通过 Bean 名称
* 显式选择专用线程池,不受主候选设置影响。</p>
*
* @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);
@@ -282,6 +282,17 @@
<include refid="pageList"/>
</select>
<!-- 已订餐类查询先只读取去重用户主键,完整用户资料仍由 UPMS 按用户数据权限过滤。 -->
<select id="listReservationUserIds" resultType="java.lang.Long">
select distinct a.user_id
from hard_order a
<where>
<include refid="whereA"/>
and a.user_id is not null
</where>
order by a.user_id
</select>
<select id="count" resultType="java.lang.Long">
select count(0)
from hard_order a
@@ -19,6 +19,7 @@
package com.bsd.pjcloud.admin.api.feign;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
import com.bsd.pjcloud.admin.api.dto.UserDeptSimpleInfo;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
@@ -43,6 +44,47 @@ import java.util.Set;
public interface RemoteUserService {
/**
* 按当前请求携带的登录令牌分页查询数据权限范围内的用户。
*
* @param current 当前页码,从 1 开始
* @param size 每页用户数量
* @param userDTO 用户、部门及包含关系筛选条件
* @return 保留用户总数的权限分页结果,记录已批量补齐身份类型、学生资料和老师资料;调用方必须检查业务状态和空数据
*/
@PostMapping("/user/page")
R<Page<UserVO>> getScopeUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size,
@RequestBody UserDTO userDTO);
/**
* 按当前登录人的数据权限分页查询用户基础信息和身份资料。
*
* <p>该接口不查询角色、岗位、人脸和审批流程,适用于只需要用户、部门及身份展示信息的业务列表。</p>
*
* @param current 当前页码,从 1 开始
* @param size 每页用户数量
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 含准确总数的轻量用户分页;调用方必须检查业务状态和空数据
*/
@PostMapping("/user/basic-page")
R<Page<UserVO>> getScopeBasicUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size,
@RequestBody UserDTO userDTO);
/**
* 按当前登录人的数据权限分页查询导出所需的完整用户主资料和身份资料。
*
* <p>接口包含角色、岗位、账号主资料和身份扩展,但跳过人脸与审批流程,适用于后台分批导出。</p>
*
* @param current 当前页码,从 1 开始
* @param size 每页用户数量
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 含准确总数的导出用户分页;调用方必须检查业务状态和空数据
*/
@PostMapping("/user/export-page")
R<Page<UserVO>> getScopeExportUserPage(@RequestParam("current") Long current, @RequestParam("size") Long size,
@RequestBody UserDTO userDTO);
@NoToken
@PostMapping("/user/inner-page")
R<List<UserVO>> getRemoteUserPage(@RequestParam Long current, @RequestParam Long size, @RequestBody UserDTO userDTO);
@@ -22,6 +22,7 @@ package com.bsd.pjcloud.admin.api.vo;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseFacesInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityInterface;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.bsd.pjcloud.admin.api.entity.SysPost;
import com.bsd.pjcloud.admin.api.entity.SysRole;
@@ -41,7 +42,7 @@ import java.util.List;
*/
@Data
@Schema(description = "前端用户展示对象")
public class UserVO implements BaseFacesInterface, Serializable {
public class UserVO implements BaseFacesInterface, BaseUserIdentityInterface, Serializable {
private static final long serialVersionUID = 1L;
@@ -1,45 +1,183 @@
package com.bsd.pjcloud.admin.api.vo.base;
/**
* 用户及其所属部门的通用数据契约。
*
* <p>历史业务模型使用 {@code userMobile}、{@code userAvatar}、{@code userDeptName} 等字段,
* 标准别名用于直接适配管理端 {@code UserInfoCell} 组件,同时通过默认方法保持已有实现兼容。</p>
*/
public interface BaseUserDeptInterface {
/**
* 用户id
* 获取用户主键。
*
* @return UPMS 用户主键;未关联用户时可为空
*/
Long getUserId();
/**
* 设置用户主键。
*
* @param userId UPMS 用户主键,未关联用户时可为空
*/
void setUserId(Long userId);
/**
* 用户名
* 获取历史用户名称字段。
*
* @return 业务模型中的用户名称;未补充用户资料时可为空
*/
String getUsername();
/**
* 设置历史用户名称字段。
*
* @param username 业务模型中的用户名称,未补充用户资料时可为空
*/
void setUsername(String username);
/**
* 用户手机号
* 获取历史用户手机号字段。
*
* @return 用户手机号;未绑定时为空
*/
String getUserMobile();
/**
* 设置历史用户手机号字段。
*
* @param userMobile 用户手机号,未绑定时可为空
*/
void setUserMobile(String userMobile);
/**
* 用户头像
* 获取历史用户头像字段。
*
* @return 用户头像地址;未维护时为空
*/
String getUserAvatar();
/**
* 设置历史用户头像字段。
*
* @param userAvatar 用户头像地址,未维护时可为空
*/
void setUserAvatar(String userAvatar);
/**
* 用户部门id
* 获取历史用户部门主键字段。
*
* @return 用户当前所属部门主键;未分配部门时为空
*/
Long getUserDeptId();
/**
* 设置历史用户部门主键字段。
*
* @param userDeptId 用户当前所属部门主键,未分配部门时可为空
*/
void setUserDeptId(Long userDeptId);
/**
* 用户部门
* 获取历史用户部门名称字段。
*
* @return 用户当前所属部门名称;未分配部门时为空
*/
String getUserDeptName();
/**
* 设置历史用户部门名称字段。
*
* @param userDeptName 用户当前所属部门名称,未分配部门时可为空
*/
void setUserDeptName(String userDeptName);
/**
* 获取标准用户姓名字段,默认沿用历史 {@code username} 字段承载的用户姓名。
*
* @return 用户姓名;未补充用户资料时可为空
*/
default String getName() {
return getUsername();
}
/**
* 设置标准用户姓名字段,默认写入历史 {@code username} 字段。
*
* @param name 用户姓名,未维护时可为空
*/
default void setName(String name) {
setUsername(name);
}
/**
* 获取标准手机号字段。
*
* @return 用户手机号;未绑定时为空
*/
default String getPhone() {
return getUserMobile();
}
/**
* 设置标准手机号字段。
*
* @param phone 用户手机号,未绑定时可为空
*/
default void setPhone(String phone) {
setUserMobile(phone);
}
/**
* 获取标准头像字段。
*
* @return 用户头像地址;未维护时为空
*/
default String getAvatar() {
return getUserAvatar();
}
/**
* 设置标准头像字段。
*
* @param avatar 用户头像地址,未维护时可为空
*/
default void setAvatar(String avatar) {
setUserAvatar(avatar);
}
/**
* 获取标准部门主键字段。
*
* @return 用户当前所属部门主键;未分配部门时为空
*/
default Long getDeptId() {
return getUserDeptId();
}
/**
* 设置标准部门主键字段。
*
* @param deptId 用户当前所属部门主键,未分配部门时可为空
*/
default void setDeptId(Long deptId) {
setUserDeptId(deptId);
}
/**
* 获取标准部门名称字段。
*
* @return 用户当前所属部门名称;未分配部门时为空
*/
default String getDeptName() {
return getUserDeptName();
}
/**
* 设置标准部门名称字段。
*
* @param deptName 用户当前所属部门名称,未分配部门时可为空
*/
default void setDeptName(String deptName) {
setUserDeptName(deptName);
}
}
@@ -0,0 +1,85 @@
package com.bsd.pjcloud.admin.api.vo.base;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import java.util.List;
/**
* 管理端用户身份信息组件使用的通用数据契约。
*
* <p>字段与 {@code UserIdentityInfo} 组件的输入保持一致,业务 VO 实现该接口后可直接承载
* UPMS 批量查询得到的身份类型、学生资料和老师资料。</p>
*/
public interface BaseUserIdentityInterface {
/**
* 获取用户主键,用于批量查询和回填身份资料。
*
* @return UPMS 用户主键;未关联系统用户时可为空
*/
Long getUserId();
/**
* 设置用户主键,用于建立业务记录与 UPMS 身份资料的关联。
*
* @param userId UPMS 用户主键,未关联系统用户时可为空
*/
void setUserId(Long userId);
/**
* 获取默认用户身份展示的手机号。
*
* @return 用户绑定手机号;未绑定时可为空
*/
String getPhone();
/**
* 设置默认用户身份展示的手机号。
*
* @param phone 用户绑定手机号,未绑定时可为空
*/
void setPhone(String phone);
/**
* 获取用户拥有的身份协议编码。
*
* @return 按 UPMS 身份记录顺序返回的编码集合;没有身份时为空集合或空值
*/
List<String> getIdentityTypes();
/**
* 设置用户拥有的身份协议编码。
*
* @param identityTypes UPMS 查询得到的身份编码集合;没有身份时使用空集合或空值
*/
void setIdentityTypes(List<String> identityTypes);
/**
* 获取学生身份扩展资料。
*
* @return 学号、学校、班级和监护人手机号;用户没有学生身份时为空
*/
SysUserStudentDto getStudentInfo();
/**
* 设置学生身份扩展资料。
*
* @param studentInfo UPMS 查询得到的学生资料;用户没有学生身份时可为空
*/
void setStudentInfo(SysUserStudentDto studentInfo);
/**
* 获取老师身份扩展资料。
*
* @return 老师学号资料;用户没有老师身份时为空
*/
SysUserTeacherDto getTeacherInfo();
/**
* 设置老师身份扩展资料。
*
* @param teacherInfo UPMS 查询得到的老师资料;用户没有老师身份时可为空
*/
void setTeacherInfo(SysUserTeacherDto teacherInfo);
}
@@ -24,6 +24,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
@@ -330,6 +331,36 @@ public class SysUserController {
return R.ok(userService.getUsersWithRolePage(page, userDTO, DataScope.of()));
}
/**
* 分页查询当前操作者数据权限内的用户基础信息和身份资料。
*
* <p>该接口供无需角色、岗位、人脸和审批信息的业务列表调用,避免复用管理端完整用户分页产生额外查询。</p>
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 包含用户总数、基础信息、部门和身份资料的轻量分页
*/
@ApiCryptoAes
@PostMapping("/basic-page")
public R<IPage<UserVO>> getBasicUserPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) {
return R.ok(userService.getBasicUsersPage(page, userDTO, DataScope.of()));
}
/**
* 分页查询当前操作者数据权限内的完整用户导出资料。
*
* <p>该接口供跨服务后台导出分批读取,返回账号主资料、角色岗位和身份扩展,跳过人脸及审批流程。</p>
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @return 包含用户总数和完整导出资料的分页
*/
@ApiCryptoAes
@PostMapping("/export-page")
public R<IPage<UserVO>> getExportUserPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) {
return R.ok(userService.getExportUsersPage(page, userDTO, DataScope.of()));
}
@ApiCryptoAes
@PostMapping("/select")
public R userPage(@ParameterObject Page page, @RequestBody UserDTO userDTO) {
@@ -61,6 +61,16 @@ public interface SysUserMapper extends PjcloudBaseMapper<SysUser> {
*/
IPage<UserVO> getUserVosPage(Page page, @Param("query") UserDTO userDTO, DataScope dataScope);
/**
* 分页读取用户基础信息和所属部门,不触发角色、岗位的嵌套查询。
*
* @param page 分页参数,返回结果包含符合条件的用户总数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围
* @return 按用户创建时间和主键倒序排列的轻量用户分页
*/
IPage<UserVO> getBasicUserPage(Page page, @Param("query") UserDTO userDTO, DataScope dataScope);
/**
* 通过ID查询用户信息
*
@@ -58,6 +58,26 @@ public interface SysUserService extends IService<SysUser> {
*/
IPage<UserVO> getUsersWithRolePage(Page page, UserDTO userDTO, DataScope dataScope);
/**
* 分页查询用户基础信息、部门和身份资料,不加载角色、岗位、人脸及审批流程。
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围
* @return 轻量用户分页结果
*/
IPage<UserVO> getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope);
/**
* 分页查询导出所需的用户主资料、角色岗位和身份资料,不加载人脸及审批流程。
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围
* @return 完整用户导出分页结果
*/
IPage<UserVO> getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope);
IPage<UserVO> innerPage(Page page, UserDTO userDTO);
/**
@@ -366,6 +366,53 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return list;
}
/**
* 分页查询用户基础信息并批量补齐身份资料。
*
* <p>预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL,
* 同时跳过人脸和审批远程查询。</p>
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
* @return 已批量补齐身份类型、学生资料和老师资料的轻量用户分页
*/
@Override
public IPage<UserVO> getBasicUsersPage(Page page, UserDTO userDTO, DataScope dataScope) {
if (ObjectUtil.isNotEmpty(dataScope)) {
// 用户查询只按所属部门做数据权限过滤,不能再用用户创建人限制可见范围。
dataScope.setScopeUserName(null);
}
IPage<UserVO> list = baseMapper.getBasicUserPage(page, userDTO, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
sysUserIdentityService.fillUserIdentities(list.getRecords());
}
return list;
}
/**
* 分页查询导出所需的完整用户主资料并批量补齐身份资料。
*
* <p>复用现有角色、岗位映射,保持与系统用户导出字段一致;不查询人脸和审批流程,避免后台分片导出执行无关远程调用。</p>
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
* @param dataScope 当前调用人的数据权限范围;为空时不附加数据范围限制
* @return 已补齐主资料、角色岗位和身份扩展的用户分页
*/
@Override
public IPage<UserVO> getExportUsersPage(Page page, UserDTO userDTO, DataScope dataScope) {
if (ObjectUtil.isNotEmpty(dataScope)) {
// 导出与列表使用相同的部门数据权限口径,不按创建人额外缩小用户范围。
dataScope.setScopeUserName(null);
}
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, dataScope);
if (ObjectUtil.isNotEmpty(list.getRecords())) {
sysUserIdentityService.fillUserIdentities(list.getRecords());
}
return list;
}
@Override
public IPage<UserVO> innerPage(Page page, UserDTO userDTO) {
IPage<UserVO> list = baseMapper.getUserVosPage(page, userDTO, null);
@@ -294,6 +294,52 @@
</if>
</select>
<!-- 业务轻量用户分页只返回用户信息组件和身份组件所需字段,避免 baseResultMap 逐人查询角色、岗位。 -->
<select id="getBasicUserPage" resultType="com.bsd.pjcloud.admin.api.vo.UserVO">
SELECT
u.user_id,
u.username,
u.phone,
u.avatar,
u.dept_id,
u.nickname,
u.name,
d.name dept_name
FROM sys_user u
LEFT JOIN sys_dept d ON d.dept_id = u.dept_id
<where>
u.del_flag = '0'
<if test="query.nameOrPhone != null and query.nameOrPhone != ''">
<bind name="basicNameOrPhone" value="'%'+query.nameOrPhone+'%'"/>
AND (u.phone LIKE #{basicNameOrPhone}
OR u.name LIKE #{basicNameOrPhone}
OR u.username LIKE #{basicNameOrPhone})
</if>
<if test="query.deptIds != null and query.deptIds.size() != 0">
<choose>
<when test="query.isInclude != null and query.isInclude == false">
AND u.dept_id NOT IN
</when>
<otherwise>
AND u.dept_id IN
</otherwise>
</choose>
<foreach collection="query.deptIds" item="deptId" open="(" close=")" separator=",">
#{deptId}
</foreach>
</if>
<if test="query.userIds != null and query.userIds.size() != 0">
AND u.user_id IN
<foreach collection="query.userIds" item="userId" open="(" close=")" separator=",">
#{userId}
</foreach>
</if>
</where>
<if test="_databaseId != 'mssql'">
ORDER BY u.create_time DESC, u.user_id DESC
</if>
</select>
<select id="selectVoListByScope" resultMap="baseResultMap">
SELECT
u.user_id,
@@ -116,6 +116,10 @@
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-async-task</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-excel</artifactId>
</dependency>
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-flow-task-api</artifactId>
@@ -9,6 +9,7 @@ import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.fill.FillConfig;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.feign.RemoteFileService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
@@ -17,6 +18,7 @@ import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.datascope.DataScopeFuncEnum;
import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.wallet.api.dto.WalletManageBalanceDto;
import com.bsd.pjcloud.wallet.api.dto.WalletManageDto;
import com.bsd.pjcloud.wallet.api.vo.WalletManageVo;
@@ -24,6 +26,13 @@ import com.bsd.pjcloud.wallet.mapper.WalletManageMapper;
import com.bsd.pjcloud.wallet.service.WalletManageExportService;
import com.bsd.pjcloud.wallet.service.WalletManageService;
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;
@@ -34,6 +43,7 @@ import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -64,8 +74,30 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
/** 查询真实数据量前创建任务使用的占位总数。 */
private static final int INITIAL_TOTAL = 1;
/** 账户余额导出的最大记录数,与原同步导出限制保持一致。 */
private static final int MAX_BALANCE_EXPORT_SIZE = 5000;
/** 账户余额导出单次查询的批大小,避免把全部导出行同时保留在 JVM 堆中。 */
private static final int BALANCE_EXPORT_BATCH_SIZE = 1000;
/** 账户余额工作表的数据起始行,按 Excel 的零基行号计算,模板第 3 行为首条明细。 */
private static final int BALANCE_DETAIL_START_ROW_INDEX = 2;
/** 账户余额明细之后预留的合计行数量。 */
private static final int BALANCE_RESERVED_TAIL_ROW_COUNT = 1;
/** 账户余额模板中原始明细占位行的零基行号。 */
private static final int BALANCE_DETAIL_TEMPLATE_ROW_INDEX = 2;
/** 账户余额模板中原始合计占位行的零基行号。 */
private static final int BALANCE_SUMMARY_TEMPLATE_ROW_INDEX = 3;
/** 账户余额模板固定列数,A-H 分别对应序号、用户、手机号、部门、钱包类型及三项金额。 */
private static final int BALANCE_COLUMN_COUNT = 8;
/** 单个 Excel 2007 工作表按当前模板布局最多可容纳的账户余额明细行数。 */
private static final int MAX_BALANCE_EXPORT_ROWS = BatchExcelExportTemplate.calculateMaxDetailRowCount(
BALANCE_DETAIL_START_ROW_INDEX, BALANCE_RESERVED_TAIL_ROW_COUNT);
/** 文件上传和异步任务成功收尾占用的额外进度步数。 */
private static final int BALANCE_EXPORT_EXTRA_PROGRESS_STEPS = 2;
/** 账户余额导出模板路径。 */
private static final String BALANCE_TEMPLATE_PATH = "file/账户余额.xlsx";
@@ -154,25 +186,29 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
if (count == null || count == 0) {
throw new IllegalStateException("没有符合当前条件的账户余额");
}
if (count > MAX_BALANCE_EXPORT_SIZE) {
throw new IllegalStateException("导出数据超出5000条,请缩小查询范围");
if (count > MAX_BALANCE_EXPORT_ROWS) {
throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 " + MAX_BALANCE_EXPORT_ROWS
+ " 条明细,请缩小查询范围后分批导出");
}
List<WalletManageVo> list = walletManageService.scopeList(dto, DataScope.of());
if (ObjectUtil.isEmpty(list)) {
throw new IllegalStateException("没有符合当前条件的账户余额");
}
if (list.size() > MAX_BALANCE_EXPORT_SIZE) {
throw new IllegalStateException("导出数据超出5000条,请缩小查询范围");
}
reporter.updateTotal(list.size(), "已查询 " + list.size() + " 条账户余额");
Map<String, Object> summary = prepareBalanceRows(dto, list, reporter);
int progressTotal = calculateBalanceProgressTotal(count);
reporter.updateTotal(progressTotal, "已查询 " + count + " 条账户余额,将分批生成文件");
String fileName = "电子钱包_" + LocalDateTime.now().format(EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
String fileUrl = writeAndUpload(BALANCE_TEMPLATE_PATH, summary, list, fileName, BALANCE_EXPORT_FILE_TYPE);
reporter.advance("账户余额导出文件已生成,共 " + list.size() + " 条");
String fileUrl = writeBalanceAndUpload(dto, count, reporter, fileName);
reporter.advance("账户余额导出文件已生成,共 " + count + " 条");
return AsyncTaskResult.file(fileUrl, fileName);
}
/**
* 根据数据总行数计算“数据批次数 + 文件上传”的进度总步数。
*
* @param rowTotal 按数据权限统计得到的导出总行数
* @return 进度组件使用的总步数
*/
private int calculateBalanceProgressTotal(long rowTotal) {
return BatchExcelExportTemplate.calculateProgressTotal(rowTotal, BALANCE_EXPORT_BATCH_SIZE,
BALANCE_EXPORT_EXTRA_PROGRESS_STEPS);
}
/**
* 查询、整理并上传钱包记录导出文件。
*
@@ -194,45 +230,197 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
}
/**
* 为账户余额行补充序号、金额展示值并累计汇总金额。
* 分批查询、填充并上传账户余额工作簿,明细行只在当前批次保留。
*
* @param dto 账户余额筛选条件,用于填充模板中的查询时间范围
* @param list 待导出的账户余额列表
* @param dto 后台线程独占的账户余额筛选条件
* @param total 按数据权限统计得到的导出总行数
* @param reporter 当前异步任务进度报告器
* @return Excel 模板头部需要填充的汇总字段
* @param fileName 下载文件名
* @return 文件中心下载地址
*/
private Map<String, Object> prepareBalanceRows(WalletManageDto dto, List<WalletManageVo> list,
AsyncTaskProgressReporter reporter) {
Map<String, Object> summary = new HashMap<>();
if (ObjectUtil.isNotEmpty(dto.getCreateTimeList())) {
summary.put("timeSpan", " 到 ");
summary.put("startTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN));
summary.put("endTime", LocalDateTimeUtil.format(dto.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN));
private String writeBalanceAndUpload(WalletManageDto dto, long total, AsyncTaskProgressReporter reporter,
String fileName) {
try {
BalanceExportSummary summary = new BalanceExportSummary();
return BatchExcelExportTemplate.<WalletManageVo, BalanceTemplateStyle, String>builder(total)
.batchSize(BALANCE_EXPORT_BATCH_SIZE)
.dataStartRowIndex(BALANCE_DETAIL_START_ROW_INDEX)
.reservedTailRowCount(BALANCE_RESERVED_TAIL_ROW_COUNT)
.temporaryFilePrefix("wallet-balance-export-")
.templateSupplier(() -> ResourceUtil.getStream(BALANCE_TEMPLATE_PATH))
.templateInitializer(workbook -> prepareBalanceTemplate(workbook, dto))
.pageFetcher((pageNumber, batchSize) -> {
Page<WalletManageVo> page = new Page<>(pageNumber, batchSize, false);
return walletManageService.scopePageList(page, dto, DataScope.of()).getRecords();
})
.rowWriter((sheet, rowIndex, dataIndex, item, templateStyle) -> {
prepareBalanceRow(item, dataIndex, summary);
writeBalanceRow(sheet, rowIndex, item, templateStyle);
})
.footerWriter((sheet, rowIndex, processedTotal, templateStyle) ->
writeBalanceSummaryRow(sheet, rowIndex, summary, templateStyle))
.progressListener((processedTotal, expectedTotal) -> reporter.advance(
"已整理 " + processedTotal + " / " + expectedTotal + " 条账户余额"))
.fileConsumer(path -> uploadWorkbook(Files.readAllBytes(path), fileName, BALANCE_EXPORT_FILE_TYPE))
.execute();
} catch (Exception exception) {
log.error("|钱包管理导出|生成或保存账户余额文件失败,fileName:{}", fileName, exception);
throw new IllegalStateException("生成钱包导出文件失败", exception);
}
long balanceSum = 0;
long totalExpenseSum = 0;
long totalRechargeSum = 0;
for (int index = 0; index < list.size(); index++) {
WalletManageVo item = list.get(index);
item.setLineNumber((long) index + 1);
if (item.getBalance() != null) {
balanceSum += item.getBalance();
item.setBalanceStr(formatPrice(item.getBalance()));
}
if (item.getTotalExpense() != null) {
totalExpenseSum += item.getTotalExpense();
item.setTotalExpenseStr(formatPrice(item.getTotalExpense()));
}
if (item.getTotalRecharge() != null) {
totalRechargeSum += item.getTotalRecharge();
item.setTotalRechargeStr(formatPrice(item.getTotalRecharge()));
}
advanceExceptLast(reporter, index, list.size(), "正在整理第 " + (index + 1) + " / " + list.size() + " 条账户余额");
}
/**
* 读取余额模板的样式并移除占位明细、合计行,为 SXSSF 追加行腾出连续的写入区间。
*
* @param workbook 由模板创建的可编辑工作簿
* @param query 账户余额查询条件,用于替换标题中的时间范围占位符
* @return 后续流式写入需要复用的行样式和行高
*/
private BalanceTemplateStyle prepareBalanceTemplate(XSSFWorkbook workbook, WalletManageDto query) {
Sheet sheet = workbook.getSheetAt(0);
Row detailTemplate = sheet.getRow(BALANCE_DETAIL_TEMPLATE_ROW_INDEX);
Row summaryTemplate = sheet.getRow(BALANCE_SUMMARY_TEMPLATE_ROW_INDEX);
if (detailTemplate == null || summaryTemplate == null) {
throw new IllegalStateException("账户余额导出模板缺少明细或合计占位行");
}
CellStyle[] detailStyles = readRowStyles(detailTemplate);
CellStyle[] summaryStyles = readRowStyles(summaryTemplate);
short detailRowHeight = detailTemplate.getHeight();
short summaryRowHeight = summaryTemplate.getHeight();
Row titleRow = sheet.getRow(0);
if (titleRow != null && titleRow.getCell(0) != null) {
titleRow.getCell(0).setCellValue(buildBalanceTitle(query));
}
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
CellRangeAddress mergedRegion = sheet.getMergedRegion(index);
if (mergedRegion.getFirstRow() >= BALANCE_DETAIL_TEMPLATE_ROW_INDEX) {
sheet.removeMergedRegion(index);
}
}
sheet.removeRow(summaryTemplate);
sheet.removeRow(detailTemplate);
return new BalanceTemplateStyle(detailStyles, summaryStyles, detailRowHeight, summaryRowHeight);
}
/**
* 读取一行的单元格样式,保证流式新增行继续使用原模板的边框、字体和金额格式。
*
* @param row 模板行
* @return 按列排列的样式数组
*/
private CellStyle[] readRowStyles(Row row) {
CellStyle[] styles = new CellStyle[BALANCE_COLUMN_COUNT];
for (int column = 0; column < BALANCE_COLUMN_COUNT; column++) {
Cell cell = row.getCell(column);
if (cell != null) {
styles[column] = cell.getCellStyle();
}
}
return styles;
}
/**
* 写入一条账户余额明细,行对象由 SXSSF 管理,超过窗口后会自动落盘。
*
* @param sheet 账户余额工作表
* @param rowIndex 明细行零基行号,已由公共导出模板校验 Excel 行数上限
* @param item 当前账户余额数据
* @param templateStyle 模板样式快照
*/
private void writeBalanceRow(SXSSFSheet sheet, int rowIndex, WalletManageVo item,
BalanceTemplateStyle templateStyle) {
Row row = sheet.createRow(rowIndex);
row.setHeight(templateStyle.detailRowHeight);
setCell(row, 0, templateStyle.detailStyles[0], item.getLineNumber());
setCell(row, 1, templateStyle.detailStyles[1], item.getUsername());
setCell(row, 2, templateStyle.detailStyles[2], item.getUserMobile());
setCell(row, 3, templateStyle.detailStyles[3], item.getDeptName());
setCell(row, 4, templateStyle.detailStyles[4], item.getWalletTypeName());
setCell(row, 5, templateStyle.detailStyles[5], item.getBalanceStr());
setCell(row, 6, templateStyle.detailStyles[6], item.getTotalExpenseStr());
setCell(row, 7, templateStyle.detailStyles[7], item.getTotalRechargeStr());
}
/**
* 写入账户余额合计行,并恢复模板原有的 A-E 合并效果。
*
* @param sheet 账户余额工作表
* @param rowIndex 合计行零基行号,已由公共导出模板预留
* @param summary 跨批次金额汇总
* @param templateStyle 模板样式快照
*/
private void writeBalanceSummaryRow(SXSSFSheet sheet, int rowIndex, BalanceExportSummary summary,
BalanceTemplateStyle templateStyle) {
Row row = sheet.createRow(rowIndex);
row.setHeight(templateStyle.summaryRowHeight);
setCell(row, 0, templateStyle.summaryStyles[0], "合计");
setCell(row, 1, templateStyle.summaryStyles[1], null);
setCell(row, 2, templateStyle.summaryStyles[2], null);
setCell(row, 3, templateStyle.summaryStyles[3], null);
setCell(row, 4, templateStyle.summaryStyles[4], null);
setCell(row, 5, templateStyle.summaryStyles[5], summary.formatPrice(summary.balanceSum));
setCell(row, 6, templateStyle.summaryStyles[6], summary.formatPrice(summary.totalExpenseSum));
setCell(row, 7, templateStyle.summaryStyles[7], summary.formatPrice(summary.totalRechargeSum));
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, 4));
}
/**
* 为单元格设置模板样式和展示值,空值保持为空单元格以延续原模板边框。
*
* @param row 目标行
* @param column 目标列
* @param style 模板样式,可为空
* @param value 展示值,数字和文本均按其字符串语义写入
*/
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());
}
}
/**
* 生成账户余额模板标题,保留同步导出原有的时间范围展示语义。
*
* @param query 账户余额查询条件
* @return 模板标题文本
*/
private String buildBalanceTitle(WalletManageDto query) {
if (query != null && query.getCreateTimeList() != null && query.getCreateTimeList().length == 2
&& query.getCreateTimeList()[0] != null && query.getCreateTimeList()[1] != null) {
return LocalDateTimeUtil.format(query.getCreateTimeList()[0], DatePattern.NORM_DATETIME_PATTERN)
+ " 到 "
+ LocalDateTimeUtil.format(query.getCreateTimeList()[1], DatePattern.NORM_DATETIME_PATTERN)
+ "账户余额";
}
return "账户余额";
}
/**
* 为一条账户余额补充序号、金额展示值并累计跨批次汇总金额。
*
* @param item 当前待导出的账户余额
* @param dataIndex 当前记录在全部明细中的零基序号
* @param summary 跨批次共享的金额汇总
*/
private void prepareBalanceRow(WalletManageVo item, long dataIndex, BalanceExportSummary summary) {
item.setLineNumber(dataIndex + 1);
summary.add(item);
if (item.getBalance() != null) {
item.setBalanceStr(formatPrice(item.getBalance()));
}
if (item.getTotalExpense() != null) {
item.setTotalExpenseStr(formatPrice(item.getTotalExpense()));
}
if (item.getTotalRecharge() != null) {
item.setTotalRechargeStr(formatPrice(item.getTotalRecharge()));
}
summary.put("balanceSum", formatPrice(balanceSum));
summary.put("totalExpenseSum", formatPrice(totalExpenseSum));
summary.put("totalRechargeSum", formatPrice(totalRechargeSum));
return summary;
}
/**
@@ -269,7 +457,7 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
* @param total 总行数
* @param message 当前处理说明
*/
private void advanceExceptLast(AsyncTaskProgressReporter reporter, int index, int total, String message) {
private void advanceExceptLast(AsyncTaskProgressReporter reporter, long index, long total, String message) {
if (index < total - 1) {
reporter.advance(message);
}
@@ -285,6 +473,28 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
return BigDecimal.valueOf(amount).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString();
}
/**
* 将生成完成的工作簿上传到文件中心。
*
* @param workbookBytes 工作簿完整字节内容
* @param fileName 下载文件名
* @param fileType 文件中心分类编码
* @return 文件中心下载地址
*/
private String uploadWorkbook(byte[] workbookBytes, String fileName, String fileType) {
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
EXCEL_CONTENT_TYPE, workbookBytes);
R uploadResult = remoteFileService.innerUpload(exportFile, EXPORT_FILE_DIR, null, fileType);
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();
}
/**
* 使用既有模板填充 Excel,并上传到文件中心。
*
@@ -297,30 +507,103 @@ public class WalletManageExportServiceImpl implements WalletManageExportService
*/
private String writeAndUpload(String templatePath, Map<String, Object> summary, List<WalletManageVo> rows,
String fileName, String fileType) {
try (InputStream template = ResourceUtil.getStream(templatePath); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
try (InputStream template = ResourceUtil.getStream(templatePath);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
try (ExcelWriter excelWriter = EasyExcel.write(output).withTemplate(template).build()) {
WriteSheet writeSheet = EasyExcel.writerSheet().build();
FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
excelWriter.fill(summary, writeSheet);
excelWriter.fill(rows, fillConfig, writeSheet);
}
MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
EXCEL_CONTENT_TYPE, output.toByteArray());
R uploadResult = remoteFileService.innerUpload(exportFile, EXPORT_FILE_DIR, null, fileType);
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();
return uploadWorkbook(output.toByteArray(), fileName, fileType);
} catch (Exception exception) {
log.error("|钱包管理导出|生成或保存文件失败,fileName:{}", fileName, exception);
throw new IllegalStateException("生成钱包导出文件失败", exception);
}
}
/**
* 账户余额导出跨批次金额汇总器。
*/
private static final class BalanceExportSummary {
/** 账户余额合计,单位分。 */
private BigDecimal balanceSum = BigDecimal.ZERO;
/** 累计支出合计,单位分。 */
private BigDecimal totalExpenseSum = BigDecimal.ZERO;
/** 累计充值合计,单位分。 */
private BigDecimal totalRechargeSum = BigDecimal.ZERO;
/**
* 创建跨批次账户余额汇总器。
*/
private BalanceExportSummary() {
}
/**
* 累计当前钱包行的金额字段。
*
* @param item 当前账户余额行
*/
private void add(WalletManageVo item) {
if (item.getBalance() != null) {
balanceSum = balanceSum.add(BigDecimal.valueOf(item.getBalance()));
}
if (item.getTotalExpense() != null) {
totalExpenseSum = totalExpenseSum.add(BigDecimal.valueOf(item.getTotalExpense()));
}
if (item.getTotalRecharge() != null) {
totalRechargeSum = totalRechargeSum.add(BigDecimal.valueOf(item.getTotalRecharge()));
}
}
/**
* 将分单位金额转换为模板展示使用的元字符串。
*
* @param amount 金额,单位分
* @return 两位小数的元金额字符串
*/
private String formatPrice(BigDecimal amount) {
return amount.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP).toPlainString();
}
}
/**
* 账户余额模板样式快照,在移除模板占位行后供 SXSSF 新增行复用。
*/
private static final class BalanceTemplateStyle {
/** 明细行 A-H 列的模板样式。 */
private final CellStyle[] detailStyles;
/** 合计行 A-H 列的模板样式。 */
private final CellStyle[] summaryStyles;
/** 明细行高度,单位为 POI twip。 */
private final short detailRowHeight;
/** 合计行高度,单位为 POI twip。 */
private final short summaryRowHeight;
/**
* 创建账户余额模板样式快照。
*
* @param detailStyles 明细行 A-H 列样式
* @param summaryStyles 合计行 A-H 列样式
* @param detailRowHeight 明细行高度
* @param summaryRowHeight 合计行高度
*/
private BalanceTemplateStyle(CellStyle[] detailStyles, CellStyle[] summaryStyles, short detailRowHeight,
short summaryRowHeight) {
this.detailStyles = detailStyles;
this.summaryStyles = summaryStyles;
this.detailRowHeight = detailRowHeight;
this.summaryRowHeight = summaryRowHeight;
}
}
/**
* 复制账户余额筛选条件,避免异步线程读取请求对象中的可变集合。
*