traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -34,14 +34,20 @@
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck;
import com.trafficaudit.reportexport.layout.SummaryLayoutExpander;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@@ -104,6 +110,10 @@
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private SummaryLayoutExpander summaryLayoutExpander;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private SummaryMissingDataCheck missingDataCheck;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
@@ -1433,10 +1443,11 @@
        Map<String, ScaleSplitTransport> result = new HashMap<>();
        List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
            new LambdaQueryWrapper<ScaleSplitTransport>()
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12:只认目标期,缺数留空、不静默回退上一期
        for (ScaleSplitTransport record : list) {
            if (record.getReportPeriod() == null) continue;
            if (record.getReportPeriod().compareTo(period) > 0) continue;
            if (!period.equals(record.getReportPeriod())) continue;
            String city = RegionUtil.normalizeCityName(record.getRegionName());
            if (city == null) continue;
            if ("湖北省".equals(city)) continue;
@@ -1469,11 +1480,12 @@
    private ScaleSplitTransport getProvinceCumulative(String period) {
        List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
            new LambdaQueryWrapper<ScaleSplitTransport>()
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12:同上(只认目标期)
        ScaleSplitTransport best = null;
        for (ScaleSplitTransport record : list) {
            if (record.getReportPeriod() == null) continue;
            if (record.getReportPeriod().compareTo(period) > 0) continue;
            if (!period.equals(record.getReportPeriod())) continue;
            String name = RegionUtil.normalizeCityName(record.getRegionName());
            if (!"湖北省".equals(name)) continue;
            if (best == null || record.getReportPeriod().compareTo(best.getReportPeriod()) > 0) best = record;
@@ -3638,14 +3650,70 @@
        if (month >= 12) {
            throw new RuntimeException("扩列:12 月之后需新开一本,跨年规则另行处理");
        }
        // 2026-10-12:扩列以「当期产物」为底(产物含当期列的值与公式;母版的当期列是空的,
        // 直接拿母版扩列会把当期数据丢成空洞,导致累计漏月)。产物取自导出落盘缓存;缺产物时回退母版并告警。
        File source = resolveLatestProduct(period);
        boolean fromProduct = source != null;
        if (source == null) {
            source = resolveSummaryMother(period, "month");
            if (source != null) {
                log.warn("汇总大表扩列:未找到 {} 的产物缓存,回退用母版扩列——历史列里缺少当期数据,累计可能漏月", period);
            }
        }
        if (source == null) {
            throw new RuntimeException("未找到汇总表母版或产物(docs/生成汇总大表):" + year + "年" + month + "月道路运输量汇总表.xlsx");
        }
        try (InputStream in = new FileInputStream(source)) {
            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
            log.info("汇总大表扩列完成:{}{} -> {}年{}月版式(只做版式、当期列留空)",
                    fromProduct ? "产物" : "母版", source.getName(), year, month + 1);
            return out;
        }
    }
    /** 取该期「最新产物」:导出落盘缓存 _cache/summaryWorkbook_<period>_month_v*.xlsx(按修改时间取最新) */
    private File resolveLatestProduct(String period) {
        try {
            String rel = summaryTemplateDir;
            while (rel != null && rel.startsWith("./")) rel = rel.substring(2);
            String[] roots = {
                summaryTemplateDir,
                System.getProperty("user.dir") + "/" + rel,
                System.getProperty("user.dir") + "/../" + rel
            };
            String prefix = "summaryWorkbook_" + period + "_month_";
            for (String root : roots) {
                if (root == null || root.trim().isEmpty()) continue;
                File dir = new File(root, "_cache");
                if (!dir.isDirectory()) continue;
                File[] files = dir.listFiles((d, name) -> name.startsWith(prefix) && name.endsWith(".xlsx"));
                if (files == null || files.length == 0) continue;
                File latest = files[0];
                for (File f : files) {
                    if (f.lastModified() > latest.lastModified()) latest = f;
                }
                return latest;
            }
            return null;
        } catch (Exception e) {
            log.warn("汇总大表扩列:查找产物缓存失败(忽略):{}", e.getMessage());
            return null;
        }
    }
    /** 只修正当期母版里 7 个标题页的标题文字(按母版期次重写,用于修复滞后标题)。 */
    public byte[] fixSummaryTitles(String period) throws Exception {
        if (period == null || !period.contains("-")) {
            throw new RuntimeException("标题修正仅支持按月,请指定 period=YYYY-MM");
        }
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, "month");
        File mother = resolveSummaryMother(period, "month");
        if (mother == null) {
            throw new RuntimeException("未找到汇总表母版(docs/生成汇总大表):" + year + "年" + month + "月道路运输量汇总表.xlsx");
        }
        try (InputStream in = new FileInputStream(mother)) {
            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
            log.info("汇总大表扩列完成:{}年{}月母版 -> {}年{}月版式(只做版式、数据留空)", year, month, year, month + 1);
            return out;
            return summaryLayoutExpander.fixTitles(org.apache.poi.util.IOUtils.toByteArray(in), year, month);
        }
    }
@@ -3691,33 +3759,326 @@
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            dbProblems.addAll(freightReadinessCheck.check(effPeriod));   // B4:货运口径导出前体检(6 条规则)
            dbProblems.addAll(missingDataCheck.check(effPeriod));        // 缺数清单(该有的数没有)
            reportProgress(progress, 22, "回填当月数据");
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);
            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 rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month", dbProblems);
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新
            int widthApplied = applySummaryReferenceLayout(wb);
            // 2026-10-12:停用「套用外部参照件《生成_道路运输量汇总表.xlsx》列宽」——版式一律以当期母版为准,
            // 避免母版与参照件两处维护(母版已并入参照件的列宽/隐藏)。
            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;
    }
    /**
     * 生成件《数据缺失清单》页(2026-10-12,用户要求)。
     *
     * <p>把本次导出的「缺数清单」与「货运基数体检」结果显式写进产物:用户拿到文件即可看到本报表期
     * 有哪些数没到、哪些数不合理,不必翻导出日志。只读提示页,不参与本表计算。
     */
    private void appendMissingDataSheet(XSSFWorkbook wb, String period, List<String> problems) {
        String sheetName = "数据缺失清单";
        int old = wb.getSheetIndex(sheetName);
        if (old >= 0) wb.removeSheetAt(old); // 母版由「当期产物」派生时可能已带本页,先移除避免重名
        XSSFSheet sh = wb.createSheet(sheetName);
        CellStyle titleStyle = wb.createCellStyle();
        Font titleFont = wb.createFont();
        titleFont.setBold(true);
        titleFont.setFontHeightInPoints((short) 14);
        titleStyle.setFont(titleFont);
        CellStyle metaStyle = wb.createCellStyle();
        Font metaFont = wb.createFont();
        metaFont.setFontHeightInPoints((short) 10);
        metaFont.setColor(IndexedColors.GREY_50_PERCENT.getIndex());
        metaStyle.setFont(metaFont);
        CellStyle sectionStyle = wb.createCellStyle();
        Font sectionFont = wb.createFont();
        sectionFont.setBold(true);
        sectionStyle.setFont(sectionFont);
        CellStyle bodyStyle = wb.createCellStyle();
        bodyStyle.setWrapText(true);
        bodyStyle.setVerticalAlignment(VerticalAlignment.TOP);
        List<String> missing = new ArrayList<>();
        List<String> check = new ArrayList<>();
        List<String> other = new ArrayList<>();
        if (problems != null) {
            for (String p : problems) {
                if (p == null || p.trim().isEmpty()) continue;
                if (p.startsWith("缺数")) missing.add(p);
                else if (p.startsWith("货运体检")) check.add(p);
                else other.add(p);
            }
        }
        int r = 0;
        Row tRow = sh.createRow(r++);
        Cell tCell = tRow.createCell(0);
        tCell.setCellValue("数据缺失清单");
        tCell.setCellStyle(titleStyle);
        Row mRow = sh.createRow(r++);
        Cell mCell = mRow.createCell(0);
        mCell.setCellValue("报表期 " + period + " 生成时间 "
            + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(new java.util.Date())
            + " 共 " + (missing.size() + check.size() + other.size()) + " 条");
        mCell.setCellStyle(metaStyle);
        Row nRow = sh.createRow(r++);
        Cell nCell = nRow.createCell(0);
        nCell.setCellValue("本页由程序自动生成(只读提示):一、缺数清单=该有的数没有;二、数据体检=数值不合理(如规下为负)。不参与本表计算。");
        nCell.setCellStyle(metaStyle);
        r++;
        r = writeProblemSection(sh, r, "一、缺数清单(该有的数没有)", missing, sectionStyle, bodyStyle);
        r = writeProblemSection(sh, r, "二、数据体检(数值不合理)", check, sectionStyle, bodyStyle);
        if (!other.isEmpty()) {
            r = writeProblemSection(sh, r, "三、其它提示", other, sectionStyle, bodyStyle);
        }
        sh.setColumnWidth(0, 6 * 256);
        sh.setColumnWidth(1, 120 * 256);
        sh.createFreezePane(0, 3);
    }
    /** 写一个清单分区(标题 + 逐条),返回下一个可写行号 */
    private int writeProblemSection(XSSFSheet sh, int rowIdx, String title, List<String> lines,
                                    CellStyle sectionStyle, CellStyle bodyStyle) {
        Row hr = sh.createRow(rowIdx++);
        Cell hc = hr.createCell(0);
        hc.setCellValue(title);
        hc.setCellStyle(sectionStyle);
        sh.addMergedRegion(new CellRangeAddress(rowIdx - 1, rowIdx - 1, 0, 1));
        if (lines.isEmpty()) {
            Row er = sh.createRow(rowIdx++);
            er.createCell(0).setCellValue("—");
            er.createCell(1).setCellValue("无");
            return rowIdx;
        }
        int i = 1;
        for (String line : lines) {
            Row row = sh.createRow(rowIdx++);
            row.createCell(0).setCellValue(i++);
            Cell c = row.createCell(1);
            c.setCellValue(line);
            c.setCellStyle(bodyStyle);
        }
        return rowIdx;
    }
    /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */
