| New file |
| | |
| | | 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 首次实现)。 |
| | | * |
| | | * <p>把 N 月母版推进到 N+1 月版式(不预留空列):在「累计列」前插入 2 列(当月值列 + 同比列), |
| | | * 克隆两行纵向合并标题、重写累计枚举与累计同比分母、右移 2025/2024/2023 历史块及其公式引用, |
| | | * 并同步平移「其它页指向已扩列页、列号 >= 插入列」的跨页引用。 |
| | | * |
| | | * <p>规则依据:{@code docs/汇总大表母版扩列配置_核对版_2026-10-09.md}(8 个可见台账页; |
| | | * 4 个隐藏页保留隐藏、不扩列,但其跨页引用需同步平移)。只做版式,当月数据列留空。 |
| | | */ |
| | | @Slf4j |
| | | @Component |
| | | public class SummaryLayoutExpander { |
| | | |
| | | private static final List<String> 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<String> expanded = new HashSet<>(EXPAND_PAGES); |
| | | Map<String, Integer> 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<Integer, Integer> cur = monthCols(sh, year); |
| | | Integer lastVal = cur.get(lastMonth); |
| | | if (lastVal == null) { |
| | | throw new IllegalStateException("扩列:" + sh.getSheetName() + " 页未按表头识别到 " + year + "年" + lastMonth + "月列"); |
| | | } |
| | | Map<Integer, Integer> last = monthCols(sh, year - 1); |
| | | Integer lastYearSameMonth = last.get(targetMonth); |
| | | if (lastYearSameMonth == null) { |
| | | throw new IllegalStateException("扩列:" + sh.getSheetName() + " 页未识别到 " + (year - 1) + "年" + targetMonth + "月列(累计同比分母)"); |
| | | } |
| | | List<Integer> 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<CellRangeAddress> 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<String> 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<Integer> headerRowsOf(Sheet sh) { |
| | | List<Integer> 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<Integer, Integer> monthCols(Sheet sh, int year) { |
| | | Map<Integer, Integer> 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<String> 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<String> 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<String, Integer> 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<String, Integer> 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<String> 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(); |
| | | } |
| | | } |