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); + } + } +}