traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -34,13 +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;
@@ -101,6 +108,12 @@
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Resource
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private SummaryLayoutExpander summaryLayoutExpander;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private SummaryMissingDataCheck missingDataCheck;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
@@ -224,6 +237,36 @@
                        rows = 0;
                        notes.add("母版定位失败:" + ex.getMessage());
                    }
                    break;
                }
                case "investPassengerDetail":
                case "investLogisticsDetail": {
                    boolean paxDetail = "investPassengerDetail".equals(t);
                    String catName = paxDetail ? "客运站场" : "物流园区";
                    String catLabel = paxDetail ? "客运站场投资月报" : "物流园区投资月报";
                    List<InvestmentProject> catProjects = investProjectMapper.selectList(
                        new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, catName));
                    final List<Long> catIds = new ArrayList<>();
                    for (InvestmentProject cp : catProjects) catIds.add(cp.getId());
                    if (catIds.isEmpty()) {
                        rows = 0;
                        notes.add("缺数据:" + catLabel);
                    } else {
                        rows = addReadyCount(() -> investMonthlyMapper.selectCount(
                            periodQw(period, mode, InvestmentMonthly::getReportPeriod)
                                .in(InvestmentMonthly::getProjectId, catIds)), catLabel, notes);
                    }
                    String detTpl = paxDetail ? "模板_X月客运站投资明细表.xlsx" : "模板_X月物流站场投资明细.xls";
                    try {
                        if (resolveAnyTemplate(investTemplateDir, detTpl) == null) {
                            rows = 0;
                            notes.add("docs/投资/模板 下缺少《" + detTpl + "》");
                        }
                    } catch (Exception ex) {
                        rows = 0;
                        notes.add("母版定位失败:" + ex.getMessage());
                    }
                    auditTypes.add("INVEST");
                    break;
                }
                default:
@@ -381,7 +424,9 @@
                coverage.addAll(investCover);
                // 投资为当月报表(无 1..N 累计口径),只提示当月缺失
                if (!investCover.contains(month)) missing.add(month);
                investPreview(period, mode, summary, problems);
                if ("investPassengerDetail".equals(t)) investPreview(period, mode, "客运站场", summary, problems);
                else if ("investLogisticsDetail".equals(t)) investPreview(period, mode, "物流园区", summary, problems);
                else investPreview(period, mode, summary, problems);
            } else if ("energySummary".equals(t)) {
                Long ec = energyMapper.selectCount(periodQw(period, mode, EnergyVehicleQuarterly::getReportPeriod));
                if (ec != null && ec > 0) coverage.add(month);
@@ -412,7 +457,8 @@
    }
    private boolean isInvestType(String t) {
        return "investPlan".equals(t) || "investFiveYearLogistics".equals(t) || "investBillion".equals(t) || "investCounty".equals(t);
        return "investPlan".equals(t) || "investFiveYearLogistics".equals(t) || "investBillion".equals(t) || "investCounty".equals(t)
            || "investPassengerDetail".equals(t) || "investLogisticsDetail".equals(t);
    }
    private boolean isEnergyType(String t) {
@@ -668,9 +714,23 @@
        if (allZero) problems.add("全省累计值全为 0(公交/出租月报可能未导入)");
    }
    /** 投资摘要:项目数 + 本月完成/自年初/自开始累计 */
    /** 投资摘要:项目数 + 本月完成/自年初/自开始累计(全投资月报合计,不分类别) */
    private void investPreview(String period, String mode, List<Map<String, Object>> summary, List<String> problems) {
        List<InvestmentMonthly> list = investMonthlyMapper.selectList(periodQw(period, mode, InvestmentMonthly::getReportPeriod));
        investPreview(period, mode, null, summary, problems);
    }
    /** 投资摘要(category 非空时只统计该类别,如客运站场/物流园区投资明细表) */
    private void investPreview(String period, String mode, String category, List<Map<String, Object>> summary, List<String> problems) {
        LambdaQueryWrapper<InvestmentMonthly> qw = periodQw(period, mode, InvestmentMonthly::getReportPeriod);
        if (category != null) {
            List<InvestmentProject> ps = investProjectMapper.selectList(
                new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, category));
            List<Long> ids = new ArrayList<>();
            for (InvestmentProject p : ps) ids.add(p.getId());
            if (ids.isEmpty()) { problems.add("缺数据:库内没有「" + category + "」项目主档"); return; }
            qw.in(InvestmentMonthly::getProjectId, ids);
        }
        List<InvestmentMonthly> list = investMonthlyMapper.selectList(qw);
        Set<Long> proj = new HashSet<>();
        double monthDone = 0.0, yearCum = 0.0, startCum = 0.0;
        for (InvestmentMonthly m : list) {
@@ -840,36 +900,36 @@
        for (Double v : h2032FreightCum.values()) {
            if (v != null) provAbove += v;
        }
        Double provAboveWan = round(provAbove / 10000.0, 4);
        Double provAboveWan = provAbove / 10000.0;
        Double provTotal = freightCum(ftMap.get("湖北省"), month);
        Double provBelow = provTotal == null ? null : round(provTotal - provAboveWan, 4);
        Double provBelow = provTotal == null ? null : provTotal - provAboveWan;
        Double provTotalYoy = freightCumYoy(ftMap.get("湖北省"), month);
        double provAboveLast = 0.0;
        for (Double v : h2032FreightCumLast.values()) {
            if (v != null) provAboveLast += v;
        }
        Double provAboveYoy = provAboveLast == 0.0 ? null : round((provAbove - provAboveLast) / provAboveLast, 4);
        Double provAboveYoy = provAboveLast == 0.0 ? null : (provAbove - provAboveLast) / provAboveLast;
        Double provTotalLast = lastFreightCum(ftMap.get("湖北省"), month);
        Double provBelowLast = provTotalLast == null ? null : round(provTotalLast - provAboveLast / 10000.0, 4);
        Double provBelowLast = provTotalLast == null ? null : provTotalLast - provAboveLast / 10000.0;
        Double provBelowYoy = provBelowLast == null || provBelowLast == 0.0 ? null
            : round((provBelow - provBelowLast) / provBelowLast, 4);
            : (provBelow - provBelowLast) / provBelowLast;
        for (String city : RegionUtil.cityList()) {
            Double a = h2032FreightCum.get(city);
            double aWan = a == null ? 0.0 : round(a / 10000.0, 4);
            double aWan = a == null ? 0.0 : a / 10000.0;
            Double t = freightCum(ftMap.get(city), month);
            above.put(city, aWan);
            total.put(city, t);
            below.put(city, t == null ? null : round(t - aWan, 4));
            below.put(city, t == null ? null : t - aWan);
            aboveYoy.put(city, h2032FreightYoy.get(city));
            // 规下同比 = (规下今年 - 规下去年) / 规下去年;规下去年 = 模板去年合计 - H2032去年规上
            Double tLast = lastFreightCum(ftMap.get(city), month);
            Double aLast = h2032FreightCumLast.get(city);
            double aLastWan = aLast == null ? 0.0 : round(aLast / 10000.0, 4);
            Double belowLast = tLast == null ? null : round(tLast - aLastWan, 4);
            double aLastWan = aLast == null ? 0.0 : aLast / 10000.0;
            Double belowLast = tLast == null ? null : tLast - aLastWan;
            Double b = below.get(city);
            belowYoy.put(city, belowLast == null || belowLast == 0.0 || b == null
                ? null : round((b - belowLast) / belowLast, 4));
                ? null : (b - belowLast) / belowLast);
            totalYoy.put(city, freightCumYoy(ftMap.get(city), month));
        }
