feat:导出excel优化
This commit is contained in:
1 parent
f917e231af
commit
e5b25d0647
2 files changed
+254
-68
No files matched your search
+131
-68
@@ -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();
|
||||
}
|
||||
|
||||
+123
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user