package com.trafficaudit.reportexport.layout; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; 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.usermodel.XSSFWorkbook; import org.springframework.stereotype.Component; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.GregorianCalendar; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 汇总大表「按月扩列器」(Step 1,2026-10-09 首次实现)。 * *

把 N 月母版推进到 N+1 月版式(不预留空列):在「累计列」前插入 2 列(当月值列 + 同比列), * 克隆两行纵向合并标题、重写累计枚举与累计同比分母、右移 2025/2024/2023 历史块及其公式引用, * 并同步平移「其它页指向已扩列页、列号 >= 插入列」的跨页引用。 * *

规则依据:{@code docs/汇总大表母版扩列配置_核对版_2026-10-09.md}(8 个可见台账页; * 4 个隐藏页保留隐藏、不扩列,但其跨页引用需同步平移)。只做版式,当月数据列留空。 */ @Slf4j @Component public class SummaryLayoutExpander { private static final List EXPAND_PAGES = Arrays.asList( " 货运", "班线包车", "城市客运", "公交", "出租", "网约车", "轨道、轮渡", "中口径明细"); private static final int HEADER_SCAN_ROWS = 15; /** 公式里的 A1 引用(可带工作表前缀)。 */ private static final Pattern REF = Pattern.compile( "(?:('([^']+)'|([A-Za-z_\\u4e00-\\u9fff][A-Za-z0-9_.\\u4e00-\\u9fff\u3001]*))!)?(\\$?)([A-Z]{1,3})(\\$?)(\\d+)"); private static final Pattern MONTH_TEXT = Pattern.compile("^(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月$"); public byte[] expand(byte[] source, int year, int targetMonth) { if (targetMonth < 2 || targetMonth > 12) { throw new IllegalArgumentException("扩列:目标月份需在 2~12 之间"); } try (XSSFWorkbook wb = new XSSFWorkbook(new ByteArrayInputStream(source))) { Set expanded = new HashSet<>(EXPAND_PAGES); Map insertColOf = new LinkedHashMap<>(); for (String name : EXPAND_PAGES) { Sheet sh = wb.getSheet(name); if (sh == null) { log.warn("扩列:找不到页签 {}", name); continue; } insertColOf.put(name, expandSheet(sh, year, targetMonth)); } for (int i = 0; i < wb.getNumberOfSheets(); i++) { Sheet sh = wb.getSheetAt(i); if (sh == null || expanded.contains(sh.getSheetName())) continue; shiftQualifiedRefs(sh, insertColOf); } wb.setForceFormulaRecalculation(true); ByteArrayOutputStream out = new ByteArrayOutputStream(); wb.write(out); return out.toByteArray(); } catch (Exception e) { throw new IllegalStateException("汇总大表扩列失败: " + e.getMessage(), e); } } /** 对单页扩列,返回插入位置(0 基列号)。 */ private int expandSheet(Sheet sh, int year, int targetMonth) { int lastMonth = targetMonth - 1; Map cur = monthCols(sh, year); Integer lastVal = cur.get(lastMonth); if (lastVal == null) { throw new IllegalStateException("扩列:" + sh.getSheetName() + " 页未按表头识别到 " + year + "年" + lastMonth + "月列"); } Map last = monthCols(sh, year - 1); Integer lastYearSameMonth = last.get(targetMonth); if (lastYearSameMonth == null) { throw new IllegalStateException("扩列:" + sh.getSheetName() + " 页未识别到 " + (year - 1) + "年" + targetMonth + "月列(累计同比分母)"); } List headerRows = headerRowsOf(sh); int insertCol = lastVal + 2; // 累计列位置(8 月值列 Q=16 → 累计 S=18) int srcVal = lastVal, srcYoy = lastVal + 1; int lastRow = sh.getLastRowNum(); // 1) 区域右移 2 列(自右向左搬格,保留值/公式/样式) for (int r = 0; r <= lastRow; r++) { Row row = sh.getRow(r); if (row == null) continue; int lastCell = row.getLastCellNum() - 1; for (int c = lastCell; c >= insertCol; c--) { Cell src = row.getCell(c); if (src == null) continue; copyCell(src, getOrCreate(row, c + 2)); src.setBlank(); } } // 2) 合并区右移(跨插入点的整体加宽):先按下标摘除全部合并区,再按调整后的范围重新添加 List merges = new ArrayList<>(sh.getMergedRegions()); for (int i = merges.size() - 1; i >= 0; i--) { sh.removeMergedRegion(i); } for (CellRangeAddress mr : merges) { CellRangeAddress moved = mr; if (mr.getFirstColumn() >= insertCol) { moved = new CellRangeAddress(mr.getFirstRow(), mr.getLastRow(), mr.getFirstColumn() + 2, mr.getLastColumn() + 2); } else if (mr.getLastColumn() >= insertCol) { moved = new CellRangeAddress(mr.getFirstRow(), mr.getLastRow(), mr.getFirstColumn(), mr.getLastColumn() + 2); } sh.addMergedRegion(moved); } // 3) 本页公式引用整体 +2(指向已扩列页的引用同样 +2;指向未扩列页的不动) Set shiftable = new HashSet<>(EXPAND_PAGES); shiftAllRefs(sh, insertCol, shiftable); // 4) 新列:列宽/隐藏照抄上月;表头(每个块的表头行)克隆值+格式+合并区;数据区只留样式与公式 int w = sh.getColumnWidth(srcVal); if (w > 0) sh.setColumnWidth(insertCol, w); int w2 = sh.getColumnWidth(srcYoy); if (w2 > 0) sh.setColumnWidth(insertCol + 1, w2); sh.setColumnHidden(insertCol, sh.isColumnHidden(srcVal)); sh.setColumnHidden(insertCol + 1, sh.isColumnHidden(srcYoy)); for (int h : headerRows) { cloneHeaderCell(sh, srcVal, insertCol, h, year, targetMonth); cloneHeaderCell(sh, srcYoy, insertCol + 1, h, year, targetMonth); cloneHeaderMerge(sh, srcVal, insertCol, h); cloneHeaderMerge(sh, srcYoy, insertCol + 1, h); } for (int r = 0; r <= lastRow; r++) { if (headerRows.contains(r)) continue; Row row = sh.getRow(r); if (row == null) continue; cloneDataCell(row, srcVal, insertCol, shiftable, insertCol); cloneDataCell(row, srcYoy, insertCol + 1, shiftable, insertCol); } // 5) 累计列补当月项;累计同比分母补去年同月列 int cumCol = insertCol + 2, cumYoyCol = insertCol + 3; int denCol = lastYearSameMonth + 2; String valLetter = colLetter(insertCol); String denLetter = colLetter(denCol); int cumFixed = 0, cumYoyFixed = 0; for (int r = 0; r <= lastRow; r++) { if (headerRows.contains(r)) continue; Row row = sh.getRow(r); if (row == null) continue; Cell cum = row.getCell(cumCol); if (cum != null && cum.getCellType() == CellType.FORMULA) { String f = cum.getCellFormula(); if (!f.contains(valLetter + (r + 1))) { cum.setCellFormula(f + "+" + valLetter + (r + 1)); cumFixed++; } } Cell cumYoy = row.getCell(cumYoyCol); if (cumYoy != null && cumYoy.getCellType() == CellType.FORMULA) { String f = cumYoy.getCellFormula(); if (f.contains(denLetter + (r + 1))) continue; int close = f.lastIndexOf(')'); if (close > 0) { String inner = f.substring(0, close); String sep = inner.contains(",") ? "," : "+"; cumYoy.setCellFormula(inner + sep + denLetter + (r + 1) + f.substring(close)); cumYoyFixed++; } else { log.warn("扩列:{} 第 {} 行累计同比公式形态未识别,未补去年同月列:{}", sh.getSheetName(), r + 1, f); } } } log.info("扩列:{} 页完成——值列 {}→{},同比列 {}→{},累计列 {},累计同比列 {},去年同月列 {};表头行 {},末行 {};累计补 {} 格、累计同比补 {} 格", sh.getSheetName(), colLetter(srcVal), colLetter(insertCol), colLetter(srcYoy), colLetter(insertCol + 1), colLetter(cumCol), colLetter(cumYoyCol), denLetter, headerRows, lastRow + 1, cumFixed, cumYoyFixed); return insertCol; } /** 表头行(0 基):含「年+月」表头单元格的行;轨道、轮渡有两个块 → 两行。 */ private List headerRowsOf(Sheet sh) { List rows = new ArrayList<>(); int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + HEADER_SCAN_ROWS); for (int r = sh.getFirstRowNum(); r <= lastRow; r++) { Row row = sh.getRow(r); if (row == null) continue; for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) { if (yearMonthOf(row.getCell(c)) != null) { rows.add(r); break; } } } return rows; } private Map monthCols(Sheet sh, int year) { Map cols = new LinkedHashMap<>(); int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + HEADER_SCAN_ROWS); for (int r = sh.getFirstRowNum(); r <= lastRow; r++) { Row row = sh.getRow(r); if (row == null) continue; for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) { Integer ym = yearMonthOf(row.getCell(c)); if (ym == null || ym / 100 != year) continue; int mo = ym % 100; if (!cols.containsKey(mo)) cols.put(mo, c); } } return cols; } private Integer yearMonthOf(Cell c) { if (c == null) return null; try { CellType t = c.getCellType(); if (t == CellType.STRING) { String v = c.getStringCellValue(); if (v == null) return null; v = v.trim(); if (v.isEmpty() || v.contains("累计") || v.contains("同比") || v.contains("环比")) return null; Matcher m = MONTH_TEXT.matcher(v); if (!m.matches()) return null; int mo = Integer.parseInt(m.group(2)); return mo >= 1 && mo <= 12 ? Integer.parseInt(m.group(1)) * 100 + mo : null; } if (t == CellType.NUMERIC || t == CellType.FORMULA) { double v; if (t == CellType.NUMERIC) { v = c.getNumericCellValue(); } else { if (c.getCachedFormulaResultType() != CellType.NUMERIC) return null; v = c.getNumericCellValue(); } if (v < 36526 || v > 73415) return null; java.util.Calendar cal = java.util.Calendar.getInstance(); cal.setTime(org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v)); if (cal.get(java.util.Calendar.DAY_OF_MONTH) != 1) return null; return cal.get(java.util.Calendar.YEAR) * 100 + (cal.get(java.util.Calendar.MONTH) + 1); } } catch (Exception ignore) { return null; } return null; } private void cloneHeaderCell(Sheet sh, int srcCol, int dstCol, int rowIdx, int year, int targetMonth) { Row row = sh.getRow(rowIdx); if (row == null) return; Cell s = row.getCell(srcCol); if (s == null) return; Cell d = getOrCreate(row, dstCol); d.setBlank(); d.setCellStyle(s.getCellStyle()); if (s.getCellType() == CellType.STRING) { String text = s.getStringCellValue(); if (text != null && text.contains("同比")) { d.setCellValue(text.replaceAll("^\\d{1,2}月", targetMonth + "月")); } else if (text != null && MONTH_TEXT.matcher(text.trim()).matches()) { d.setCellValue(year + "年" + targetMonth + "月"); } else { d.setCellValue(text); } } else if (s.getCellType() == CellType.NUMERIC || s.getCellType() == CellType.FORMULA) { d.setCellValue(new GregorianCalendar(year, targetMonth - 1, 1).getTime()); } } private void cloneHeaderMerge(Sheet sh, int srcCol, int dstCol, int headerRow) { for (CellRangeAddress mr : new ArrayList<>(sh.getMergedRegions())) { if (mr.getFirstColumn() == srcCol && mr.getLastColumn() == srcCol && mr.getFirstRow() == headerRow) { sh.addMergedRegion(new CellRangeAddress(mr.getFirstRow(), mr.getLastRow(), dstCol, dstCol)); return; } } } private void cloneDataCell(Row row, int srcCol, int dstCol, Set shiftable, int insertCol) { Cell s = row.getCell(srcCol); if (s == null) return; Cell d = getOrCreate(row, dstCol); d.setBlank(); d.setCellStyle(s.getCellStyle()); if (s.getCellType() == CellType.FORMULA) { d.setCellFormula(shiftRefs(s.getCellFormula(), insertCol, shiftable)); } } private void shiftAllRefs(Sheet sh, int insertCol, Set shiftable) { for (int r = 0; r <= sh.getLastRowNum(); r++) { Row row = sh.getRow(r); if (row == null) continue; for (int c = 0; c < row.getLastCellNum(); c++) { Cell cell = row.getCell(c); if (cell == null || cell.getCellType() != CellType.FORMULA) continue; String f = cell.getCellFormula(); String nf = shiftRefs(f, insertCol, shiftable); if (!nf.equals(f)) cell.setCellFormula(nf); } } } /** 只平移「显式带工作表前缀、该页已扩列、且列号 >= 该页插入列」的引用(用于未扩列的页)。 */ private void shiftQualifiedRefs(Sheet sh, Map insertColOf) { for (int r = 0; r <= sh.getLastRowNum(); r++) { Row row = sh.getRow(r); if (row == null) continue; for (int c = 0; c < row.getLastCellNum(); c++) { Cell cell = row.getCell(c); if (cell == null || cell.getCellType() != CellType.FORMULA) continue; String f = cell.getCellFormula(); String nf = shiftQualified(f, insertColOf); if (!nf.equals(f)) cell.setCellFormula(nf); } } } private String shiftQualified(String formula, Map insertColOf) { Matcher m = REF.matcher(formula); StringBuffer sb = new StringBuffer(); while (m.find()) { String sheet = m.group(2) != null ? m.group(2) : m.group(3); Integer ins = sheet == null ? null : insertColOf.get(sheet); if (sheet == null || ins == null || isFunctionName(formula, m)) { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); continue; } int colIdx = colIndex(m.group(5)); if (colIdx >= ins) { m.appendReplacement(sb, Matcher.quoteReplacement(rebuild(m, sheet, colIdx + 2))); } else { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); } } m.appendTail(sb); return sb.toString(); } /** 平移公式中所有「列号 >= insertCol」的引用(sheet 为 null 或属于 shiftable 时生效)。 */ private String shiftRefs(String formula, int insertCol, Set shiftable) { Matcher m = REF.matcher(formula); StringBuffer sb = new StringBuffer(); while (m.find()) { String sheet = m.group(2) != null ? m.group(2) : m.group(3); if ((sheet != null && !shiftable.contains(sheet)) || isFunctionName(formula, m)) { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); continue; } int colIdx = colIndex(m.group(5)); if (colIdx >= insertCol) { m.appendReplacement(sb, Matcher.quoteReplacement(rebuild(m, sheet, colIdx + 2))); } else { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); } } m.appendTail(sb); return sb.toString(); } private boolean isFunctionName(String formula, Matcher m) { return m.group(1) == null && formula.indexOf('(', m.end()) == m.end(); } private String rebuild(Matcher m, String sheet, int newColIdx0) { String prefix = sheet == null ? "" : (m.group(1).startsWith("'") ? "'" + sheet + "'" : sheet) + "!"; return prefix + m.group(4) + colLetter(newColIdx0) + m.group(6) + m.group(7); } private void copyCell(Cell src, Cell dst) { dst.setCellStyle(src.getCellStyle()); CellType t = src.getCellType(); if (t == CellType.FORMULA) { dst.setCellFormula(src.getCellFormula()); } else if (t == CellType.NUMERIC) { dst.setCellValue(src.getNumericCellValue()); } else if (t == CellType.STRING) { dst.setCellValue(src.getStringCellValue()); } else if (t == CellType.BOOLEAN) { dst.setCellValue(src.getBooleanCellValue()); } else { dst.setBlank(); } } private Cell getOrCreate(Row row, int col) { Cell c = row.getCell(col); return c == null ? row.createCell(col) : c; } private static int colIndex(String letters) { int n = 0; for (int i = 0; i < letters.length(); i++) n = n * 26 + (letters.charAt(i) - 'A' + 1); return n - 1; } private static String colLetter(int idx0) { StringBuilder sb = new StringBuilder(); int n = idx0 + 1; while (n > 0) { int rem = (n - 1) % 26; sb.insert(0, (char) ('A' + rem)); n = (n - 1) / 26; } return sb.toString(); } }