@@ -894,20 +954,20 @@
            if (maLast != null) provMonthAboveLast += maLast;
            monthAbove.put(city, ma);
            monthTotal.put(city, mt);
            monthBelow.put(city, mt == null ? null : round(mt - (ma == null ? 0.0 : ma), 4));
            monthBelow.put(city, mt == null ? null : mt - (ma == null ? 0.0 : ma));
            monthAboveYoy.put(city, growthYoy(ma, maLast));
            Double mb = monthBelow.get(city);
            Double mbLast = mtLast == null ? null : round(mtLast - (maLast == null ? 0.0 : maLast), 4);
            Double mbLast = mtLast == null ? null : mtLast - (maLast == null ? 0.0 : maLast);
            monthBelowYoy.put(city, growthYoy(mb, mbLast));
            monthTotalYoy.put(city, freightYoy(ftMap.get(city), month));
        }
        Double provMonthAboveWan = provMonthAbove == 0.0 ? null : round(provMonthAbove, 4);
        Double provMonthAboveWan = provMonthAbove == 0.0 ? null : provMonthAbove;
        Double provMonthTotal = freightMonth(ftMap.get("湖北省"), month);
        Double provMonthBelow = provMonthTotal == null ? null : round(provMonthTotal - provMonthAbove, 4);
        Double provMonthBelow = provMonthTotal == null ? null : provMonthTotal - provMonthAbove;
        Double provMonthAboveYoy = growthYoy(provMonthAbove == 0.0 ? null : provMonthAbove, provMonthAboveLast);
        Double provMonthTotalLast = lastFreightMonth(ftMap.get("湖北省"), month);
        Double provMonthBelowLast = provMonthTotalLast == null ? null
            : round(provMonthTotalLast - provMonthAboveLast, 4);
            : provMonthTotalLast - provMonthAboveLast;
        Double provMonthBelowYoy = growthYoy(provMonthBelow, provMonthBelowLast);
        Double provMonthTotalYoy = freightYoy(ftMap.get("湖北省"), month);
@@ -1244,7 +1304,7 @@
    private Double monthFreightWan(Map<String, Map<Integer, Double>> byCityMonth, String city, int month) {
        Map<Integer, Double> byMonth = byCityMonth == null ? null : byCityMonth.get(city);
        Double v = byMonth == null ? null : byMonth.get(month);
        return v == null ? null : round(v / 10000.0, 4);
        return v == null ? null : v / 10000.0;
    }
    /** 同比 = (本期-基期)/基期;任一期缺失或基期为 0 返回 null */
@@ -1346,12 +1406,12 @@
        }
    }
    /** 周转量排名 Q/R 占比:模板为缓存值,按新数据重算(规上占比 1 位小数) */
    /** 周转量排名 Q/R 占比:模板为缓存值,按新数据重算(全精度写入,两位小数展示交给单元格格式) */
    private void setFreightShare(Row row, Double above, Double total) {
        if (total != null && total > 0 && above != null) {
            double share = round(above * 10.0 / total, 1);
            double share = above * 10.0 / total;
            setFreightCell(row, 16, share);
            setFreightCell(row, 17, round(10.0 - share, 1));
            setFreightCell(row, 17, 10.0 - share);
        } else {
            setFreightCell(row, 16, (Double) null);
            setFreightCell(row, 17, (Double) null);
@@ -1383,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;
@@ -1419,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;
@@ -1561,9 +1623,9 @@
        setNumeric(row, 15, isProvince ? null : yoyRankOfMap(totalYoy, city));
        if (t != null && t > 0 && a != null) {
            double share = round(a * 10.0 / t, 1);
            double share = a * 10.0 / t;
            setNumeric(row, 16, share);
            setNumeric(row, 17, round(10.0 - share, 1));
            setNumeric(row, 17, 10.0 - share);
        }
        return rowIdx + 1;
    }
@@ -1604,9 +1666,9 @@
        setNumeric(row, 15, isProvince || cum == null ? null : yoyRankOfTurnover(cumMap, city, 2));
        if (total != null && total > 0 && above != null) {
            double share = round(above * 10.0 / total, 1);
            double share = above * 10.0 / total;
            setNumeric(row, 16, share);
            setNumeric(row, 17, round(10.0 - share, 1));
            setNumeric(row, 17, 10.0 - share);
        }
        return rowIdx + 1;
    }
@@ -1626,7 +1688,7 @@
                Double v = byMonth == null ? null : byMonth.get(month);
                if (v != null) sum += v;
            }
            return round(sum / 10000.0, 4);
            return sum / 10000.0;
        }
        Double total = freightMonth(ft, month);
        Double above = getProvinceMonthValue(provinceMonthMap, ft, month, "freight", "above", h2032Freight);
@@ -1657,7 +1719,7 @@
            for (Double v : h2032FreightCum.values()) {
                if (v != null) sum += v;
            }
            return round(sum / 10000.0, 4);
            return sum / 10000.0;
        }
        Double total = freightCum(ft, monthCount);
        Double above = getProvinceCumValue(provinceCum, ft, "freight", "above", h2032FreightCum, monthCount);
@@ -1687,7 +1749,7 @@
        if ("above".equals(scale)) {
            Map<Integer, Double> byMonth = h2032Freight.get(city);
            Double v = byMonth == null ? null : byMonth.get(month);
            return v == null ? 0.0 : round(v / 10000.0, 4);
            return v == null ? 0.0 : v / 10000.0;
        }
        Double total = freightMonth(ft, month);
        Double above = getCityMonthValue(monthData, ft, city, month, "freight", "above", h2032Freight);
@@ -1716,7 +1778,7 @@
        if ("total".equals(scale)) return freightCum(ft, monthCount);
        if ("above".equals(scale)) {
            Double v = h2032FreightCum.get(city);
            return v == null ? 0.0 : round(v / 10000.0, 4);
            return v == null ? 0.0 : v / 10000.0;
        }
        Double total = freightCum(ft, monthCount);
        Double above = getCityCumValue(cum, ft, "freight", "above", h2032FreightCum, city, monthCount);
@@ -1953,20 +2015,20 @@
                    double v = passengerMonthVal(data, indi, currentYear, m, region, metricIdx);
                    if (!keepFormula) {
                        if (c == null) c = row.createCell(col);
                        if (v == 0.0) c.setBlank(); else c.setCellValue(round(v, 4));
                        if (v == 0.0) c.setBlank(); else c.setCellValue(v);
                    }
                    // 同比列:库内去年同月同比(模板 #REF! 公式替换为数值,公式行也覆写)
                    double lv = passengerMonthVal(data, indi, currentYear - 1, m, region, metricIdx);
                    Cell yc = row.getCell(col + 1);
                    if (yc == null) yc = row.createCell(col + 1);
                    if (v == 0.0 || lv == 0.0) yc.setBlank(); else yc.setCellValue(round((v - lv) / lv, 4));
                    if (v == 0.0 || lv == 0.0) yc.setBlank(); else yc.setCellValue((v - lv) / lv);
                }
                // 累计同比列(模板 #REF! → 库内去年 1..N 月累计同比数值)
                int cumCol = extended ? 26 : 14;
                Double cumYoy = passengerCumYoy(data, indi, currentYear, fillMonths, region, metricIdx);
                Cell yc2 = row.getCell(cumCol + 1);
                if (yc2 == null) yc2 = row.createCell(cumCol + 1);
                if (cumYoy == null) yc2.setBlank(); else yc2.setCellValue(round(cumYoy, 4));
                if (cumYoy == null) yc2.setBlank(); else yc2.setCellValue(cumYoy);
            }
            recalc(wb);
            return toBytes(wb);
