zhizhijie
2026-09-11 a76a8cce476ceccbc061a3f51681b6090a7d3741
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -29,6 +29,9 @@
import com.trafficaudit.dataimport.mapper.PassengerEnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerIndividualMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
@@ -44,11 +47,13 @@
import org.apache.poi.hssf.usermodel.HSSFSheet;
import org.apache.poi.hssf.usermodel.HSSFWorkbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.openxml4j.util.ZipSecureFile;
import org.openxmlformats.schemas.spreadsheetml.x2006.main.STCellType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
@@ -91,6 +96,8 @@
    private CityBusMonthlyMapper cityBusMapper;
    @Resource
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Resource
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private AuditResultMapper auditResultMapper;
    @Resource
@@ -333,12 +340,7 @@
                    if (resolveSummaryMother(period, mode) == null) {
                        problems.add("docs/生成汇总大表 下缺少可复制的母版汇总表");
                    } else {
                        Map<Integer, Map<String, double[]>> busM = loadCityBusByMonth(yearPrefix, month);
                        Map<Integer, Map<String, double[]>> taxiM = loadCityTaxiByMonth(yearPrefix, month);
                        Map<String, double[]> bc = busM.get(month);
                        Map<String, double[]> tc = taxiM.get(month);
                        if (bc != null && bc.get("全省") != null) addSum(summary, "城市公交客运量(全省当月)", bc.get("全省")[0], "万人");
                        if (tc != null && tc.get("全省") != null) addSum(summary, "巡游出租客运量(全省当月)", tc.get("全省")[0], "万人");
                        summaryWorkbookPreview(period, yearNum, month, summary, problems);
                    }
                } catch (Exception ex) {
                    problems.add(ex.getMessage());
@@ -442,6 +444,118 @@
        return sb.toString();
    }
    /** 汇总整本关键指标预览:货运量/周转量、城市客运、中口径的 当月、1..M 累计、当月同比、累计同比(同比取库内去年同期,缺则显示—) */
    private void summaryWorkbookPreview(String period, int year, int month,
                                        List<Map<String, Object>> summary, List<String> problems) {
        String yearPrefix = year + "-";
        // ---- 货运(量=模板_货运量周转量全省行;周转量=规上规下拆分表全省行) ----
        FreightTurnoverImport ft = loadFreightTurnover(period).get("湖北省");
        Double fm = ft == null ? null : freightMonth(ft, month);
        Double fcum = ft == null ? null : freightCum(ft, month);
        Double fy = ft == null ? null : freightYoy(ft, month);
        Double fcy = ft == null ? null : freightCumYoy(ft, month);
        addKpi(summary, "全省货运量", fm, fcum, fy, fcy, "万吨");
        if (fm != null && fy == null) {
            problems.add((year - 1) + "-" + String.format("%02d", month) + " 同期货运量数据未导入库,货运量当月/累计同比暂不显示,待 2025 年定稿数据导入后自动出现");
        }
        ScaleSplitTransport tm = loadProvinceMonthMap(period).get(month);
        ScaleSplitTransport tc = getProvinceCumulative(period);
        Double tvM = tm == null ? null : tm.getTotalTurnover();
        Double tvC = tc == null ? null : tc.getTotalTurnover();
        Double tvMY = tm == null ? null : getYoyMetric(tm, "turnover", "total");
        Double tvCY = tc == null ? null : getYoyMetric(tc, "turnover", "total");
        addKpi(summary, "全省货物周转量", tvM, tvC, tvMY, tvCY, "万吨公里");
        if (fm == null) problems.add("模板_货运量周转量缺 " + period + " 当月数据,货运量仅能提供截至最近导入月的累计");
        if (tvM == null) problems.add("规上规下拆分缺 " + period + " 当月数据,周转量/规上规下当月缺,累计为截至最近导入月");
        // ---- 城市客运(公交+出租+轨道+轮渡 全省小计;指标位 0/2/4/6=客运,1/3/5/7=周转) ----
        double[] cumM = cityCumProv(yearPrefix, month);
        double[] cumPrev = cityCumProv(yearPrefix, month - 1);
        double[] monthVec = diff8(cumM, cumPrev);
        double cityPaxM = sumIdx(monthVec, new int[]{0, 2, 4, 6});
        double cityTurnM = sumIdx(monthVec, new int[]{1, 3, 5, 7});
        double cityPaxC = sumIdx(cumM, new int[]{0, 2, 4, 6});
        double cityTurnC = sumIdx(cumM, new int[]{1, 3, 5, 7});
        double[] lastCumM = cityCumProv((year - 1) + "-", month);
        double[] lastCumPrev = cityCumProv((year - 1) + "-", month - 1);
        double[] lastVec = diff8(lastCumM, lastCumPrev);
        double lastPaxM = sumIdx(lastVec, new int[]{0, 2, 4, 6});
        double lastPaxC = sumIdx(lastCumM, new int[]{0, 2, 4, 6});
        double lastTurnM = sumIdx(lastVec, new int[]{1, 3, 5, 7});
        double lastTurnC = sumIdx(lastCumM, new int[]{1, 3, 5, 7});
        addKpiRaw(summary, "城市客运量", cityPaxM, cityPaxC, lastPaxM, lastPaxC, "万人次");
        addKpiRaw(summary, "城市客运周转量", cityTurnM, cityTurnC, lastTurnM, lastTurnC, "万人公里");
        // ---- 中口径(公路旅客企业 H203-1 全省,单位换算 /10000) ----
        Map<Integer, Map<String, PassengerAgg>> data = loadPassengerAggMap();
        double paxM = 0.0, paxC = 0.0, turnM = 0.0, turnC = 0.0;
        double lastPaxM2 = 0.0, lastPaxC2 = 0.0, lastTurnM2 = 0.0, lastTurnC2 = 0.0;
        for (int m = 1; m <= month; m++) {
            PassengerAgg agg = aggOf(data, year, m, "湖北省");
            if (agg != null) {
                double pax = agg.passengerTotal / 10000.0;
                double turn = agg.turnoverTotal / 10000.0;
                if (m == month) { paxM = pax; turnM = turn; }
                paxC += pax;
                turnC += turn;
            }
            PassengerAgg last = aggOf(data, year - 1, m, "湖北省");
            if (last != null) {
                double lp = last.passengerTotal / 10000.0;
                double lt = last.turnoverTotal / 10000.0;
                if (m == month) { lastPaxM2 = lp; lastTurnM2 = lt; }
                lastPaxC2 += lp;
                lastTurnC2 += lt;
            }
        }
        addKpiRaw(summary, "中口径客运量", paxM, paxC, lastPaxM2, lastPaxC2, "万人次");
        addKpiRaw(summary, "中口径周转量", turnM, turnC, lastTurnM2, lastTurnC2, "万人公里");
        if (paxM <= 0 && paxC <= 0 && cityPaxC <= 0 && fm == null) {
            problems.add("货运量周转量/规上规下/公路旅客/城市客运当月数据均缺失");
        }
        if (cityPaxC > 0 && lastPaxC <= 0) {
            problems.add((year - 1) + "-" + String.format("%02d", month) + " 同期城市客运数据未导入库,城市客运同比暂不显示,待 2025 年定稿数据导入后自动出现");
        }
        if (paxC > 0 && lastPaxC2 <= 0) {
            problems.add((year - 1) + "-" + String.format("%02d", month) + " 同期公路旅客数据未导入库,中口径同比暂不显示,待 2025 年定稿数据导入后自动出现");
        }
    }
    /** 城市客运全省累计向量(8 指标:公交客运/周转、出租客运/周转、轨道客运/周转、轮渡客运/周转) */
    private double[] cityCumProv(String yearPrefix, int month) {
        if (month <= 0) return new double[8];
        return loadCityPassengerCumulative(yearPrefix, month).getOrDefault("全省", new double[8]);
    }
    private double[] diff8(double[] a, double[] b) {
        double[] out = new double[8];
        for (int i = 0; i < 8; i++) out[i] = a[i] - b[i];
        return out;
    }
    private double sumIdx(double[] arr, int[] idx) {
        double sum = 0.0;
        for (int i : idx) sum += (i < arr.length ? arr[i] : 0.0);
        return sum;
    }
    /** 百分比同比(值已为比率 0.xx):输出四行 当月/累计/当月同比/累计同比 */
    private void addKpi(List<Map<String, Object>> summary, String name, Double m, Double cum, Double yoyM, Double yoyC, String unit) {
        if (m != null) addSum(summary, name + "·当月", round(m, 2), unit);
        if (cum != null) addSum(summary, name + "·累计", round(cum, 2), unit);
        if (yoyM != null) addSum(summary, name + "·当月同比", round(yoyM * 100.0, 1), "%");
        if (yoyC != null) addSum(summary, name + "·累计同比", round(yoyC * 100.0, 1), "%");
    }
    /** 原始数值版(月/累计为 0 视为无数据不出行) */
    private void addKpiRaw(List<Map<String, Object>> summary, String name, double m, double cum, double lastM, double lastC, String unit) {
        if (m > 0) addSum(summary, name + "·当月", round(m, 2), unit);
        if (cum > 0) addSum(summary, name + "·累计", round(cum, 2), unit);
        if (m > 0 && lastM > 0) addSum(summary, name + "·当月同比", round((m - lastM) / lastM * 100.0, 1), "%");
        if (cum > 0 && lastC > 0) addSum(summary, name + "·累计同比", round((cum - lastC) / lastC * 100.0, 1), "%");
    }
    private void addSum(List<Map<String, Object>> summary, String label, Double value, String unit) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("label", label);
