From e5b25d0647a4273883ecc02ebee632b66cbbdd90 Mon Sep 17 00:00:00 2001
From: dzc <2388224167@qq.com>
Date: Sun, 23 Aug 2026 14:39:31 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E5=AF=BC=E5=87=BAexcel=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...dCanteenProductApptExportMergeHandler.java | 199 ++++++++++++------
...teenProductApptExportMergeHandlerTest.java | 123 +++++++++++
2 files changed, 254 insertions(+), 68 deletions(-)
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
index ad62bf197..70c294320 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandler.java
@@ -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;
/**
* 食堂预约菜品统计导出合并处理器。
*
- *
数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
- * 处理器在工作簿关闭前合并父层级字段,避免分批写入时依赖 Easy Excel 的行缓存。
+ * 数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列。
+ * Easy Excel 使用流式工作簿,较早的数据行会在关闭工作簿前刷出内存,因此必须在逐行写出时记录分组边界并及时合并。
*/
-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 centeredStyles = new HashMap<>();
+
+ /** 当前日期餐段分组的起始行,使用 POI 从零开始的行号。 */
+ private int summaryGroupStart = -1;
+
+ /** 当前商户部门分组的起始行,使用 POI 从零开始的行号。 */
+ private int merchantGroupStart = -1;
+
+ /** 最近写出的数据行号,用于工作簿结束时收尾最后一组。 */
+ private int lastDataRow = -1;
+
+ /** 当前导出工作表;本处理器只注册到菜品预约导出的单个工作表。 */
+ private Sheet currentSheet;
+
+ /**
+ * 数据行完成写入后立即处理样式和分组边界,避免该行被流式工作簿刷出内存后无法再次读取。
*
* Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性,
* 不能直接替换成全新的样式,否则金额会失去两位小数格式。
*
+ * @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 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();
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
new file mode 100644
index 000000000..148ba09be
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenProductApptExportMergeHandlerTest.java
@@ -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 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 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 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);
+ }
+ }
+}