@@ -3889,11 +4250,11 @@
     * 的结果刷新数值,避免汇总表里的排名页停留在旧月份。
     * 数值格搬值、公式格按偏移平移公式后写入;目标格本身已是公式的(SUM/RANK/占比/补数)保留不动,打开时按新数据重算。
     */
    private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode) throws Exception {
    private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode, List<String> problems) throws Exception {
        int n = 0;
        n += copyRankValues(wb, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}});
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}});
        n += copyRankValues(wb, "货运量排名", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems);
        n += copyRankValues(wb, "货运周转量排名", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}}, problems);
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}}, problems);
        return n;
    }
@@ -3902,13 +4263,15 @@
     * srcBlocks[i] = 源块 i 的起始 {行, 列}(0 基);目标块按标题顺序(累计块在前、当月块在后)对应。
     * 数值格直接搬值;源侧是公式的(排名/占比等)按目标块偏移整体平移后写入公式,打开时按汇总表自身数据重算。
     */
    private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks) throws Exception {
    private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks,
                              List<String> problems) throws Exception {
        XSSFSheet dst = dstWb.getSheet(dstSheetName);
        if (dst == null) return 0;
        List<XSSFCell> dstTitles = rankTitleCells(dst);
        if (dstTitles.isEmpty()) return 0;
        int copied = 0;
        FormulaEvaluator dstEv = dstWb.getCreationHelper().createFormulaEvaluator();
        List<int[]> anchors = new ArrayList<>(); // {源全省行, 目标全省行, 块首列, 列偏移, 块宽}
        try (InputStream sin = new ByteArrayInputStream(srcBytes);
             XSSFWorkbook srcWb = new XSSFWorkbook(sin)) {
            XSSFSheet src = srcWb.getSheetAt(0);
@@ -3918,19 +4281,59 @@
                int sr0 = srcBlocks[b][0];
                int sc0 = srcBlocks[b][1];
                XSSFCell dstTitle = dstTitles.get(b);
                int rowOff = dstTitle.getRowIndex() - sr0;
                int colOff = dstTitle.getColumnIndex() - sc0;
                int rowOff = dstTitle.getRowIndex() - sr0;
                // 2026-10-13:《货运周转量排名》母版块标题曾占两行(合并),"标题行+固定偏移"会把源行
                // 整体写错位一行(全省值落到表头行、市州值与市州名错开)。改为用「全省」行做锚点对齐:
                // rowOff = 目标全省行 - 源全省行;识别不到锚点再回退标题行偏移并告警。
                Integer srcAnchor = rankAnchorRow(src, sr0, sc0);
                Integer dstAnchor = rankAnchorRow(dst, dstTitle.getRowIndex(), dstTitle.getColumnIndex());
                if (srcAnchor != null && dstAnchor != null) {
                    int anchored = dstAnchor - srcAnchor;
                    if (anchored != rowOff) {
                        log.info("排名页《{}》第 {} 块:标题行偏移 {} 与「全省」锚点偏移 {} 不一致,按锚点对齐(源全省行={},目标全省行={})",
                                dstSheetName, b + 1, rowOff, anchored, srcAnchor + 1, dstAnchor + 1);
                    }
                    rowOff = anchored;
                } else {
                    log.warn("排名页《{}》第 {} 块:未识别到「全省」锚点(源={},目标={}),回退按标题行偏移 {} 对齐",
                            dstSheetName, b + 1, srcAnchor, dstAnchor, rowOff);
                }
                int h = rankBlockHeight(src, sr0, sc0);
                int w = rankBlockWidth(src, sr0, sc0);
                anchors.add(new int[]{srcAnchor == null ? -1 : srcAnchor, dstAnchor == null ? -1 : dstAnchor, sc0, colOff, w});
                // 2026-10-12:整块缺数(源侧一个数值都没有,如 9 月缺②③时的周转量排名)→ 目标块只留标题与标签,
                // 数据/排名/占比一律留空;否则母版的 RANK/占比公式会在空块上算出「全 1 + #DIV/0!」。
                boolean blockHasData = false;
                for (int r = 1; r < h && !blockHasData; r++) {
                    Row sr = src.getRow(sr0 + r);
                    if (sr == null) continue;
                    for (int c = 0; c < w; c++) {
                        Cell sc0c = sr.getCell(sc0 + c);
                        if (sc0c != null && rankNumeric(sc0c, ev) != null) {
                            blockHasData = true;
                            break;
                        }
                    }
                }
                for (int r = 0; r < h; r++) {
                    Row srcRow = src.getRow(sr0 + r);
                    if (srcRow == null) continue;
                    Row dstRow = dst.getRow(sr0 + r + rowOff);
                    if (dstRow == null) dstRow = dst.createRow(sr0 + r + rowOff);
                    for (int c = 0; c < w; c++) {
                        Cell sc = srcRow.getCell(sc0 + c);
                        if (sc == null) continue;
                        int dc = sc0 + c + colOff;
                        Cell sc = srcRow.getCell(sc0 + c);
                        if (!blockHasData && r > 0) {
                            // 整块缺数:只保留标签(市州名/指标名),数据与排名/占比重算结果一律留空
                            if (sc == null || !isRankLabel(sc)) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (sc == null) {
                            // 2026-10-12:源侧整格缺失 = 缺数 → 目标格按「缺数留空」清空(不再沿用母版上一报表期的旧值)
                            if (r > 0) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (r == 0) {
                            // 标题行:同步块标题文案。母版标题是 inlineStr,POI 的 setCellValue 只写 <v>、
                            // 不更新 <is>,Excel 会继续显示旧月份,必须先 setBlank 清掉再写。
@@ -3965,13 +4368,46 @@
                            continue;
                        }
                        Double v = rankNumeric(sc, ev);
                        if (v == null) continue;
                        if (v == null) {
                            // 2026-10-12:源侧数值位为空(如缺②全口径时排名表的「规下」列)= 缺数 → 留空;
                            // 源侧是标签文本(市州名/指标名)时不是缺数,保留母版自带内容。
                            if (!isRankLabel(sc)) clearRankCell(dstRow, dc);
                            continue;
                        }
                        if (dcCell == null) dcCell = rankCell(dstRow, dc);
                        dcCell.setCellValue(v);
                        copied++;
                    }
                }
            }
        // 2026-10-13 自检:目标「全省」行应与源侧一致(偏移错位、汇总区间不对时报警并写进缺数清单)
        for (int[] an : anchors) {
            if (an[0] < 0 || an[1] < 0) continue;
            Row srr = src.getRow(an[0]);
            Row drr = dst.getRow(an[1]);
            if (srr == null || drr == null) continue;
            int badCols = 0;
            StringBuilder ex = new StringBuilder();
            for (int c = 0; c < an[4]; c++) {
                Cell sCell = srr.getCell(an[2] + c);
                Cell dCell = drr.getCell(an[2] + c + an[3]);
                Double sv = sCell == null ? null : rankNumeric(sCell, ev);
                Double dv = dCell == null ? null : rankNumeric(dCell, dstEv);
                if (sv == null && dv == null) continue;
                if (sv == null || dv == null || Math.abs(sv - dv) > Math.max(1.0, Math.abs(sv) * 1e-4)) {
                    badCols++;
                    if (badCols <= 3) {
                        if (ex.length() > 0) ex.append(";");
                        ex.append(colLetter(an[2] + c)).append(" 源=").append(sv).append(" 目标=").append(dv);
                    }
                }
            }
            if (badCols >= 2) {
                String msg = "排名页《" + dstSheetName + "》「全省」行与源侧不一致(" + badCols + " 列):" + ex;
                log.warn(msg);
                if (problems != null) problems.add("生成校验:" + msg);
            }
        }
        }
        return copied;
    }
@@ -3982,6 +4418,34 @@
        Cell left = col > 0 ? row.getCell(col - 1) : null;
        if (left != null) c.setCellStyle(left.getCellStyle());
        return c;
    }
    /**
     * 目标格按「缺数留空」清空。
     *
     * <p>2026-10-12:这里连母版自带的排名/占比公式格一起清(缺数期不显示"上一期残留值 + 全 1 排名 + #DIV/0!"),
     * 与《生成_货运量排名/周转量排名/中口径排名》这些用户日常核对件的空态一致;下期源侧有数时按源侧重写回来。
     * 文字标签格不动。
     */
    private void clearRankCell(Row dstRow, int col) {
        Cell c = dstRow.getCell(col);
        if (c == null) return;
        if (c.getCellType() != CellType.STRING) c.setBlank();
    }
    /** 源侧是否为文字标签(市州名/指标名)——标签不是缺数,不清空目标格 */
    private boolean isRankLabel(Cell c) {
        if (c.getCellType() != CellType.STRING) return false;
        String v = c.getStringCellValue();
        if (v == null) return false;
        String x = v.replace(",", "").trim();
        if (x.isEmpty()) return false;
        try {
            Double.parseDouble(x);
            return false;
        } catch (NumberFormatException ignore) {
            return true;
        }
    }
    /** 公式内所有 A1 引用整体平移:列 +dCol、行 +dRow(SUM/RANK 等函数名后接括号不会被匹配) */