@@ -2047,7 +2109,7 @@
    private Double yoy(Double cur, Double base) {
        if (cur == null || base == null || base == 0.0) return null;
        return round((cur - base) / base, 4);
        return (cur - base) / base;
    }
    /** 指标取值(已换算为万人/万人公里/公里;无数据返回 null) */
@@ -2286,7 +2348,7 @@
        if (v == 0.0) return; // 库内无源:保留母版/定稿原值,缺数据不覆盖
        // POI setCellValue(double) 对公式单元格只更新缓存不移除公式,必须先 setBlank 再写值
        c.setBlank();
        c.setCellValue(round(v, 4));
        c.setCellValue(v);
    }
    /** 显式写入 0(POI setCellValue(0.0) 会转 blank,需操作底层 XML) */
@@ -2454,11 +2516,12 @@
            String city = RegionUtil.normalizeCityName(b.getCity());
            if (city == null) continue;
            int key = y * 100 + m;
            // B1 兜底:存储的「城际城乡」缺失(<=0)时用「总客运量 − 城市内」现算,避免 2025 全年公交维度被当成 0
            double cxPass = nz(b.getPassengerChengxiang());
            if (cxPass <= 0) cxPass = nz(b.getPassengerVolume()) - nz(b.getPassengerCity());
            double cxTurn = nz(b.getTurnoverChengxiang());
            if (cxTurn <= 0) cxTurn = nz(b.getTurnover()) - nz(b.getTurnoverCity());
            // 口径统一(2026-09-19,用户核对「中口径排名」周转量 vs 人工列):城际城乡一律用「总量 − 城市内」现算。
            // 原逻辑「存储的城际城乡 > 0 就优先取字段值,<=0 才回退到差额法」会在个别企业行上与差额法差 ±0.01
            // (源报表里总量/城市内/城际城乡各自两位小数舍入,2026 年 1-8 月全省共 72 行),累加后
            // 「中口径排名」页比「中口径明细」/人工口径多 0.32 万人公里。改为差额法后与台账页、明细页、四页合成口径一致。
            double cxPass = nz(b.getPassengerVolume()) - nz(b.getPassengerCity());
            double cxTurn = nz(b.getTurnover()) - nz(b.getTurnoverCity());
            if (cxPass < 0) cxPass = 0.0;
            if (cxTurn < 0) cxTurn = 0.0;
            addMidClass(mid, key, city, 2, cxPass, cxTurn);
@@ -2581,12 +2644,12 @@
                    int monthCount = (y == currentYear) ? maxMonth : 12;
                    for (int m = 1; m <= monthCount; m++) {
                        Double cur = midMetricValue(aggOf(data, y, m, region), i, volume);
                        setNumeric(row, col++, cur == null ? null : round(cur, 4));
                        setNumeric(row, col++, cur);
                        Double base = midMetricValue(aggOf(data, y - 1, m, region), i, volume);
                        setNumeric(row, col++, yoy(cur, base));
                    }
                    Double cum = midCumOf(data, y, region, monthCount, i, volume);
                    setNumeric(row, col++, cum == null ? null : round(cum, 4));
                    setNumeric(row, col++, cum);
                    Double cumBase = midCumOf(data, y - 1, region, monthCount, i, volume);
                    setNumeric(row, col++, yoy(cum, cumBase));
                }
@@ -2633,9 +2696,19 @@
     * 生成_中口径排名.xlsx:单块累计(选 1 个月=当月,选 1-6 月=累计),RANK/SUM 公式保留。
     */
    public byte[] exportPassengerMidRank(String period, String mode) throws Exception {
        return exportPassengerMidRank(period, mode, null);
    }
    /**
     * 方案 A(2026-09-21):中口径排名的累计同比基数与“中口径明细”保持同源。
     * 明细页的 2025 年列是母版静态定稿值,排名页原先查库现算,两者在 2025-03/04 网约车拆分上不同。
     * summaryWb 非空时直接复用当前正在生成的汇总工作簿;独立导出时再打开对应母版。
     */
    private byte[] exportPassengerMidRank(String period, String mode, XSSFWorkbook summaryWb) throws Exception {
        int maxMonth = monthOf(period, mode);
        int currentYear = Integer.parseInt(period.substring(0, 4));
        File template = resolvePassengerTemplate("生成_中口径排名.xlsx");
        Map<String, double[]> lastYearCumBase = loadMidRankStaticBase(period, mode, summaryWb, currentYear, maxMonth);
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
@@ -2655,8 +2728,8 @@
                title.getCell(10).setCellValue(currentYear + "年" + maxMonth + "月全省分市州完成道路客运生产情况");
            }
            // 全省行 r3:B3/F3 为 SUM 公式保留;D3/H3 同比填值(0-based 列 3/7)
            setValOrBlank(sheet, 2, 3, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, true));
            setValOrBlank(sheet, 2, 7, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, false));
            setValOrBlank(sheet, 2, 3, midRankCumYoy(mid, currentYear, "湖北省", maxMonth, true, lastYearCumBase));
            setValOrBlank(sheet, 2, 7, midRankCumYoy(mid, currentYear, "湖北省", maxMonth, false, lastYearCumBase));
            // 右块全省行 N3/R3:当月同比(L3/P3 为 SUM 公式保留)
            setValOrBlank(sheet, 2, 13, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, true));
            setValOrBlank(sheet, 2, 17, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, false));
