feat(汇总大表): 生成件页签打开定位到本期数据列并重建累计/累计同比公式(待验收)
2个文件已修改
405 ■■■■■ 已修改文件
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryWorkbookFiller.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryWorkbookFiller.java
@@ -50,6 +50,9 @@
    /** 2026 年 1 月当月值列(0 基,即 C 列) */
    private static final int FIRST_MONTH_COL = 2;
    /** 表头行扫描范围(兼容“一页两块”的轨道、轮渡:第 2 块表头在第 11 行) */
    private static final int HDR_SCAN_ROWS = 15;
    /** 回填 1..keepMonths 月台账页,返回写入格数;缺数提示写入 problems */
    public int fillMonthlyLedger(XSSFWorkbook wb, String period, int year, int keepMonths, List<String> problems) {
        if (wb == null || keepMonths <= 0) return 0;
@@ -837,6 +840,186 @@
        return false;
    }
    /**
     * 「累计 / 累计同比」按表头重建(2026-10-13 新增,根治“累计漏月”)。
     *
     * <p>背景:当月列由“按月扩列器”插入时只在旧公式末尾追加当月项、不重建;一旦历史上漏过某月
     * (扩列器早期版本把含巧合日期值的数据行误判成表头行从而整行跳过——2026-09 的
     * 公交!U8、出租!U6、中口径明细!U5 即此),漏项会永久保留并逐月累积。
     *
     * <p>本方法在生成收尾时按表头重建公式,底稿里的历史欠账自动补齐,且不再依赖底稿公式本身正确:
     * <ul>
     *   <li>「累计」:公式引用全部落在本月度块的“当年当月值列”上时,重写为当年 1..month 各月值之和;</li>
     *   <li>「累计同比」:公式引用只用到本月度块的“去年月份列 + 本行累计列”时,分母重写为去年 1..month 各月值之和;</li>
     *   <li>已经完整的公式零改动;跨页引用等其它形态不动,只计数告警,避免误伤。</li>
     * </ul>
     * 多块页(轨道、轮渡一页两块)按“该行上方最近表头行”确定所属月份块。
     */
    public int repairCumulativeFormulas(XSSFWorkbook wb, int year, int month) {
        if (wb == null || month < 1) return 0;
        int fixed = 0, skipped = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet sh = wb.getSheetAt(i);
            if (sh == null || !isMonthLedgerSheet(sh.getSheetName())) continue;
            List<Integer> hdrs = headerRowsOf(sh, year);
            for (int hi = 0; hi < hdrs.size(); hi++) {
                int h = hdrs.get(hi);
                int blockEnd = (hi + 1 < hdrs.size()) ? hdrs.get(hi + 1) - 1 : sh.getLastRowNum();
                Map<Integer, Integer> cur = new LinkedHashMap<>();
                Map<Integer, Integer> lastY = new LinkedHashMap<>();
                monthColsOfRow(sh, h, year, month, cur, lastY);
                if (cur.isEmpty() || lastY.isEmpty()) continue;
                int lastMonthCol = 0;
                for (int c : cur.values()) lastMonthCol = Math.max(lastMonthCol, c);
                Integer cumCol = cumulativeColOfRow(sh, h, lastMonthCol);
                if (cumCol == null) continue;
                Integer cumYoyCol = cumYoyColOfRow(sh, h, cumCol);
                java.util.Set<Integer> curSet = new java.util.HashSet<>(cur.values());
                java.util.Set<Integer> lastSet = new java.util.HashSet<>(lastY.values());
                for (int r = h + 1; r <= blockEnd; r++) {
                    Row row = sh.getRow(r);
                    if (row == null) continue;
                    Cell cum = row.getCell(cumCol);
                    if (cum != null && cum.getCellType() == org.apache.poi.ss.usermodel.CellType.FORMULA) {
                        java.util.Set<Integer> used = sameRowRefCols(cum.getCellFormula(), r + 1);
                        if (!used.isEmpty()) {
                            if (used.equals(curSet)) {
                                // 已完整,零改动
                            } else if (curSet.containsAll(used)) {
                                String nf = refsJoined(cur, r + 1);
                                if (nf != null && !nf.equals(cum.getCellFormula())) {
                                    cum.setCellFormula(nf);
                                    fixed++;
                                }
                            } else {
                                skipped++;
                            }
                        }
                    }
                    if (cumYoyCol != null) {
                        Cell cy = row.getCell(cumYoyCol);
                        if (cy != null && cy.getCellType() == org.apache.poi.ss.usermodel.CellType.FORMULA) {
                            java.util.Set<Integer> used = sameRowRefCols(cy.getCellFormula(), r + 1);
                            used.remove(cumCol);
                            if (!used.isEmpty()) {
                                if (used.equals(lastSet)) {
                                    // 已完整,零改动
                                } else if (lastSet.containsAll(used)) {
                                    String den = refsJoined(lastY, r + 1);
                                    if (den != null) {
                                        String nf = cellRef(cumCol, r + 1) + "/SUM(" + den + ")-1";
                                        if (!nf.equals(cy.getCellFormula())) {
                                            cy.setCellFormula(nf);
                                            fixed++;
                                        }
                                    }
                                } else {
                                    skipped++;
                                }
                            }
                        }
                    }
                }
            }
        }
        if (fixed > 0 && log != null) {
            log.info("汇总工作簿:累计/累计同比按表头重建 {} 格(第 {} 月;形态不符跳过 {} 格)", fixed, month, skipped);
        } else if (skipped > 0 && log != null) {
            log.warn("汇总工作簿:{} 格累计/累计同比公式形态不符,未自动重建(需人工确认)", skipped);
        }
        return fixed;
    }
    /** 表头行(0 基):先取“月份格最多的那一行”为基准,只有这些列上过半是月份格的行才算表头行(同扩列器口径) */
    private List<Integer> headerRowsOf(Sheet sh, int year) {
        int last = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + HDR_SCAN_ROWS);
        List<Integer> base = new ArrayList<>();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            List<Integer> cols = monthHeaderCols(sh.getRow(r), year);
            if (cols.size() > base.size()) base = cols;
        }
        List<Integer> rows = new ArrayList<>();
        if (base.size() < 2) return rows;
        int need = Math.max(2, (base.size() + 1) / 2);
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            int hit = 0;
            for (int c : base) if (yearMonthOf(row.getCell(c), year) != null) hit++;
            if (hit >= need) rows.add(r);
        }
        return rows;
    }
    private List<Integer> monthHeaderCols(Row row, int year) {
        List<Integer> cols = new ArrayList<>();
        if (row == null) return cols;
        for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
            if (yearMonthOf(row.getCell(c), year) != null) cols.add(c);
        }
        return cols;
    }
    /** 表头行 -> 当年 1..month 月值列 / 去年 1..month 月值列(同月取最左一列) */
    private void monthColsOfRow(Sheet sh, int hdrRow, int year, int month,
                                Map<Integer, Integer> cur, Map<Integer, Integer> lastY) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return;
        for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
            Integer ym = yearMonthOf(row.getCell(c), year);
            if (ym == null) continue;
            int y = ym / 100, m = ym % 100;
            if (m < 1 || m > month) continue;
            if (y == year && !cur.containsKey(m)) cur.put(m, c);
            else if (y == year - 1 && !lastY.containsKey(m)) lastY.put(m, c);
        }
    }
    /** 该表头行「累计」列(0 基):最后一个月份列右侧 3 列内含“累计”且不含“同比”的表头格 */
    private Integer cumulativeColOfRow(Sheet sh, int hdrRow, int lastMonthCol) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return null;
        int end = Math.min(row.getLastCellNum() - 1, lastMonthCol + 3);
        for (int c = lastMonthCol + 1; c <= end; c++) {
            String t = text(row.getCell(c));
            if (t == null) continue;
            t = t.trim();
            if (t.contains("累计") && !t.contains("同比") && !YM_RANGE_CUM.matcher(t).matches()) return c;
        }
        return null;
    }
    /** 「累计」右邻(或再右一格)是否为「累计同比」 */
    private Integer cumYoyColOfRow(Sheet sh, int hdrRow, int cumCol) {
        Row row = sh.getRow(hdrRow);
        if (row == null) return null;
        String t = text(row.getCell(cumCol + 1));
        if (t != null && t.trim().contains("同比")) return cumCol + 1;
        String t2 = text(row.getCell(cumCol + 2));
        return (t2 != null && t2.trim().contains("同比")) ? cumCol + 2 : null;
    }
    /** 公式中“引用同一行”的列号集合(0 基);SUM 等函数名后无数字,不会被误匹配 */
    private java.util.Set<Integer> sameRowRefCols(String formula, int row1) {
        java.util.Set<Integer> cols = new java.util.HashSet<>();
        if (formula == null) return cols;
        java.util.regex.Matcher m = A1_REF.matcher(formula);
        while (m.find()) {
            if (Integer.parseInt(m.group(4)) == row1) cols.add(colIndexOf(m.group(2)));
        }
        return cols;
    }
    /** 把 {月->列} 按月份顺序拼成 "C5+E5+G5..." */
    private String refsJoined(Map<Integer, Integer> monthToCol, int row1) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<Integer, Integer> e : new java.util.TreeMap<>(monthToCol).entrySet()) {
            if (sb.length() > 0) sb.append('+');
            sb.append(cellRef(e.getValue(), row1));
        }
        return sb.length() == 0 ? null : sb.toString();
    }
    /** 该页「累计 / 累计同比」两列(0 基),按表头前 5 行找 “累计” 或 “{year}年累计”,且其右列含 “同比” */
    private int[] cumulativeCols(Sheet sh, int year) {
        int last = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -3767,6 +3767,8 @@
            int monthFormulaFilled = summaryWorkbookFiller.ensureMonthFormulas(wb, year, effMonth);
            // 累计同比分母修正:母版把「去年各月」与「去年1-M月累计」辅助列同时计入(重复计/漏当月)
            int cumYoyFixed = summaryWorkbookFiller.repairCumulativeYoyFormulas(wb, year, effMonth);
            // 2026-10-13:累计列 / 累计同比分母按表头重建(根治扩列器早期“累计漏月”的历史欠账)
            int cumRebuilt = summaryWorkbookFiller.repairCumulativeFormulas(wb, year, effMonth);
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新
@@ -3774,23 +3776,209 @@
            // 避免母版与参照件两处维护(母版已并入参照件的列宽/隐藏)。
            int widthApplied = 0;
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "修正累计同比分母格数={},重建累计/累计同比格数={},刷新排名页数值格数={},"
                    + "刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, cumRebuilt, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
            applyTwoDecimalFormat(wb); // 数值显示两位小数(保留全精度);% / 日期等既有样式不改变
            int inlineFixed = normalizeStaleInlineStrings(wb); // 清掉「非 inlineStr 却残留 <is>」的非法格,否则 Excel 拒绝打开
            if (inlineFixed > 0) log.warn("汇总工作簿清掉残留 inlineStr 非法格 {} 个(母版由 openpyxl 写出时的典型问题)", inlineFixed);
            reportProgress(progress, 70, "重算整本公式(约 4 万格)");
            int[] recalc = recalcWorkbookFormulas(wb); // 公式重算并写回缓存值:全省行(Σ市州)/累计列/排名 SUM 才能被预览与二次处理读到
            setSheetViewsToCurrentMonth(wb, year, effMonth); // 打开时定位到本期数据列(不影响数据与格式)
            log.info("汇总工作簿公式重算完成:成功 {} 格,失败 {} 格(失败格保留母版旧缓存值,打开时按 fullCalcOnLoad 重算)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算
            appendMissingDataSheet(wb, effPeriod, dbProblems); // 2026-10-12:缺数/体检结果写进生成件(用户要求)
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
            return normalizeSheetViews(toBytes(wb), wb, year, effMonth);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /**
     * 打开工作簿时的定位(2026-10-13):把每个页签的滚动/选中位置归一到「本期数据列」,
     * 并清掉母版(被 Excel/WPS 存过)残留的旧 topLeftCell/activeCell —— 否则每个页签会停在
     * 上次编辑时的奇怪位置(实测母版里 城市客运 BQ38、中口径明细 CC103、货运排名 A40…)。
     * 冻结拆分(xSplit/ySplit/state)原样保留,只把锚点挪到本期月份列。
     */
    private byte[] normalizeSheetViews(byte[] xlsx, XSSFWorkbook wb, int year, int month) {
        try {
            java.util.Map<String, String> targets = sheetViewTargets(wb, year, month);
            if (targets.isEmpty()) return xlsx;
            java.util.Map<String, byte[]> entries = new java.util.LinkedHashMap<>();
            try (java.util.zip.ZipInputStream zin = new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(xlsx))) {
                java.util.zip.ZipEntry e;
                byte[] buf = new byte[8192];
                while ((e = zin.getNextEntry()) != null) {
                    java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream();
                    int n;
                    while ((n = zin.read(buf)) > 0) b.write(buf, 0, n);
                    entries.put(e.getName(), b.toByteArray());
                }
            }
            byte[] wbx = entries.get("xl/workbook.xml");
            byte[] rlx = entries.get("xl/_rels/workbook.xml.rels");
            if (wbx == null || rlx == null) return xlsx;
            String wbs = new String(wbx, java.nio.charset.StandardCharsets.UTF_8);
            String rls = new String(rlx, java.nio.charset.StandardCharsets.UTF_8);
            java.util.Map<String, String> rid2t = new java.util.HashMap<>();
            java.util.regex.Matcher rm = java.util.regex.Pattern.compile("Id=\"([^\"]+)\"[^>]*Target=\"([^\"]+)\"").matcher(rls);
            while (rm.find()) rid2t.put(rm.group(1), rm.group(2));
            java.util.Map<String, String> name2part = new java.util.LinkedHashMap<>();
            java.util.regex.Matcher sm = java.util.regex.Pattern.compile("<sheet[^>]*name=\"([^\"]+)\"[^>]*r:id=\"([^\"]+)\"").matcher(wbs);
            while (sm.find()) {
                String t = rid2t.get(sm.group(2));
                if (t == null) continue;
                if (t.startsWith("/")) t = t.substring(1);
                else if (!t.startsWith("xl/")) t = "xl/" + t;
                name2part.put(sm.group(1), t);
            }
            int patched = 0;
            for (java.util.Map.Entry<String, String> en : name2part.entrySet()) {
                String target = targets.get(en.getKey());
                byte[] part = entries.get(en.getValue());
                if (target == null || part == null) continue;
                String xml = new String(part, java.nio.charset.StandardCharsets.UTF_8);
                String np = patchSheetView(xml, target);
                if (!np.equals(xml)) { entries.put(en.getValue(), np.getBytes(java.nio.charset.StandardCharsets.UTF_8)); patched++; }
            }
            try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(xlsx.length);
                 java.util.zip.ZipOutputStream zout = new java.util.zip.ZipOutputStream(bos)) {
                for (java.util.Map.Entry<String, byte[]> en : entries.entrySet()) {
                    zout.putNextEntry(new java.util.zip.ZipEntry(en.getKey()));
                    zout.write(en.getValue());
                    zout.closeEntry();
                }
                zout.finish();
                log.info("汇总工作簿:页签打开位置已归一到本期({}年{}月),处理 {} 个页签", year, month, patched);
                return bos.toByteArray();
            }
        } catch (Exception ex) {
            log.warn("汇总工作簿:页签打开位置归一失败(忽略,保留原样):{}", ex.toString());
            return xlsx;
        }
    }
    /** 每页的定位目标:有本期月份列 → 该月表头格;没有(排名/合成页)→ A1(顺带清掉残留位置) */
    private java.util.Map<String, String> sheetViewTargets(XSSFWorkbook wb, int year, int month) {
        java.util.Map<String, String> m = new java.util.LinkedHashMap<>();
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            XSSFSheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            int headerRow = -1, col = -1;
            int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
            for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                    Integer ym = smYearMonthOf(row.getCell(c));
                    if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; }
                }
            }
            m.put(sh.getSheetName(), col >= 0 ? a1Ref(headerRow, col) : "A1");
        }
        return m;
    }
    /** 0 基行列 -> "S3" */
    private String a1Ref(int row0, int col0) {
        StringBuilder sb = new StringBuilder();
        int n = col0 + 1;
        while (n > 0) { int rem = (n - 1) % 26; sb.insert(0, (char) ('A' + rem)); n = (n - 1) / 26; }
        return sb.toString() + (row0 + 1);
    }
    /** 去掉属性 */
    private String stripAttr(String attrs, String name) {
        int i = attrs.indexOf(name + "=\"");
        if (i < 0) return attrs;
        int e = attrs.indexOf('"', i + name.length() + 2);
        if (e < 0) return attrs;
        return attrs.substring(0, i) + attrs.substring(e + 1);
    }
    /** 找标签起点:要求标签名后面紧跟 空格/>,避免 "<sheetView" 命中 "<sheetViews" */
    private int findTag(String xml, String tag) {
        int from = 0;
        while (true) {
            int i = xml.indexOf(tag, from);
            if (i < 0) return -1;
            int nx = i + tag.length();
            char ch = nx < xml.length() ? xml.charAt(nx) : '>';
            if (ch == ' ' || ch == '>' || ch == '/') return i;
            from = i + 1;
        }
    }
    /** 取属性值(不存在返回 null) */
    private String attrValue(String attrs, String name) {
        int i = attrs.indexOf(name + "=\"");
        if (i < 0) return null;
        int e = attrs.indexOf('"', i + name.length() + 2);
        return e < 0 ? null : attrs.substring(i + name.length() + 2, e);
    }
    /** 追加属性(已存在则不重复加) */
    private String addAttr(String attrs, String name, String v) {
        if (attrValue(attrs, name) != null) return attrs;
        String sep = (attrs.isEmpty() || attrs.endsWith(" ")) ? "" : " ";
        return attrs + sep + name + "=\"" + v + "\"";
    }
    /**
     * 单页 XML 归一:sheetView / pane 的 topLeftCell、活动 pane 的 selection 全部指向 target。
     * 冻结拆分(xSplit/ySplit/state)原样保留;破坏结构则返回原文(failed-safe)。
     */
    private String patchSheetView(String xml, String target) {
        String orig = xml;
        int i = findTag(xml, "<sheetView");
        if (i >= 0) {
            int j = xml.indexOf('>', i);
            if (j > 0) {
                String attrs = addAttr(stripAttr(xml.substring(i + 10, j), "topLeftCell"), "topLeftCell", target);
                xml = xml.substring(0, i) + "<sheetView" + attrs + ">" + xml.substring(j + 1);
            }
        }
        String activePane = null;
        int p = findTag(xml, "<pane");
        if (p >= 0) {
            int q = xml.indexOf("/>", p);
            if (q > 0) {
                String raw = xml.substring(p + 5, q);
                activePane = attrValue(raw, "activePane");
                String attrs = addAttr(stripAttr(raw, "topLeftCell"), "topLeftCell", target);
                xml = xml.substring(0, p) + "<pane" + attrs + "/>" + xml.substring(q + 2);
            }
        }
        StringBuilder sb = new StringBuilder();
        int last = 0, pos = 0;
        boolean done = false, any = false;
        while (true) {
            int s0 = xml.indexOf("<selection", pos);
            if (s0 < 0) break;
            int s1 = xml.indexOf("/>", s0);
            if (s1 < 0) break;
            String raw = xml.substring(s0 + 10, s1);
            String attrs = stripAttr(stripAttr(raw, "activeCell"), "sqref");
            String pane = attrValue(raw, "pane");
            boolean hit = (activePane != null) ? (pane != null && pane.equals(activePane)) : !done;
            if (hit) { attrs = addAttr(addAttr(attrs, "activeCell", target), "sqref", target); done = true; }
            sb.append(xml, last, s0).append("<selection").append(attrs).append("/>");
            last = s1 + 2;
            pos = s1 + 2;
            any = true;
        }
        if (!any) {
            int close = xml.indexOf("</sheetView>");
            if (close > 0) xml = xml.substring(0, close) + "<selection activeCell=\"" + target + "\" sqref=\"" + target + "\"/>" + xml.substring(close);
        } else {
            sb.append(xml, last, xml.length());
            xml = sb.toString();
        }
        if (!xml.contains("<sheetViews>") || !xml.contains("</sheetViews>")) return orig;
        return xml;
    }
    /**
@@ -4563,6 +4751,34 @@
        return cols;
    }
    /**
     * 打开工作簿时把每个页签定位到「本期数据」位置(2026-10-13 用户要求)。
     * 做法:凡能按表头识别出「当年当月值列」的页,把活动单元格设到该月表头格;其余页保持默认。
     * 只影响打开时的滚动/选中位置,不改任何数据与格式。
     */
    private int setSheetViewsToCurrentMonth(XSSFWorkbook wb, int year, int month) {
        int n = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            XSSFSheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            int headerRow = -1, col = -1;
            int scan = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
            for (int r = sh.getFirstRowNum(); r <= scan && col < 0; r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                    Integer ym = smYearMonthOf(row.getCell(c));
                    if (ym != null && ym / 100 == year && ym % 100 == month) { headerRow = r; col = c; break; }
                }
            }
            if (col < 0) continue;
            sh.setActiveCell(new org.apache.poi.ss.util.CellAddress(headerRow, col));
            n++;
        }
        if (n > 0) log.info("汇总工作簿:{} 个页签已定位到本期({}年{}月)数据列", n, year, month);
        return n;
    }
    /** 表头单元格 -> 年*100+月;只认「YYYY年M月」文本或「某月 1 号」的日期序列号,排除累计/同比/环比 */
    private Integer smYearMonthOf(Cell c) {
        if (c == null) return null;