@@ -456,7 +570,7 @@
        Map<String, Double> h2032Cum = loadH2032FreightCumulative(period);
        ScaleSplitTransport provCum = getProvinceCumulative(period);
        Double total = freightCum(ft.get("湖北省"), month);
        Double totalWan = total == null ? null : round(total / 10000.0, 4);
        Double totalWan = total == null ? null : round(total, 4); // 模板列已是万吨,不再 ÷10000
        double above = 0.0;
        for (Double v : h2032Cum.values()) if (v != null) above += v;
        double aboveWan = round(above / 10000.0, 4);
@@ -3199,17 +3313,361 @@
     * 保留全部页签/版式/公式/2025 年缓存值,仅把表头年份动态化为目标年;数据填充按 P2 起逐步接入。
     */
    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
        return exportSummaryWorkbook(period, mode, null);
    }
    /** 生成_道路运输量汇总表.xlsx(P0-P1 骨架,支持 toMonth 裁剪):
     *  以《YYYY年M月道路运输量汇总表.xlsx》(M 由 period 决定)为母版,按同期备份母版还原月度块;
     *  toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头,
     *  累计与累计同比公式改为按 toMonth 月重算),用于“先生成 1-6 月、1-7 月扩列”验证。 */
    public byte[] exportSummaryWorkbook(String period, String mode, Integer toMonth) throws Exception {
        int year = Integer.parseInt(period.contains("-") ? period.split("-")[0] : period);
        int month = period.contains("-") ? monthOf(period, mode) : 12;
        File template = resolveSummaryMother(period, mode);
        if (template == null) {
            throw new RuntimeException("未找到汇总表母版《" + year + "年" + month + "月道路运输量汇总表.xlsx》(docs/生成汇总大表)");
        }
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        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)) {
            File donor = resolveSummaryDonor(year, month);
            if (donor != null) {
                try (InputStream din = new FileInputStream(donor); XSSFWorkbook dwb = new XSSFWorkbook(din)) {
                    int restored = restoreSummaryMonthlyBlocks(wb, dwb);
                    log.info("汇总工作簿月度块还原完成,补齐/覆盖单元格数={}(母版:{},备份母版:{})", restored, template.getName(), donor.getName());
                }
            } else {
                log.warn("汇总工作簿未找到同期备份母版(_备份_{}年{}月…),输出将保持母版现状(可能为 1-4 月骨架)", year, month);
            }
            if (toMonth != null && toMonth > 0 && toMonth < month) {
                int trimmed = trimSummaryMonthlySheets(wb, toMonth);
                log.info("汇总工作簿裁剪至 {} 月完成,清空/改写单元格数={}", toMonth, trimmed);
            }
            dynamicSummaryYear(wb, year);
            cleanSummarySheetPresentation(wb); // 取消各页筛选、还原隐藏行(否则“班线包车”等页看起来像缺数据)
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);
            int monthFormulaFilled = summaryWorkbookFiller.ensureMonthFormulas(wb, year, effMonth);
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int widthApplied = applySummaryReferenceLayout(wb);
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "刷新排名页数值格数={},按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, rankRefreshed, widthApplied, dbProblems);
            applyTwoDecimalFormat(wb); // 数值显示两位小数(保留全精度);% / 日期等既有样式不改变
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算
            return toBytes(wb);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /**
     * 版式对齐:按 docs/生成汇总大表/《生成_道路运输量汇总表.xlsx》(用户提供的版式参照)逐列套用列宽与隐藏标记,
     * 使生成件与参照件“格式完全一致”,并让母版没定义宽度的“后续扩列”(如 8 月列)也有与 8 月一致的列宽。
     * 参照件缺失时静默跳过(返回 0),不影响原有导出。
     */
    private int applySummaryReferenceLayout(XSSFWorkbook wb) {
        File ref;
        try {
            ref = resolveSummaryLayoutRef();
        } catch (Exception e) {
            return 0;
        }
        if (ref == null) return 0;
        double savedZipRatio = ZipSecureFile.getMinInflateRatio();
        ZipSecureFile.setMinInflateRatio(0.0001);
        int applied = 0;
        try (InputStream in = new FileInputStream(ref); XSSFWorkbook rwb = new XSSFWorkbook(in)) {
            for (int i = 0; i < rwb.getNumberOfSheets(); i++) {
                org.apache.poi.xssf.usermodel.XSSFSheet rs = rwb.getSheetAt(i);
                if (rs == null) continue;
                org.apache.poi.xssf.usermodel.XSSFSheet ts = wb.getSheet(rs.getSheetName());
                if (ts == null) continue;
                int maxCol = Math.min(usedColumnCount(rs), usedColumnCount(ts) + 4);
                if (maxCol <= 0) continue;
                double[] width = new double[maxCol];
                boolean[] hasWidth = new boolean[maxCol];
                boolean[] hidden = new boolean[maxCol];
                for (org.openxmlformats.schemas.spreadsheetml.x2006.main.CTCols cols : rs.getCTWorksheet().getColsArray()) {
                    for (org.openxmlformats.schemas.spreadsheetml.x2006.main.CTCol col : cols.getColArray()) {
                        long min = col.getMin();
                        long max = col.getMax();
                        for (long c = min; c <= max; c++) {
                            int idx = (int) (c - 1);
                            if (idx < 0 || idx >= maxCol) {
                                if (idx >= maxCol) break;
                                continue;
                            }
                            if (col.isSetWidth()) {
                                width[idx] = col.getWidth();
                                hasWidth[idx] = true;
                            }
                            hidden[idx] = col.isSetHidden() && col.getHidden();
                        }
                    }
                }
                for (int c = 0; c < maxCol; c++) {
                    if (hasWidth[c]) {
                        int w = (int) Math.round(width[c] * 256d);
                        if (ts.getColumnWidth(c) != w) {
                            ts.setColumnWidth(c, w);
                            applied++;
                        }
                    }
                    if (hidden[c] != ts.isColumnHidden(c)) ts.setColumnHidden(c, hidden[c]);
                }
            }
            log.info("汇总工作簿版式对齐《生成_道路运输量汇总表》完成:套用列宽 {} 列(参照件:{})", applied, ref.getName());
        } catch (Exception e) {
            log.warn("汇总工作簿版式对齐失败(忽略,继续按母版版式输出):{}", e.getMessage());
            return 0;
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
        return applied;
    }
    /** 一页已用到的最大列数(0 基计数) */
    private int usedColumnCount(org.apache.poi.ss.usermodel.Sheet sh) {
        int max = 0;
        for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) {
            org.apache.poi.ss.usermodel.Row row = sh.getRow(r);
            if (row == null) continue;
            if (row.getLastCellNum() > max) max = row.getLastCellNum();
        }
        return max;
    }
    // ==================== 汇总工作簿内的排名页刷新 ====================
    /**
     * 汇总工作簿里的「货运量排名 / 货运周转量排名 / 中口径排名」在母版里是上一报表期的静态值,
     * 导出时用独立排名表(《生成_货运量排名》《生成_周转量排名》《生成_中口径排名》,与用户日常核对的口径一致)
     * 的结果刷新数值,避免汇总表里的排名页停留在旧月份。
     * 数值格搬值、公式格按偏移平移公式后写入;目标格本身已是公式的(SUM/RANK/占比/补数)保留不动,打开时按新数据重算。
     */
    private int refreshSummaryRankSheets(XSSFWorkbook wb, String period, String mode) 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), new int[][]{{0, 0}, {0, 10}});
        return n;
    }
    /**
     * 把独立排名表的各个块按标题对齐搬进汇总大表对应页。
     * srcBlocks[i] = 源块 i 的起始 {行, 列}(0 基);目标块按标题顺序(累计块在前、当月块在后)对应。
     * 数值格直接搬值;源侧是公式的(排名/占比等)按目标块偏移整体平移后写入公式,打开时按汇总表自身数据重算。
     */
    private int copyRankValues(XSSFWorkbook dstWb, String dstSheetName, byte[] srcBytes, int[][] srcBlocks) 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();
        try (InputStream sin = new ByteArrayInputStream(srcBytes);
             XSSFWorkbook srcWb = new XSSFWorkbook(sin)) {
            XSSFSheet src = srcWb.getSheetAt(0);
            FormulaEvaluator ev = srcWb.getCreationHelper().createFormulaEvaluator();
            int blocks = Math.min(srcBlocks.length, dstTitles.size());
            for (int b = 0; b < blocks; b++) {
                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 h = rankBlockHeight(src, sr0, sc0);
                int w = rankBlockWidth(src, sr0, sc0);
                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;
                        if (r == 0) {
                            // 标题行:同步块标题文案。母版标题是 inlineStr,POI 的 setCellValue 只写 <v>、
                            // 不更新 <is>,Excel 会继续显示旧月份,必须先 setBlank 清掉再写。
                            Cell dt = dstRow.getCell(dc);
                            if (dt == null) dt = rankCell(dstRow, dc);
                            if (sc.getCellType() == CellType.STRING && dt.getCellType() != CellType.FORMULA) {
                                String text = sc.getStringCellValue();
                                String old = dt.getCellType() == CellType.STRING ? dt.getStringCellValue() : null;
                                if (text != null && !text.equals(old)) {
                                    dt.setBlank();
                                    dt.setCellValue(text);
                                }
                            }
                            continue;
                        }
                        Cell dcCell = dstRow.getCell(dc);
                        if (dcCell != null && dcCell.getCellType() == CellType.FORMULA) continue; // 保留母版自己的公式
                        if (sc.getCellType() == CellType.FORMULA) {
                            // 源侧公式(排名/占比):按目标块偏移平移后原样写入,再按汇总表自身数据求值缓存。
                            // 不直接搬 POI 对源表的求值结果(如 RANK 对手工留空的同比一律返回 1)。
                            String f = sc.getCellFormula();
                            if (f == null || f.isEmpty()) continue;
                            String shifted = shiftFormula(f, colOff, rowOff);
                            if (dcCell == null) dcCell = rankCell(dstRow, dc);
                            dcCell.setCellFormula(shifted);
                            try {
                                dstEv.evaluateFormulaCell(dcCell);
                            } catch (Exception ignore) {
                                // 求值失败不阻塞导出:打开时由 Excel/WPS 重算
                            }
                            copied++;
                            continue;
                        }
                        Double v = rankNumeric(sc, ev);
                        if (v == null) continue;
                        if (dcCell == null) dcCell = rankCell(dstRow, dc);
                        dcCell.setCellValue(v);
                        copied++;
                    }
                }
            }
        }
        return copied;
    }
    /** 目标块缺格时新建,并沿用同行左邻格样式(防新格丢边框/百分数格式) */
    private Cell rankCell(Row row, int col) {
        Cell c = row.createCell(col);
        Cell left = col > 0 ? row.getCell(col - 1) : null;
        if (left != null) c.setCellStyle(left.getCellStyle());
        return c;
    }
    /** 公式内所有 A1 引用整体平移:列 +dCol、行 +dRow(SUM/RANK 等函数名后接括号不会被匹配) */
    private String shiftFormula(String formula, int dCol, int dRow) {
        if (formula == null || (dCol == 0 && dRow == 0)) return formula;
        java.util.regex.Matcher m = java.util.regex.Pattern
                .compile("(?<![A-Za-z0-9_$])([$]?)([A-Z]{1,3})([$]?)([0-9]+)")
                .matcher(formula);
        StringBuffer sb = new StringBuffer();
        while (m.find()) {
            String col = m.group(2);
            int idx = 0;
            for (int i = 0; i < col.length(); i++) idx = idx * 26 + (col.charAt(i) - 'A' + 1);
            int nidx = Math.max(1, idx + dCol);
            StringBuilder nc = new StringBuilder();
            while (nidx > 0) {
                int rem = (nidx - 1) % 26;
                nc.insert(0, (char) ('A' + rem));
                nidx = (nidx - 1) / 26;
            }
            int nrow = Math.max(1, Integer.parseInt(m.group(4)) + dRow);
            m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(m.group(1) + nc + m.group(3) + nrow));
        }
        m.appendTail(sb);
        return sb.toString();
    }
    /** 排名页里的“块标题”单元格(含“全省分市州”的说明文字),按行、列顺序返回 */
    private List<XSSFCell> rankTitleCells(Sheet sh) {
        List<XSSFCell> out = new ArrayList<>();
        for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            for (int c = row.getFirstCellNum(); c < row.getLastCellNum(); c++) {
                Cell cell = row.getCell(c);
                if (cell == null || cell.getCellType() != CellType.STRING) continue;
                String t = cell.getStringCellValue();
                if (t != null && t.contains("全省分市州")) out.add((XSSFCell) cell);
            }
        }
        return out;
    }
    /** 块高度:从起始行向下直到整行为空(在块列范围内) */
    private int rankBlockHeight(Sheet sh, int r0, int c0) {
        int h = 0;
        for (int r = r0; r <= sh.getLastRowNum(); r++) {
            Row row = sh.getRow(r);
            boolean any = false;
            if (row != null) {
                int last = row.getLastCellNum();
                for (int c = c0; c < last; c++) {
                    if (rankHasContent(row.getCell(c))) { any = true; break; }
                }
            }
            if (!any) break;
            h++;
        }
        return h;
    }
    /** 块宽度:从起始列向右直到整列为空(在块行范围内,扫描上限 40 列) */
    private int rankBlockWidth(Sheet sh, int r0, int c0) {
        int w = 0;
        for (int c = c0; c < c0 + 40; c++) {
            boolean any = false;
            for (int r = r0; r < r0 + 30; r++) {
                Row row = sh.getRow(r);
                if (row != null && rankHasContent(row.getCell(c))) { any = true; break; }
            }
            if (!any) break;
            w++;
        }
        return w;
    }
    private boolean rankHasContent(Cell c) {
        if (c == null) return false;
        CellType t = c.getCellType();
        if (t == CellType.BLANK) return false;
        if (t == CellType.STRING) {
            String v = c.getStringCellValue();
            return v != null && !v.trim().isEmpty();
        }
        return true;
    }
    /** 取单元格数值(公式取求值结果,纯数字文本按数值解析),取不到返回 null */
    private Double rankNumeric(Cell c, FormulaEvaluator ev) {
        try {
            CellType t = c.getCellType();
            if (t == CellType.NUMERIC) return c.getNumericCellValue();
            if (t == CellType.FORMULA) {
                org.apache.poi.ss.usermodel.CellValue cv = ev.evaluate(c);
                return cv != null && cv.getCellType() == CellType.NUMERIC ? cv.getNumberValue() : null;
            }
            if (t == CellType.STRING) {
                String v = c.getStringCellValue();
                if (v == null) return null;
                String x = v.replace(",", "").trim();
                if (x.isEmpty()) return null;
                try { return Double.parseDouble(x); } catch (NumberFormatException ignore) { return null; }
            }
        } catch (Exception ignore) {
            return null;
        }
        return null;
    }
    /** 定位版式参照件 docs/生成汇总大表/生成_道路运输量汇总表.xlsx(优先级同母版:配置目录 → user.dir 相对 → 上级) */
    private File resolveSummaryLayoutRef() {
        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
        };
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File f = new File(root, "生成_道路运输量汇总表.xlsx");
            if (f.isFile()) return f;
        }
        return null;
    }
    /** 汇总表母版定位:仅精确匹配 {year}年{month}月…(P0-P1 骨架期不允许跨月/跨年静默回退,
@@ -3472,6 +3930,175 @@
            if (a[5] <= 0 && a[2] > 0) a[5] = round(a[3] / a[2], 2);
        }
        return map;
    }
    /** 定位同期备份母版(清理前的完整月母版),用于把骨架母版缺失的 5-7 月月度块按备份还原;
     *  与 resolveAnyTemplate 相同策略:在配置目录、user.dir 相对目录、上级目录三个候选根中扫描 */
    private File resolveSummaryDonor(int year, int month) {
        String rel = summaryTemplateDir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {
            summaryTemplateDir,
            System.getProperty("user.dir") + "/" + rel,
            System.getProperty("user.dir") + "/../" + rel
        };
        for (String root : roots) {
            if (root == null || root.trim().isEmpty()) continue;
            File dir = new File(root);
            File[] files = dir.listFiles((d, name) ->
                name.startsWith("_备份_") && name.contains(year + "年" + month + "月") && name.contains("道路运输量汇总表"));
            if (files != null && files.length > 0) return files[0];
        }
        return null;
    }
    /** 按同期备份母版还原月度块:凡备份有内容而母版同格缺失/内容不同(清理时被裁掉的 5-7 月值与累计公式)的单元格,整格回填并覆盖 */
    private int restoreSummaryMonthlyBlocks(XSSFWorkbook wb, XSSFWorkbook donor) {
        int restored = 0;
        for (int i = 0; i < donor.getNumberOfSheets(); i++) {
            Sheet ds = donor.getSheetAt(i);
            if (ds == null) continue;
            Sheet bs = wb.getSheet(ds.getSheetName());
            if (bs == null) continue;
            // 中口径排名页 A..H 陈旧左块已于 2026-09-09 拍板整块删除:该区域永不从备份母版回填,避免“生成又复活”
            boolean skipMidRankLeftBlock = "中口径排名".equals(ds.getSheetName());
            for (int r = ds.getFirstRowNum(); r <= ds.getLastRowNum(); r++) {
                Row dr = ds.getRow(r);
                if (dr == null) continue;
                Row br = bs.getRow(r);
                for (Cell dc : dr) {
                    if (dc == null) continue;
                    if (skipMidRankLeftBlock && dc.getColumnIndex() < 8) continue;
                    CellType t = dc.getCellType();
                    if (t == CellType.BLANK) continue;
                    String dtx = summaryCellText(dc);
                    if (dtx == null || dtx.isEmpty()) continue;
                    Cell bc = (br == null) ? null : br.getCell(dc.getColumnIndex());
                    if (bc != null && dtx.equals(summaryCellText(bc))) continue;
                    Row tr = (bc != null) ? br : bs.getRow(r);
                    if (tr == null) tr = bs.createRow(r);
                    Cell tc = (bc != null) ? bc : tr.getCell(dc.getColumnIndex());
                    if (tc == null) tc = tr.createCell(dc.getColumnIndex());
                    tc.setBlank();
                    applyDonorNumberFormat(wb, tc, dc);
                    if (t == CellType.FORMULA) {
                        String f = dc.getCellFormula();
                        if (f != null) tc.setCellFormula(f);
                    } else if (t == CellType.STRING) {
                        tc.setCellValue(dc.getStringCellValue());
                    } else if (t == CellType.NUMERIC) {
                        tc.setCellValue(dc.getNumericCellValue());
                    } else if (t == CellType.BOOLEAN) {
                        tc.setCellValue(dc.getBooleanCellValue());
                    }
                    restored++;
                }
            }
        }
        return restored;
    }
    /** 还原回填时同步 donor(已做版式统一)的数字格式,保证同比/数值/排名格显示格式与母版一致 */
    private void applyDonorNumberFormat(XSSFWorkbook wb, Cell dst, Cell src) {
        if (wb == null || dst == null || src == null) return;
        try {
            String fmt = src.getCellStyle().getDataFormatString();
            if (fmt == null || fmt.isEmpty() || "General".equalsIgnoreCase(fmt)) return;
            org.apache.poi.ss.usermodel.DataFormat df = wb.createDataFormat();
            short idx = df.getFormat(fmt);
            CellStyle cur = dst.getCellStyle();
            if (cur != null && idx == cur.getDataFormat()) return;
            CellStyle ns = wb.createCellStyle();
            if (cur != null) ns.cloneStyleFrom(cur);
            ns.setDataFormat(idx);
            dst.setCellStyle(ns);
        } catch (Exception ignore) {
            // 个别格式异常不阻塞还原
        }
    }
    /** 单元格内容文本化(公式带 = 前缀),用于跨簿比对 */
    private String summaryCellText(Cell c) {
        if (c == null) return null;
        switch (c.getCellType()) {
            case FORMULA: {
                String f = c.getCellFormula();
                return f == null ? "" : "=" + f;
            }
            case STRING:
                return c.getStringCellValue();
            case NUMERIC:
                return Double.toString(c.getNumericCellValue());
            case BOOLEAN:
                return Boolean.toString(c.getBooleanCellValue());
            default:
                return "";
        }
    }
    /** 把 8 个台账/合成页裁剪到 keepMonths 月:
     *  月度值列 2026 年 m 月 = 第 3+2*(m-1) 列(C/E/G/I/K/M/O),同比列为其后一列;
     *  累计 Q 列 = C+E+...+O,累计同比 R 列分母 = 2025 年同月列 T/V/X/Z/AB/AD/AF。
     *  裁剪 = 数据行(含累计公式且任一 2026 月度列有内容)中 keepMonths+1..7 月的当月值/同比单元格清空(表头保留),
     *  并把该行累计公式去掉对应 +列,累计同比分母去掉对应 2025 列。 */
    private int trimSummaryMonthlySheets(XSSFWorkbook wb, int keepMonths) {
        String[] scope = {" 货运", "班线包车", "城市客运", "公交", "出租", "网约车", "轨道、轮渡", "中口径明细"};
        java.util.Set<String> scopeSet = new java.util.HashSet<>();
        for (String s : scope) scopeSet.add(s);
        int touched = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet sh = wb.getSheetAt(i);
            if (sh == null || !scopeSet.contains(sh.getSheetName())) continue;
            for (int r = sh.getFirstRowNum(); r <= sh.getLastRowNum(); r++) {
                Row row = sh.getRow(r);
                if (row == null) continue;
                Cell q = row.getCell(16); // Q 列 = 累计
                if (q == null || q.getCellType() != CellType.FORMULA) continue;
                boolean hasMonthly = false;
                for (int m = 1; m <= 7 && !hasMonthly; m++) {
                    Cell v = row.getCell(2 + 2 * (m - 1)); // C,E,G,I,K,M,O
                    if (v != null && v.getCellType() != CellType.BLANK) hasMonthly = true;
                }
                if (!hasMonthly) continue; // 排除页首/页中跨页签校验公式行(无月度值)
                int excelRow = r + 1; // 公式内引用为 Excel 1-based 行号
                for (int m = keepMonths + 1; m <= 7; m++) {
                    int valueCol = 2 + 2 * (m - 1);     // 2026 当月值列
                    int yoyCol = valueCol + 1;          // 当月同比列
                    String vLetter = colLetter(valueCol);
                    Cell vc = row.getCell(valueCol);
                    if (vc != null) {
                        vc.setBlank();
                        touched++;
                    }
                    Cell yc = row.getCell(yoyCol);
                    if (yc != null) {
                        yc.setBlank();
                        touched++;
                    }
                    String fq = q.getCellFormula();
                    if (fq != null && fq.contains("+" + vLetter + excelRow)) {
                        q.setCellFormula(fq.replace("+" + vLetter + excelRow, ""));
                        touched++;
                    }
                    Cell rc = row.getCell(17); // R 列 = 累计同比
                    if (rc != null && rc.getCellType() == CellType.FORMULA) {
                        String fr = rc.getCellFormula();
                        int lastCol = 19 + 2 * (m - 1); // 2025 同月列 T..AF(0-based:T=19,AF=31)
                        String lLetter = colLetter(lastCol);
                        String txt = fr;
                        if (txt != null && txt.contains("," + lLetter + excelRow)) {
                            txt = txt.replace("," + lLetter + excelRow, "");
                            touched++;
                        }
                        if (txt != null && txt.contains("+" + lLetter + excelRow)) {
                            txt = txt.replace("+" + lLetter + excelRow, "");
                            touched++;
                        }
                        if (txt != null && !txt.equals(fr)) rc.setCellFormula(txt);
                    }
                }
            }
        }
        return touched;
    }
    /** 汇总工作簿年份动态化:仅把 2026年 前缀替换为目标年(2025/2024 参考列不动) */