@@ -2667,25 +2740,127 @@
                int r0 = 3 + i;
                double pass = midCumClass(mid, currentYear, city, maxMonth, 0, true);
                double turn = midCumClass(mid, currentYear, city, maxMonth, 0, false);
                double lastPass = midCumClass(mid, currentYear - 1, city, maxMonth, 0, true);
                double lastTurn = midCumClass(mid, currentYear - 1, city, maxMonth, 0, false);
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : round(pass, 4));
                setValOrBlank(sheet, r0, 3, midCumYoy(mid, currentYear, city, maxMonth, 0, true));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : round(turn, 4));
                setValOrBlank(sheet, r0, 7, midCumYoy(mid, currentYear, city, maxMonth, 0, false));
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : pass);
                setValOrBlank(sheet, r0, 3, midRankCumYoy(mid, currentYear, city, maxMonth, true, lastYearCumBase));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : turn);
                setValOrBlank(sheet, r0, 7, midRankCumYoy(mid, currentYear, city, maxMonth, false, lastYearCumBase));
                // 右块当月:L/P 值、N/R 当月同比(M/O/Q/S 排名公式保留)
                double mPass = midClassVal(mid, currentYear, maxMonth, city, 0, true);
                double mTurn = midClassVal(mid, currentYear, maxMonth, city, 0, false);
                double mLastPass = midClassVal(mid, currentYear - 1, maxMonth, city, 0, true);
                double mLastTurn = midClassVal(mid, currentYear - 1, maxMonth, city, 0, false);
                setValOrBlank(sheet, r0, 11, mPass == 0 ? null : round(mPass, 4));
                setValOrBlank(sheet, r0, 11, mPass == 0 ? null : mPass);
                setValOrBlank(sheet, r0, 13, mPass == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, city, 0, true));
                setValOrBlank(sheet, r0, 15, mTurn == 0 ? null : round(mTurn, 4));
                setValOrBlank(sheet, r0, 15, mTurn == 0 ? null : mTurn);
                setValOrBlank(sheet, r0, 17, mTurn == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, city, 0, false));
            }
            recalc(wb);
            return toBytes(wb);
        }
    }
    /** 读取母版《中口径明细》的 2025 年逐月静态值,汇总成排名页所需的去年同期累计基数。 */
    private Map<String, double[]> readMidDetailLastYearCumulative(XSSFWorkbook sourceWb, int currentYear, int maxMonth) {
        Map<String, double[]> out = new LinkedHashMap<>();
        if (sourceWb == null || maxMonth <= 0) return out;
        Sheet sheet = sourceWb.getSheet("中口径明细");
        if (sheet == null) return out;
        Map<Integer, Integer> monthCols = new LinkedHashMap<>();
        java.util.regex.Pattern monthHeader = java.util.regex.Pattern.compile("^(\\d{4})年(\\d{1,2})月$");
        int headerEnd = Math.min(sheet.getLastRowNum(), sheet.getFirstRowNum() + 5);
        for (int r = sheet.getFirstRowNum(); r <= headerEnd; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (int c = row.getFirstCellNum(); c >= 0 && c < row.getLastCellNum(); c++) {
                String text = cellText(row.getCell(c));
                if (text == null) continue;
                java.util.regex.Matcher matcher = monthHeader.matcher(text.trim());
                if (!matcher.matches()) continue;
                int y = Integer.parseInt(matcher.group(1));
                int m = Integer.parseInt(matcher.group(2));
                if (y == currentYear - 1 && m >= 1 && m <= maxMonth) monthCols.putIfAbsent(m, c);
            }
        }
        if (monthCols.size() < maxMonth) return out;
        for (int r = sheet.getFirstRowNum(); r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            String city = RegionUtil.normalizeCityName(cellText(row.getCell(0)));
            if (city == null || !RegionUtil.CITY_LIST.contains(city)) continue;
            String metric = cellText(row.getCell(1));
            if (metric == null) continue;
            int idx = metric.contains("总旅客周转量") ? 1 : (metric.contains("总客运量") ? 0 : -1);
            if (idx < 0) continue;
            double sum = 0.0;
            boolean complete = true;
            for (int m = 1; m <= maxMonth; m++) {
                Cell c = row.getCell(monthCols.get(m));
                if (c == null) { complete = false; break; }
                if (c.getCellType() == CellType.NUMERIC) {
                    sum += c.getNumericCellValue();
                } else if (c.getCellType() == CellType.FORMULA && c.getCachedFormulaResultType() == CellType.NUMERIC) {
                    sum += c.getNumericCellValue();
                } else {
                    complete = false;
                    break;
                }
            }
            double[] values = out.computeIfAbsent(city, k -> new double[]{Double.NaN, Double.NaN});
            values[idx] = complete ? sum : Double.NaN;
        }
        double[] province = new double[2];
        for (String city : RegionUtil.CITY_LIST) {
            double[] values = out.get(city);
            if (values == null || Double.isNaN(values[0]) || Double.isNaN(values[1])) return new LinkedHashMap<>();
            province[0] += values[0];
            province[1] += values[1];
        }
        out.put("湖北省", province);
        return out;
    }
    /** 独立导出时从对应汇总母版读取静态基数;汇总导出时直接复用内存中的工作簿,避免重复 I/O。 */
    private Map<String, double[]> loadMidRankStaticBase(String period, String mode, XSSFWorkbook summaryWb,
                                                       int currentYear, int maxMonth) {
        if (summaryWb != null) {
            Map<String, double[]> base = readMidDetailLastYearCumulative(summaryWb, currentYear, maxMonth);
            if (!base.isEmpty()) return base;
            log.warn("中口径排名:当前汇总工作簿的中口径明细未读到完整 2025 静态累计基数,回退库内现算");
            return null;
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        ZipSecureFile.setMinInflateRatio(0.0001);
        try {
            File mother = resolveSummaryMother(period, mode);
            if (mother == null || !mother.isFile()) {
                log.warn("中口径排名:未找到 {} 的汇总母版,2025 累计基数回退库内现算", period);
                return null;
            }
            try (InputStream in = new FileInputStream(mother); XSSFWorkbook wb = new XSSFWorkbook(in)) {
                Map<String, double[]> base = readMidDetailLastYearCumulative(wb, currentYear, maxMonth);
                if (base.isEmpty()) {
                    log.warn("中口径排名:母版 {} 的中口径明细未读到完整 2025 静态累计基数,回退库内现算", mother.getName());
                    return null;
                }
                log.info("中口径排名:2025 累计基数改用母版静态列({})", mother.getName());
                return base;
            }
        } catch (Exception e) {
            log.warn("中口径排名:读取母版 2025 静态累计基数失败,回退库内现算:{}", e.getMessage());
            return null;
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /** 中口径排名累计同比:方案 A 有静态基数时与其对齐,否则保留原库内现算逻辑。 */
    private Double midRankCumYoy(Map<Integer, Map<String, double[][]>> mid, int year, String area,
                                 int monthCount, boolean volume, Map<String, double[]> staticBase) {
        if (staticBase == null) return midCumYoy(mid, year, area, monthCount, 0, volume);
        double[] values = staticBase.get(area);
        if (values == null) return null;
        double current = midCumClass(mid, year, area, monthCount, 0, volume);
        if (current == 0.0) return null;
        double base = volume ? values[0] : values[1];
        return base == 0.0 ? null : yoyOf(current, base);
    }
    /** 写数值或清空(公式单元格保留不动) */
@@ -2700,7 +2875,7 @@
    private Double yoyOf(double cur, double base) {
        if (base == 0.0) return null;
        return round((cur - base) / base, 4);
        return (cur - base) / base;
    }
    /** 中口径分类累计(1..monthCount 月求和) */
@@ -2742,9 +2917,9 @@
                double turn = midCumClass(mid, currentYear, "湖北省", maxMonth, dims[i], false);
                double lastPass = midCumClass(mid, currentYear - 1, "湖北省", maxMonth, dims[i], true);
                double lastTurn = midCumClass(mid, currentYear - 1, "湖北省", maxMonth, dims[i], false);
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : round(pass, 4));
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : pass);
                setValOrBlank(sheet, r0, 2, midCumYoy(mid, currentYear, "湖北省", maxMonth, dims[i], true));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : round(turn, 4));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : turn);
                setValOrBlank(sheet, r0, 6, midCumYoy(mid, currentYear, "湖北省", maxMonth, dims[i], false));
            }
            // 下半块(当月):标题行9、数据行11-15(0-based 8、10-14)
@@ -2758,9 +2933,9 @@
                double turn = midClassVal(mid, currentYear, maxMonth, "湖北省", dims[i], false);
                double lastPass = midClassVal(mid, currentYear - 1, maxMonth, "湖北省", dims[i], true);
                double lastTurn = midClassVal(mid, currentYear - 1, maxMonth, "湖北省", dims[i], false);
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : round(pass, 4));
                setValOrBlank(sheet, r0, 1, pass == 0 ? null : pass);
                setValOrBlank(sheet, r0, 2, pass == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, "湖北省", dims[i], true));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : round(turn, 4));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : turn);
                setValOrBlank(sheet, r0, 6, turn == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, "湖北省", dims[i], false));
            }
            // 下半块占比公式由样例指向上半块(B$3/F$3),改写为本块总行(B$11/F$11),行12-15
@@ -3178,7 +3353,7 @@
        Cell c = row.getCell(colIdx);
        if (c != null && c.getCellType() == org.apache.poi.ss.usermodel.CellType.FORMULA) return;
        if (c == null) c = row.createCell(colIdx);
        if (v == 0) c.setBlank(); else c.setCellValue(round(v, 2));
        if (v == 0) c.setBlank(); else c.setCellValue(v);
    }
    /** 显式写入 0(setDataCell 对 0 置空;汇总表网约车行需要显示 0) */
