traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -34,6 +34,7 @@
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
import com.trafficaudit.reportexport.layout.SummaryLayoutExpander;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
@@ -101,6 +102,8 @@
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Resource
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private SummaryLayoutExpander summaryLayoutExpander;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
@@ -3622,6 +3625,46 @@
        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 月之后需新开一本,跨年规则另行处理");
        }
        File mother = resolveSummaryMother(period, "month");
        if (mother == null) {
            throw new RuntimeException("未找到汇总表母版(docs/生成汇总大表):" + year + "年" + month + "月道路运输量汇总表.xlsx");
        }
        try (InputStream in = new FileInputStream(mother)) {
            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
            log.info("汇总大表扩列完成:{}年{}月母版 -> {}年{}月版式(只做版式、数据留空)", year, month, year, month + 1);
            return out;
        }
    }
    /** 只修正当期母版里 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 裁剪):
     *  以《YYYY年M月道路运输量汇总表.xlsx》(M 由 period 决定)为母版,按同期备份母版还原月度块;
     *  toMonth 非空且小于 M 时,把 8 个台账/合成页裁到 toMonth 月(toMonth+1..M 的当月值/同比列清空、保留表头,
@@ -4085,9 +4128,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 月)及对应同比;
@@ -4098,10 +4140,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;
@@ -4130,22 +4172,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);
@@ -4168,10 +4211,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;
@@ -4181,9 +4224,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++) {
@@ -4192,12 +4236,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);
@@ -4214,15 +4258,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);
@@ -4239,16 +4284,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);
@@ -4265,22 +4311,76 @@
                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;
    }
    /** 表头单元格 -> 年*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;
    }
    /** 台账页单元格取数(数字 / 数字文本 / 公式缓存) */
    private double smNum(Cell c) {
        if (c == null) return 0;