@@ -4010,6 +4474,23 @@
    }
    /** 排名页里的“块标题”单元格(含“全省分市州”的说明文字),按行、列顺序返回 */
    /** 在 [fromRow, fromRow+40) 范围内、块起始两列里找整格文本为「全省」的行号(0 基);找不到返回 null。
     *  2026-10-13:排名页块结构可能因母版版式不同而整体上/下移一行,用「全省」行做锚点可自动对齐。 */
    private Integer rankAnchorRow(Sheet sh, int fromRow, int col0) {
        int last = Math.min(sh.getLastRowNum(), fromRow + 40);
        for (int r = fromRow; r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            for (int c = Math.max(0, col0); c <= col0 + 1; c++) {
                Cell cell = row.getCell(c);
                if (cell == null || cell.getCellType() != CellType.STRING) continue;
                String v = cell.getStringCellValue();
                if (v != null && v.replace(" ", "").equals("全省")) return r;
            }
        }
        return null;
    }
    private List<XSSFCell> rankTitleCells(Sheet sh) {
        List<XSSFCell> out = new ArrayList<>();
        for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) {
@@ -4334,6 +4815,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;
@@ -4497,8 +5006,8 @@
        if (sh == null) return 0;
        smSetTitle(sh, 0, 0, "城市客运客运量分市州1-" + month + "月累计情况");
        smSetTitle(sh, 22, 0, "城市客运客运周转量分市州1-" + month + "月累计情况");
        smSetTitle(sh, 45, 0, "城市客运客运量分市州" + month + "月情况");
        smSetTitle(sh, 67, 0, "城市客运客运周转量分市州" + month + "月情况");
        smSetTitle(sh, 44, 0, "城市客运客运量分市州" + month + "月情况");   // 0基44 = Excel 第45行(母版里标题在 A45,A46 是"单位"行)
        smSetTitle(sh, 66, 0, "城市客运客运周转量分市州" + month + "月情况");   // 0基66 = Excel 第67行(同上)
        int n = 0;
        n += smWriteCityByRegionBlock(sh, 4, true, curCum, lastCum);
        n += smWriteCityByRegionBlock(sh, 26, false, curCum, lastCum);