@@ -3459,7 +3634,87 @@
     * 保留全部页签/版式/公式/2025 年缓存值,仅把表头年份动态化为目标年;数据填充按 P2 起逐步接入。
     */
    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
        return exportSummaryWorkbook(period, mode, null);
        return exportSummaryWorkbook(period, mode, null, null);
    }
    /**
     * 汇总大表「按月扩列」(2026-10-09 Step 1):把 period 当月母版扩列成下一月版式。
     * 只做版式——当月数据列留空;不落盘,由调用方决定是否确认后另存为下期母版。
     */
    public byte[] expandSummaryWorkbook(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");
        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)) {
            return summaryLayoutExpander.fixTitles(org.apache.poi.util.IOUtils.toByteArray(in), year, month);
        }
    }
    /** 生成_道路运输量汇总表.xlsx(P0-P1 骨架,支持 toMonth 裁剪):
@@ -3467,6 +3722,13 @@
     *  toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头,
     *  累计与累计同比公式改为按 toMonth 月重算),用于“先生成 1-6 月、1-7 月扩列”验证。 */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth) throws Exception {
        return exportSummaryWorkbook(period, mode, toMonth, null);
    }
    /** 同上,额外通过 progress 回调报告进度(异步导出任务用:步骤百分比 + 步骤文案) */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth,
                                        java.util.function.BiConsumer<Integer, String> progress) throws Exception {
        reportProgress(progress, 2, "校验报表期与母版");
        int year = Integer.parseInt(period.contains("-") ? period.split("-")[0] : period);
        int month = period.contains("-") ? monthOf(period, mode) : 12;
        File template = resolveSummaryMother(period, mode);
@@ -3474,9 +3736,11 @@
            throw new RuntimeException("未找到汇总表母版《" + year + "年" + month + "月道路运输量汇总表.xlsx》(docs/生成汇总大表)");
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        reportProgress(progress, 6, "读取母版工作簿");
        ZipSecureFile.setMinInflateRatio(0.0001); // 新版母版 styles.xml 由 openpyxl 高压缩(0.0099<0.01)触发 POI Zip-bomb 误报,读取后还原
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            reportProgress(progress, 12, "还原同期月度块");
            File donor = resolveSummaryDonor(year, month);
            if (donor != null) {
                try (InputStream din = new FileInputStream(donor); XSSFWorkbook dwb = new XSSFWorkbook(din)) {
@@ -3495,26 +3759,335 @@
            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 cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新
            int widthApplied = applySummaryReferenceLayout(wb);
            // 2026-10-12:停用「套用外部参照件《生成_道路运输量汇总表.xlsx》列宽」——版式一律以当期母版为准,
            // 避免母版与参照件两处维护(母版已并入参照件的列宽/隐藏)。
            int widthApplied = 0;
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "修正累计同比分母格数={},重建累计/累计同比格数={},刷新排名页数值格数={},"
                    + "刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, 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); // 还原/改写公式后打开即重算
            return toBytes(wb);
            appendMissingDataSheet(wb, effPeriod, dbProblems); // 2026-10-12:缺数/体检结果写进生成件(用户要求)
            reportProgress(progress, 95, "写出文件");
            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;
    }
    /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */
    private static void reportProgress(java.util.function.BiConsumer<Integer, String> progress, int percent, String step) {
        if (progress == null) return;
        try {
            progress.accept(percent, step);
        } catch (Exception ignore) {
            // 进度回调失败不影响导出
        }
    }
@@ -3681,7 +4254,7 @@
        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), new int[][]{{0, 0}, {0, 10}});
        n += copyRankValues(wb, "中口径排名", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}});
        return n;
    }
@@ -3710,15 +4283,38 @@
                int colOff = dstTitle.getColumnIndex() - sc0;
                int h = rankBlockHeight(src, sr0, sc0);
                int w = rankBlockWidth(src, sr0, sc0);
                // 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 清掉再写。
@@ -3753,7 +4349,12 @@
                            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++;
@@ -3770,6 +4371,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 等函数名后接括号不会被匹配) */
@@ -3900,9 +4529,8 @@
    private static final int SM_WYC_CX_PAX = 16, SM_WYC_CX_TURN = 17;
    private static final int SM_FREIGHT_TURN = 18;
    private static final int SM_DIM = 19;
    /** 2026 年 1 月当月值列(0 基,C 列);2025 年 1 月当月值列(0 基,V 列) */
    private static final int SM_CUR_COL0 = 2;
    private static final int SM_LAST_COL0 = 21;
    // 2026-10-09:原先写死的 SM_CUR_COL0=2(2026年1月=C)/ SM_LAST_COL0=21(2025年1月=V)已废弃,
    // 改为按表头动态定位(见 smMonthCols / smYearMonthOf),兼容「文本 / 日期序列号 / 日期格式」三种表头写法。
    /**
     * 刷新四张跨页合成页:当年累计(1..month 月)、当月(month 月)及对应同比;
@@ -3913,10 +4541,10 @@
        int[] cumMonths = new int[month];
        for (int m = 1; m <= month; m++) cumMonths[m - 1] = m;
        int[] oneMonth = new int[]{month};
        Map<String, double[]> curCum = smReadLedgerMetrics(wb, true, cumMonths);
        Map<String, double[]> lastCum = smReadLedgerMetrics(wb, false, cumMonths);
        Map<String, double[]> curMon = smReadLedgerMetrics(wb, true, oneMonth);
        Map<String, double[]> lastMon = smReadLedgerMetrics(wb, false, oneMonth);
        Map<String, double[]> curCum = smReadLedgerMetrics(wb, year, cumMonths);
        Map<String, double[]> lastCum = smReadLedgerMetrics(wb, year - 1, cumMonths);
        Map<String, double[]> curMon = smReadLedgerMetrics(wb, year, oneMonth);
        Map<String, double[]> lastMon = smReadLedgerMetrics(wb, year - 1, oneMonth);
        if (curCum.isEmpty()) {
            log.warn("汇总大表四页刷新:台账页未读到市州数据,跳过(保持母版原值)");
            return 0;
@@ -3945,22 +4573,23 @@
    }
    /** 台账页累计取数:市州规范名 -> 19 维指标 */
    private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, boolean curYear, int[] months) {
    private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, int blockYear, int[] months) {
        Map<String, double[]> out = new LinkedHashMap<>();
        smReadCityPaxPage(wb, "公交", curYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN);
        smReadCityPaxPage(wb, "出租", curYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN);
        smReadCityPaxPage(wb, "网约车", curYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN);
        smReadRailFerry(wb, curYear, months, out);
        smReadBanxian(wb, curYear, months, out);
        smReadFreightTurnover(wb, curYear, months, out);
        smReadCityPaxPage(wb, "公交", blockYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN);
        smReadCityPaxPage(wb, "出租", blockYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN);
        smReadCityPaxPage(wb, "网约车", blockYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN);
        smReadRailFerry(wb, blockYear, months, out);
        smReadBanxian(wb, blockYear, months, out);
        smReadFreightTurnover(wb, blockYear, months, out);
        return out;
    }
    /** 公交/出租/网约车页:每市州 4 行(客运量/旅客周转量/其中城市内客运量/其中城市内旅客周转量) */
    private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, boolean curYear, int[] months,
    private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, int blockYear, int[] months,
                                   Map<String, double[]> out, int paxIdx, int turnIdx, int cxPaxIdx, int cxTurnIdx) {
        Sheet sh = wb.getSheet(sheetName);
        if (sh == null) return;
        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
@@ -3983,10 +4612,10 @@
                    else if (paxRow < 0) paxRow = k;
                }
            }
            double totalPax = smSumMonths(sh, paxRow, curYear, months);
            double totalTurn = smSumMonths(sh, turnRow, curYear, months);
            double cityPax = smSumMonths(sh, cxPaxRow, curYear, months);
            double cityTurn = smSumMonths(sh, cxTurnRow, curYear, months);
            double totalPax = smSumMonths(sh, paxRow, monthCols, months);
            double totalTurn = smSumMonths(sh, turnRow, monthCols, months);
            double cityPax = smSumMonths(sh, cxPaxRow, monthCols, months);
            double cityTurn = smSumMonths(sh, cxTurnRow, monthCols, months);
            double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
            arr[paxIdx] += cityPax;                 // 城市客运口径取“城市内”
            arr[turnIdx] += cityTurn;