@@ -4574,7 +5201,13 @@
                        CellType ct = cell.getCellType();
                        if (ct != CellType.NUMERIC && ct != CellType.FORMULA) continue;
                        if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) continue;
                        if (ct == CellType.NUMERIC && Math.rint(cell.getNumericCellValue()) == cell.getNumericCellValue()) 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();
@@ -4595,6 +5228,28 @@
        }
    }
    /** 汇总表导出后按内容自动加宽列宽(只加宽不缩窄;跳过合并单元格标题,防长标题把单列撑爆) */
    /** 汇总大表输出前清理母版残留下来的筛选与行隐藏:筛选箭头/隐藏行会让整页看起来“缺数据” */
    private void cleanSummarySheetPresentation(org.apache.poi.ss.usermodel.Workbook wb) {
        if (wb == null) return;
        for (int s = 0; s < wb.getNumberOfSheets(); s++) {
            org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(s);
            if (sh == null) continue;
            if (sh instanceof org.apache.poi.xssf.usermodel.XSSFSheet) {
                org.apache.poi.xssf.usermodel.XSSFSheet xs = (org.apache.poi.xssf.usermodel.XSSFSheet) sh;
                if (xs.getCTWorksheet().isSetAutoFilter()) xs.getCTWorksheet().unsetAutoFilter();
                for (org.apache.poi.ss.usermodel.Row row : xs) {
                    if (row == null) continue;
                    org.apache.poi.xssf.usermodel.XSSFRow xr = (org.apache.poi.xssf.usermodel.XSSFRow) row;
                    if (xr.getCTRow().isSetHidden()) xr.getCTRow().unsetHidden();
                }
            } else {
                sh.setAutoFilter(null);
            }
        }
    }
    private byte[] toBytes(XSSFWorkbook wb) throws Exception {
        applyTwoDecimalFormat(wb);
        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {