feat:导出excel优化

This commit is contained in:
dzc committed 2026-08-23 14:39:31 +08:00
1 parent f917e231af
commit e5b25d0647
2 files changed
+254 -68

No files matched your search

@@ -1,9 +1,13 @@
package com.bsd.pjcloud.hard.excel;
import com.alibaba.excel.write.handler.RowWriteHandler;
import com.alibaba.excel.write.handler.WorkbookWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteTableHolder;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
@@ -16,10 +20,10 @@ import java.util.Map;
/**
* 食堂预约菜品统计导出合并处理器。
*
* <p>数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
* 处理器在工作簿关闭前合并父层级字段,避免分批写入时依赖 Easy Excel 的行缓存。</p>
* <p>数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列。
* Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。</p>
*/
public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHandler {
public class HardCanteenProductApptExportMergeHandler implements RowWriteHandler, WorkbookWriteHandler {
/** 日期餐段汇总列。 */
private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5};
@@ -30,87 +34,144 @@ public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHa
/** Easy Excel 双层表头占用两行,数据从第三行开始。 */
private static final int DATA_START_ROW = 2;
/** 导出列总数,用于逐行应用居中样式。 */
private static final int COLUMN_COUNT = 17;
/** 表头字体大小,单位为磅;使用紧凑字号降低双层表头占用空间。 */
private static final short HEAD_FONT_SIZE = 10;
/**
* 工作簿关闭前统一处理数据行样式、层级合并和列宽。
* 各列宽度,单位为 Excel 字符宽度;常规统计列保持紧凑,商品名称列单独加宽以展示更多名称内容。
*/
private static final int[] COLUMN_WIDTHS = {12, 11, 10, 10, 14, 11, 14, 14, 12, 11, 10, 10, 14, 11, 32, 9, 11};
/** 原样式索引到居中样式的缓存,避免每个数据单元格重复创建样式。 */
private final Map<Short, CellStyle> centeredStyles = new HashMap<>();
/** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */
private int summaryGroupStart = -1;
/** 当前商户部门分组的起始行,使用 POI 从零开始的行号。 */
private int merchantGroupStart = -1;
/** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
private int lastDataRow = -1;
/** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
private Sheet currentSheet;
/**
* 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。
*
* <p>Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性,
* 不能直接替换成全新的样式,否则金额会失去两位小数格式。</p>
*
* @param writeSheetHolder 当前工作表持有者
* @param writeTableHolder 当前表格持有者,本导出未使用独立表格
* @param row 刚完成写入且尚在内存中的行
* @param relativeRowIndex 当前批次中的相对行号
* @param isHead 是否为表头行
*/
@Override
public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row,
Integer relativeRowIndex, Boolean isHead) {
if (row == null) {
return;
}
if (Boolean.TRUE.equals(isHead)) {
applyCompactHeadFont(row, writeSheetHolder.getParentWriteWorkbookHolder());
return;
}
if (row.getRowNum() < DATA_START_ROW) {
return;
}
currentSheet = writeSheetHolder.getSheet();
applyCenteredStyle(row, writeSheetHolder.getParentWriteWorkbookHolder());
int rowIndex = row.getRowNum();
boolean summaryRow = hasValue(row, 0);
boolean merchantRow = hasValue(row, 6);
if (summaryRow) {
mergeColumns(currentSheet, summaryGroupStart, rowIndex - 1, SUMMARY_COLUMNS);
summaryGroupStart = rowIndex;
mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS);
merchantGroupStart = -1;
}
if (merchantRow) {
mergeColumns(currentSheet, merchantGroupStart, rowIndex - 1, MERCHANT_COLUMNS);
merchantGroupStart = rowIndex;
}
lastDataRow = rowIndex;
}
/**
* 工作簿关闭前收尾最后一个日期餐段和商户部门分组,并统一设置列宽。
*
* @param holder Easy Excel 当前工作簿持有者
*/
@Override
public void afterWorkbookDispose(WriteWorkbookHolder holder) {
Sheet sheet = holder.getWorkbook().getNumberOfSheets() == 0 ? null : holder.getWorkbook().getSheetAt(0);
if (sheet == null || sheet.getLastRowNum() < DATA_START_ROW) {
if (currentSheet == null || lastDataRow < DATA_START_ROW) {
return;
}
Map<Short, CellStyle> centeredStyles = new HashMap<>();
int lastRow = sheet.getLastRowNum();
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
mergeColumns(currentSheet, summaryGroupStart, lastDataRow, SUMMARY_COLUMNS);
mergeColumns(currentSheet, merchantGroupStart, lastDataRow, MERCHANT_COLUMNS);
for (int column = 0; column < COLUMN_WIDTHS.length; column++) {
currentSheet.setColumnWidth(column, COLUMN_WIDTHS[column] * 256);
}
}
/**
* 在当前行仍处于流式窗口时复制原数字格式并应用水平、垂直居中。
*
* @param row 当前数据行
* @param holder 当前工作簿持有者,用于创建可复用样式
*/
private void applyCenteredStyle(Row row, WriteWorkbookHolder holder) {
for (int column = 0; column < COLUMN_COUNT; column++) {
Cell cell = row.getCell(column);
if (cell == null) {
continue;
}
for (int column = 0; column < 17; column++) {
Cell cell = row.getCell(column);
if (cell != null) {
CellStyle originalStyle = cell.getCellStyle();
short styleIndex = originalStyle.getIndex();
CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> {
CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
copiedStyle.cloneStyleFrom(originalStyle);
copiedStyle.setAlignment(HorizontalAlignment.CENTER);
copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER);
return copiedStyle;
});
cell.setCellStyle(centeredStyle);
}
}
}
mergeSummaryGroups(sheet, lastRow);
mergeMerchantGroups(sheet, lastRow);
int[] widths = {13, 12, 11, 11, 13, 14, 18, 18, 13, 12, 11, 11, 13, 14, 24, 11, 14};
for (int column = 0; column < widths.length; column++) {
sheet.setColumnWidth(column, widths[column] * 256);
CellStyle originalStyle = cell.getCellStyle();
short styleIndex = originalStyle.getIndex();
CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> {
CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
copiedStyle.cloneStyleFrom(originalStyle);
copiedStyle.setAlignment(HorizontalAlignment.CENTER);
copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER);
return copiedStyle;
});
cell.setCellStyle(centeredStyle);
}
}
/** 合并每个日期餐段组的汇总列。 */
private void mergeSummaryGroups(Sheet sheet, int lastRow) {
int groupStart = -1;
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) {
boolean boundary = rowIndex > lastRow || hasValue(sheet, rowIndex, 0);
if (!boundary) {
/**
* 缩小当前表头行使用的字体;只调整字号并保留 Easy Excel 生成的字体名称、颜色和加粗属性。
*
* @param row 当前表头行
* @param holder 当前工作簿持有者,用于定位表头样式引用的字体
*/
private void applyCompactHeadFont(Row row, WriteWorkbookHolder holder) {
for (int column = 0; column < COLUMN_COUNT; column++) {
Cell cell = row.getCell(column);
if (cell == null) {
continue;
}
if (groupStart >= 0) {
mergeColumns(sheet, groupStart, rowIndex - 1, SUMMARY_COLUMNS);
}
groupStart = rowIndex > lastRow ? -1 : rowIndex;
Font font = holder.getWorkbook().getFontAt(cell.getCellStyle().getFontIndex());
font.setFontHeightInPoints(HEAD_FONT_SIZE);
}
}
/** 合并每个商户部门组的统计列,商品明细行共享商户统计。 */
private void mergeMerchantGroups(Sheet sheet, int lastRow) {
int groupStart = -1;
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) {
boolean summaryRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 0);
boolean merchantRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 6);
// 日期汇总行也是商户分组的边界,不能让上一日期最后一个商户跨过汇总行继续合并。
boolean boundary = rowIndex > lastRow || summaryRow || (merchantRow && groupStart >= 0);
if (boundary && groupStart >= 0) {
mergeColumns(sheet, groupStart, rowIndex - 1, MERCHANT_COLUMNS);
groupStart = -1;
}
if (merchantRow) {
groupStart = rowIndex;
}
}
}
/** 按列范围添加合并单元格。 */
/**
* 按列范围添加合并单元格;单行或尚未开始的分组无需创建合并区域。
*
* @param sheet 当前导出工作表
* @param firstRow 分组起始行,使用 POI 从零开始的行号
* @param lastRow 分组结束行,使用 POI 从零开始的行号
* @param columns 需要纵向合并的列索引
*/
private void mergeColumns(Sheet sheet, int firstRow, int lastRow, int[] columns) {
if (firstRow >= lastRow) {
if (sheet == null || firstRow < DATA_START_ROW || firstRow >= lastRow) {
return;
}
for (int column : columns) {
@@ -118,12 +179,14 @@ public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHa
}
}
/** 判断指定单元格是否包含非空文本。 */
private boolean hasValue(Sheet sheet, int rowIndex, int columnIndex) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
return false;
}
/**
* 判断当前尚在内存中的行是否在指定列包含非空值。
*
* @param row 当前数据行
* @param columnIndex 用于识别行层级的列索引
* @return 指定单元格存在且包含非空文本时返回 {@code true}
*/
private boolean hasValue(Row row, int columnIndex) {
Cell cell = row.getCell(columnIndex);
return cell != null && !cell.toString().trim().isEmpty();
}
@@ -0,0 +1,123 @@
package com.bsd.pjcloud.hard.excel;
import com.alibaba.excel.EasyExcel;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 菜品预约导出合并处理器测试,覆盖 Easy Excel 流式窗口刷盘后的早期行合并。
*/
class HardCanteenProductApptExportMergeHandlerTest {
/**
* 构造超过默认流式窗口的两组数据,验证前后日期餐段及商户部门区域都能完成合并。
*
* @throws Exception 临时文件或工作簿读写失败时抛出
*/
@Test
void shouldMergeAllGroupsAfterEarlierRowsAreFlushed() throws Exception {
Path output = Files.createTempFile("canteen-product-appt-merge-test-", ".xlsx");
try {
List<HardCanteenProductApptExcelVo> rows = new ArrayList<>();
rows.add(summaryRow(LocalDate.of(2026, 8, 23), "早餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 23), "早餐"));
addProductRows(rows, 130);
rows.add(summaryRow(LocalDate.of(2026, 8, 24), "午餐"));
rows.add(merchantRow(LocalDate.of(2026, 8, 24), "午餐"));
addProductRows(rows, 5);
EasyExcel.write(output.toFile(), HardCanteenProductApptExcelVo.class)
.registerWriteHandler(new HardCanteenProductApptExportMergeHandler())
.sheet("菜品预约统计").doWrite(rows);
try (InputStream input = Files.newInputStream(output); Workbook workbook = new XSSFWorkbook(input)) {
Set<String> mergedRegions = workbook.getSheetAt(0).getMergedRegions().stream()
.map(region -> region.formatAsString()).collect(Collectors.toSet());
assertTrue(mergedRegions.contains("A3:A134"));
assertTrue(mergedRegions.contains("F3:F134"));
assertTrue(mergedRegions.contains("G4:G134"));
assertTrue(mergedRegions.contains("N4:N134"));
assertTrue(mergedRegions.contains("A135:A141"));
assertTrue(mergedRegions.contains("G136:G141"));
assertEquals(10, workbook.getFontAt(workbook.getSheetAt(0).getRow(0).getCell(0)
.getCellStyle().getFontIndex()).getFontHeightInPoints());
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(4));
assertEquals(14 * 256, workbook.getSheetAt(0).getColumnWidth(12));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(14));
assertEquals(9 * 256, workbook.getSheetAt(0).getColumnWidth(15));
}
} finally {
Files.deleteIfExists(output);
}
}
/**
* 构造日期餐段汇总行。
*
* @param date 预约日期
* @param hourType 餐段名称
* @return 仅填写汇总列的导出行
*/
private HardCanteenProductApptExcelVo summaryRow(LocalDate date, String hourType) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setSummaryDate(date);
row.setSummaryHourType(hourType);
row.setSummaryPayUserCount(1L);
row.setSummaryTimeCount(1L);
row.setSummaryPayCount(1L);
row.setSummaryTotalPrice(BigDecimal.ONE);
return row;
}
/**
* 构造商户部门统计行。
*
* @param date 预约日期
* @param hourType 餐段名称
* @return 仅填写商户部门列的导出行
*/
private HardCanteenProductApptExcelVo merchantRow(LocalDate date, String hourType) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setMerchantName("测试食堂");
row.setDepartmentName("测试部门");
row.setMerchantDate(date);
row.setMerchantHourType(hourType);
row.setMerchantPayUserCount(1L);
row.setMerchantTimeCount(1L);
row.setMerchantPayCount(1L);
row.setMerchantTotalPrice(BigDecimal.ONE);
return row;
}
/**
* 追加指定数量的商品明细行,用于触发流式工作簿刷出早期数据行。
*
* @param rows 待追加的导出行集合
* @param count 商品明细行数量
*/
private void addProductRows(List<HardCanteenProductApptExcelVo> rows, int count) {
for (int index = 0; index < count; index++) {
HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
row.setProductName("测试商品" + index);
row.setProductCount(1L);
row.setProductTotalPrice(BigDecimal.ONE);
rows.add(row);
}
}
}