@@ -3996,9 +4625,10 @@
    }
    /** 轨道、轮渡页:轨道块(武汉/黄石)+ 轮渡块(武汉) */
    private void smReadRailFerry(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
    private void smReadRailFerry(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet("轨道、轮渡");
        if (sh == null) return;
        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
        int last = sh.getLastRowNum();
        int ferryTitle = -1;
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
@@ -4007,12 +4637,12 @@
            String a = smText(row.getCell(0));
            if (a != null && a.contains("轮渡客运量")) { ferryTitle = r; break; }
        }
        smReadRailFerryBlock(sh, curYear, months, out, sh.getFirstRowNum(),
        smReadRailFerryBlock(sh, monthCols, months, out, sh.getFirstRowNum(),
                ferryTitle < 0 ? last : ferryTitle - 1, SM_RAIL_PAX);
        if (ferryTitle >= 0) smReadRailFerryBlock(sh, curYear, months, out, ferryTitle, last, SM_FERRY_PAX);
        if (ferryTitle >= 0) smReadRailFerryBlock(sh, monthCols, months, out, ferryTitle, last, SM_FERRY_PAX);
    }
    private void smReadRailFerryBlock(Sheet sh, boolean curYear, int[] months, Map<String, double[]> out,
    private void smReadRailFerryBlock(Sheet sh, Map<Integer, Integer> monthCols, int[] months, Map<String, double[]> out,
                                      int start, int end, int baseIdx) {
        for (int r = start; r <= end; r++) {
            Row row = sh.getRow(r);
@@ -4029,15 +4659,16 @@
                else if (paxRow < 0) paxRow = k;
            }
            double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
            arr[baseIdx] += smSumMonths(sh, paxRow, curYear, months);
            arr[baseIdx + 1] += smSumMonths(sh, turnRow, curYear, months);
            arr[baseIdx] += smSumMonths(sh, paxRow, monthCols, months);
            arr[baseIdx + 1] += smSumMonths(sh, turnRow, monthCols, months);
        }
    }
    /** 班线包车页:每市州 客运量 / 旅客周转量(“其中个体”行不重复计入) */
    private void smReadBanxian(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
    private void smReadBanxian(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet("班线包车");
        if (sh == null) return;
        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
@@ -4054,16 +4685,17 @@
                else if (b.contains("客运量") && paxRow < 0) paxRow = k;
            }
            double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
            arr[SM_BX_PAX] += smSumMonths(sh, paxRow, curYear, months);
            arr[SM_BX_TURN] += smSumMonths(sh, turnRow, curYear, months);
            arr[SM_BX_PAX] += smSumMonths(sh, paxRow, monthCols, months);
            arr[SM_BX_TURN] += smSumMonths(sh, turnRow, monthCols, months);
        }
    }
    /** 货运页:每市州 规上货物周转量 + 规下货物周转量(“规上+规下”行本身是求和公式,不直接取) */
    private void smReadFreightTurnover(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
    private void smReadFreightTurnover(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet(" 货运");
        if (sh == null) sh = wb.getSheet("货运");
        if (sh == null) return;
        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
@@ -4080,20 +4712,102 @@
                else if (b.contains("规下货物周转量")) { if (below < 0) below = k; }
            }
            double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
            arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, curYear, months) + smSumMonths(sh, below, curYear, months);
            arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, monthCols, months) + smSumMonths(sh, below, monthCols, months);
        }
    }
    private double smSumMonths(Sheet sh, int rowIdx, boolean curYear, int[] months) {
        if (sh == null || rowIdx < 0) return 0;
    private double smSumMonths(Sheet sh, int rowIdx, Map<Integer, Integer> monthCols, int[] months) {
        if (sh == null || rowIdx < 0 || monthCols == null) return 0;
        Row row = sh.getRow(rowIdx);
        if (row == null) return 0;
        double s = 0;
        for (int m : months) {
            int col = (curYear ? SM_CUR_COL0 : SM_LAST_COL0) + 2 * (m - 1);
            Integer col = monthCols.get(m);
            if (col == null) continue;
            s += smNum(row.getCell(col));
        }
        return s;
    }
    /** 台账页「某年 M 月」的 0 基列号:按表头动态定位(不再写死 C/V),兼容文本 / 日期序列号 / 日期格式三种写法 */
    private Map<Integer, Integer> smMonthCols(Sheet sh, int wantedYear) {
        Map<Integer, Integer> cols = new LinkedHashMap<>();
        if (sh == null) return cols;
        int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
        for (int r = sh.getFirstRowNum(); r <= lastRow; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            int lastCol = row.getLastCellNum();
            for (int c = row.getFirstCellNum(); c >= 0 && c < lastCol; c++) {
                Integer ym = smYearMonthOf(row.getCell(c));
                if (ym == null || ym / 100 != wantedYear) continue;
                int mo = ym % 100;
                if (!cols.containsKey(mo)) cols.put(mo, c);
            }
        }
        if (cols.isEmpty()) {
            log.warn("汇总大表四页刷新:{} 页未识别到 {} 年的月份表头列(支持文本/序列号/日期三种形式)", sh.getSheetName(), wantedYear);
        }
        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;
        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;
                java.util.regex.Matcher m = java.util.regex.Pattern
                        .compile("^(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月$").matcher(v);
                if (!m.matches()) return null;
                int y = Integer.parseInt(m.group(1));
                int mo = Integer.parseInt(m.group(2));
                return mo >= 1 && mo <= 12 ? y * 100 + mo : null;
            }
            if (t == CellType.NUMERIC) {
                double 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;
    }
    /** 台账页单元格取数(数字 / 数字文本 / 公式缓存) */
@@ -4228,8 +4942,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);
@@ -4642,8 +5356,8 @@
            if (r.getAvgDistanceCity() != null && r.getAvgDistanceCity() > 0) a[5] = r.getAvgDistanceCity();
        }
        for (double[] a : map.values()) {
            if (a[4] <= 0 && a[0] > 0) a[4] = round(a[1] / a[0], 2);
            if (a[5] <= 0 && a[2] > 0) a[5] = round(a[3] / a[2], 2);
            if (a[4] <= 0 && a[0] > 0) a[4] = a[1] / a[0];
            if (a[5] <= 0 && a[2] > 0) a[5] = a[3] / a[2];
        }
        return map;
    }
@@ -4959,19 +5673,19 @@
                    Row row = sheet.getRow(rowIdx);
                    if (row == null) row = sheet.createRow(rowIdx);
                    if (a > 0) {
                        // 2026年 1..N 月:库内有值则填(2位小数),无值清空模板样例
                        // 2026年 1..N 月:库内有值则填(全精度写入,两位小数由单元格格式展示),无值清空模板样例
                        for (int m = 1; m <= month; m++) {
                            double v = cityVal(monthCity, area, m, k);
                            Cell c = row.getCell(2 + (m - 1) * 2);
                            if (c == null) c = row.createCell(2 + (m - 1) * 2);
                            if (v == 0) c.setBlank(); else c.setCellValue(round(v, 2));
                            if (v == 0) c.setBlank(); else c.setCellValue(v);
                        }
                        // 2025年 1..12 月:库内有去年数据则填,否则清空模板样例
                        for (int m = 1; m <= 12; m++) {
                            double v = cityVal(lastYearMonthCity, area, m, k);
                            Cell c = row.getCell(19 + (m - 1) * 2);
                            if (c == null) c = row.createCell(19 + (m - 1) * 2);
                            if (v == 0) c.setBlank(); else c.setCellValue(round(v, 2));
                            if (v == 0) c.setBlank(); else c.setCellValue(v);
                        }
                    }
                    rowIdx++;
@@ -5380,6 +6094,7 @@
                hssfText(row, 0, "合计");
                InvestAgg prov = new InvestAgg();
                for (InvestmentMonthly m : monthlies) prov.add(m, pmap.get(m.getProjectId()));
                addProjectsWithoutMonthly(prov, projects, mByProject, null, null);
                writeInvestAggHssf(row, prov);
                continue;
            }
@@ -5392,6 +6107,7 @@
                    InvestmentProject p = pmap.get(m.getProjectId());
                    if (p != null && blockCategory.equals(p.getCategory())) agg.add(m, p);
                }
                addProjectsWithoutMonthly(agg, projects, mByProject, blockCategory, null);
                writeInvestAggHssf(row, agg);
                seq = 0;
                continue;
@@ -5420,6 +6136,7 @@
                    if (p != null && city.equals(p.getCity())
                        && (blockCategory == null || blockCategory.equals(p.getCategory()))) agg.add(m, p);
                }
                addProjectsWithoutMonthly(agg, projects, mByProject, blockCategory, city);
                writeInvestAggHssf(row, agg);
                continue;
            }
@@ -5464,7 +6181,25 @@
        hssfNum(row, 8, agg.startCum == 0 ? null : agg.startCum);
        hssfNum(row, 9, agg.yearPlan == 0 ? null : agg.yearPlan);
        hssfNum(row, 22, agg.yearCum == 0 ? null : agg.yearCum);
        if (agg.yearPlan > 0) hssfNum(row, 23, round(agg.yearCum / agg.yearPlan, 4));
        if (agg.yearPlan > 0) hssfNum(row, 23, agg.yearCum / agg.yearPlan);
    }
    /**
     * 补计「本期没有月度记录、但项目主档有总投资」的项目(只计总投资这一列)。
     * 场景:某市州本月未报送单表(如武汉 8 月物流),主档里项目仍在,若不补计,该市州小计/合计会整块为空。
     *
     * @param category 块类别(客运站场/物流园区);null 表示不限
     * @param city     市州全称(如 武汉市);null 表示不限
     */
    private void addProjectsWithoutMonthly(InvestAgg agg, List<InvestmentProject> projects,
                                           Map<Long, InvestmentMonthly> mByProject,
                                           String category, String city) {
        for (InvestmentProject p : projects) {
            if (p == null || mByProject.containsKey(p.getId())) continue;
            if (category != null && !category.equals(p.getCategory())) continue;
            if (city != null && !city.equals(p.getCity())) continue;
            if (p.getTotalInvestment() != null) agg.total += p.getTotalInvestment();
        }
    }
@@ -5575,6 +6310,8 @@
            String curCity = null;
            Set<Long> used = new HashSet<>();
            int filled = 0, kept = 0, dup = 0;
            List<String> skipped = new ArrayList<>();
            Map<String, Integer> skippedByCity = new LinkedHashMap<>();
            for (int r = 8; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
@@ -5586,11 +6323,16 @@
                if (!a.matches("\\d+(\\.\\d+)?")) continue;
                if (b.isEmpty() || curCity == null) continue;
                InvestmentProject p = matchInvest(projects, b, curCity, "客运站场", used);
                p = preferMonthlyCandidate(projects, p, mByProject, "客运站场", used);
                InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
                if (p == null || m == null) {
                    kept++;
                    // 有库内项目但当月无数据 → 记为待核对;无匹配项目 → 结转上月
                    if (p != null) dup++;
                    // 2026-09-22:不再静默结转上月,未覆盖的行必须留痕(行号+市州+项目名+原因)
                    skipped.add("第" + (r + 1) + "行 " + curCity + " " + b
                            + (p == null ? " → 库内无匹配项目" : " → 库内无 " + period + " 月度数据"));
                    skippedByCity.merge(curCity, 1, Integer::sum);
                    continue;
                }
                used.add(p.getId());
@@ -5620,6 +6362,7 @@
            }
            log.info("客运站投资明细表 {}:写入 {} 行,结转母版原值 {} 行(其中库内有项目但当月无数据 {} 行)",
                    period, filled, kept, dup);
            logSkippedRows("客运站投资明细表", period, skipped, skippedByCity);
            return toBytes(wb);
        }
    }
@@ -5660,6 +6403,8 @@
        Map<String, double[]> cityStats = new LinkedHashMap<>();  // 全名 -> {总数, 累计, 规划内数, 规划内累计, 规划外数, 规划外累计}
        Set<Long> used = new HashSet<>();
        int filled = 0, kept = 0;
        List<String> skipped = new ArrayList<>();
        Map<String, Integer> skippedByCity = new LinkedHashMap<>();
        for (int i = 0; i < cityRows.size(); i++) {
            int cr = cityRows.get(i);
            String cityLabel = nvl(cellText(sheet.getRow(cr).getCell(0)));
@@ -5682,8 +6427,16 @@
                if (!a.matches("\\d+(\\.\\d+)?")) continue;
                if (b.isEmpty()) continue;
                InvestmentProject p = matchInvest(projects, b, city, "物流园区", used);
                p = preferMonthlyCandidate(projects, p, mByProject, "物流园区", used);
                InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
                if (p == null || m == null) { kept++; continue; }
                if (p == null || m == null) {
                    kept++;
                    // 2026-09-22:不再静默结转上月,未覆盖的行必须留痕
                    skipped.add("第" + (r + 1) + "行 " + city + " " + b
                            + (p == null ? " → 库内无匹配项目" : " → 库内无 " + period + " 月度数据"));
                    skippedByCity.merge(city, 1, Integer::sum);
                    continue;
                }
                used.add(p.getId());
                // 口径同客运站明细表:只覆盖 H 自开始建设累计 / J 自年初累计 / K 本月完成 / M 形象进度
                if (m.getStartCum() != null) putNum(row, 7, m.getStartCum());
@@ -5796,7 +6549,7 @@
                putNum(row, 2, s[3] == 0 ? null : s[3]);
                putNum(row, 3, s[4] == 0 ? null : s[4]);
                double b = numOf(row.getCell(1));
                putNum(row, 4, b > 0 ? round(s[3] / b * 100, 4) : null);
                putNum(row, 4, b > 0 ? s[3] / b * 100 : null);
                cT += s[3];
                dT += s[4];
            }
@@ -5805,7 +6558,7 @@
                putNum(tot, 2, cT == 0 ? null : cT);
                putNum(tot, 3, dT == 0 ? null : dT);
                double b = numOf(tot.getCell(1));
                putNum(tot, 4, b > 0 ? round(cT / b * 100, 4) : null);
                putNum(tot, 4, b > 0 ? cT / b * 100 : null);
            }
        }
        try {
@@ -5814,6 +6567,7 @@
            log.warn("物流站场投资明细公式求值失败: {}", e.getMessage());
        }
        log.info("物流站场投资明细 {}:写入 {} 行,结转母版原值 {} 行", period, filled, kept);
        logSkippedRows("物流站场投资明细", period, skipped, skippedByCity);
        return toBytesHssf(wb);
    }
@@ -6026,7 +6780,7 @@
        for (InvestmentMonthly m : monthlies) provinceYearPlan += nz(m.getYearPlan());
        HSSFRow rowOther = s1.getRow(9);
        for (int c = 1; c <= 5; c++) hssfNum(rowOther, c, null);
        hssfNum(rowOther, 5, provinceYearPlan == 0 ? null : round(provinceYearPlan / 10000, 2));
        hssfNum(rowOther, 5, provinceYearPlan == 0 ? null : provinceYearPlan / 10000);
        // ---- sheet2 规上项目表:表头年份 + 合计 + 项目行 ----
        HSSFSheet s2 = wb.getSheetAt(1);
        hssfText(s2.getRow(0), 0, year + "年交通建设项目储备情况(规模以上项目)");
@@ -6108,6 +6862,40 @@
        return full != null && RegionUtil.cityList().contains(full);
    }
    /**
     * 同名(归一化后一致)多档时,优先选「当月有数据」的那一档。
     * 背景(2026-09-22):十堰「快递物流产业园二期」在库内有两档 id=154(竣工 2026,月度只到 7 月)与
     * id=181(竣工 2029,有 8 月),模板行名前缀与 154 一致 → 导出取到 154 的 7 月值。
     */
    private InvestmentProject preferMonthlyCandidate(List<InvestmentProject> pool, InvestmentProject p,
                                                    Map<Long, InvestmentMonthly> mByProject,
                                                    String category, Set<Long> excludeIds) {
        if (p == null || mByProject.containsKey(p.getId())) return p;
        String norm = normInvestName(p.getProjectName());
        if (norm.isEmpty()) return p;
        InvestmentProject better = null;
        for (InvestmentProject c : pool) {
            if (c == null || c.getId().equals(p.getId())) continue;
            if (excludeIds != null && excludeIds.contains(c.getId())) continue;
            if (category != null && !category.equals(c.getCategory())) continue;
            if (!mByProject.containsKey(c.getId())) continue;
            if (!norm.equals(normInvestName(c.getProjectName()))) continue;
            if (better == null || c.getId() > better.getId()) better = c;
        }
        return better != null ? better : p;
    }
    /** 未按当月数据覆盖的行必须留痕:逐行列出行号+市州+项目名,避免「静默结转上期值」再次发生 */
    private void logSkippedRows(String reportName, String period, List<String> skipped, Map<String, Integer> byCity) {
        if (skipped == null || skipped.isEmpty()) return;
        log.warn("{} {}:{} 行未按当月数据覆盖(结转母版原值),需人工核对源文件/库内数据:",
                reportName, period, skipped.size());
        int limit = Math.min(skipped.size(), 50);
        for (int i = 0; i < limit; i++) log.warn("  [未覆盖] {}", skipped.get(i));
        if (skipped.size() > limit) log.warn("  …另有 {} 行未逐行列出", skipped.size() - limit);
        if (byCity != null && !byCity.isEmpty()) log.warn("  未覆盖行按市州统计:{}", byCity);
    }
    /** 在候选项目池中按名称匹配 DB 项目(可选 市州/类别 过滤 + 县区前缀消歧),未命中返回 null */
    private InvestmentProject matchInvest(List<InvestmentProject> pool, String name, String cityFull, String category) {
        return matchInvest(pool, name, cityFull, category, null);
@@ -6156,7 +6944,8 @@
        if (name == null) return "";
        String s = name.trim();
        s = s.replaceAll("[((]", "").replaceAll("[))]", "");
        s = s.replaceAll("[·•—-_\\-]", "");
        // 2026-09-22:补充半/全角句点、顿号、分号等(库内「中国供销.公安商贸物流园」因半角句点匹配不上模板行)
        s = s.replaceAll("[·•—-_\\-..。、,,;;::]", "");
        s = s.replaceAll("[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ]", "I");
        s = s.replaceAll("^(湖北省|武汉市|黄石市|十堰市|宜昌市|襄阳市|鄂州市|荆门市|孝感市|荆州市|黄冈市|咸宁市|随州市|恩施州|仙桃市|潜江市|天门市|神农架林区)", "");
        s = s.replaceAll("^(?:[\\u4e00-\\u9fa5]{2,4}(?:县|市|区))", "");
@@ -6258,6 +7047,8 @@
    private double nz(Double v) {
        return v == null ? 0.0 : v;
    }
    /** 数值格式里 3 位及以上的小数部分(如 0.0000 / 0.000_ ) */
    private static final java.util.regex.Pattern DEC_MORE_THAN_TWO = java.util.regex.Pattern.compile("\\.0{3,}");
    /** 生成表数值列统一样式:保留全精度数值,显示两位小数;整数值与 %/日期/科学计数等既有样式不改变 */
    private void applyTwoDecimalFormat(org.apache.poi.ss.usermodel.Workbook wb) {
        if (wb == null) return;
@@ -6274,24 +7065,32 @@
                        CellType ct = cell.getCellType();
                        if (ct != CellType.NUMERIC && ct != CellType.FORMULA) continue;
                        if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) continue;
                        boolean integral = false;
                        if (ct == CellType.NUMERIC) {
                            integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue();
                        } else if (cell.getCachedFormulaResultType() == CellType.NUMERIC) {
                            integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue();
                        }
                        if (integral) continue; // 整数不显示 .00(含公式结果为整数的格)
                        org.apache.poi.ss.usermodel.CellStyle cs = cell.getCellStyle();
                        if (cs == null) continue;
                        String f = cs.getDataFormatString();
                        if (f == null) f = "";
                        if (f.contains("%") || f.contains("E") || f.contains("0.00")) continue;
                        boolean plain = f.isEmpty() || "General".equals(f) || "general".equals(f)
                            || "0".equals(f) || "#,##0".equals(f) || "0.0".equals(f) || "#,##0.0".equals(f);
                        if (!plain) continue;
                        if (f.contains("%") || f.contains("E") || f.contains("@")) continue;
                        String normalized = null;
                        if (DEC_MORE_THAN_TWO.matcher(f).find()) {
                            // 母版个别格带 3~4 位小数格式(如 0.000_ 、0.0000_);[Red]\(0.0000\)):数值仍全精度,
                            // 展示统一收敛到两位。这类格本就按数值格式展示,故不受「整数不显示 .00」保护影响。
                            normalized = DEC_MORE_THAN_TWO.matcher(f).replaceAll(".00");
                        } else {
                            boolean integral = false;
                            if (ct == CellType.NUMERIC) {
                                integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue();
                            } else if (cell.getCachedFormulaResultType() == CellType.NUMERIC) {
                                integral = Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue();
                            }
                            if (integral) continue; // 整数不显示 .00(含公式结果为整数的格)
                            if (f.contains("0.00")) continue;
                            boolean plain = f.isEmpty() || "General".equals(f) || "general".equals(f)
                                || "0".equals(f) || "#,##0".equals(f) || "0.0".equals(f) || "#,##0.0".equals(f);
                            if (!plain) continue;
                        }
                        org.apache.poi.ss.usermodel.CellStyle ns = wb.createCellStyle();
                        ns.cloneStyleFrom(cs);
                        ns.setDataFormat(fmtTwo);
                        ns.setDataFormat(normalized == null ? fmtTwo : df.getFormat(normalized));
                        cell.setCellStyle(ns);
                    } catch (Exception ignore) {
                        // 个别单元格格式异常不阻塞导出