zhizhijie
2026-09-19 ade4387f72c876f779b0956295922003464434f0
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -1189,18 +1189,9 @@
    /** 中口径月同比:k=-1 表示总行(班线+公交+出租城乡+网约车城乡 四项之和);无去年数据留空 */
    private void fillMidYoyCell(Cell c, Map<Integer, Map<String, double[][]>> mid, int year, int month,
                                String area, int k, boolean volume) {
        double v = 0.0, lv = 0.0;
        if (k == -1) {
            for (int kk = 1; kk <= 4; kk++) {
                v += midClassVal(mid, year, month, area, kk, volume);
                lv += midClassVal(mid, year - 1, month, area, kk, volume);
            }
        } else {
            v = midClassVal(mid, year, month, area, k, volume);
            lv = midClassVal(mid, year - 1, month, area, k, volume);
        }
        Double y = midMonthYoy(mid, year, month, area, k, volume);
        c.setBlank();
        if (v != 0.0 && lv != 0.0) c.setCellValue(round((v - lv) / lv, 4));
        if (y != null) c.setCellValue(y);
    }
    /** 中口径累计同比列(1..months 月累计;四维齐全) */
@@ -1221,19 +1212,13 @@
            if (blockOffset < 0 || blockOffset % 5 == 0) continue;
            int k = blockOffset % 5;
            String area = areas.get(blockOffset / 5);
            double v = 0.0, lv = 0.0;
            for (int m = 1; m <= months; m++) {
                {
                    v += midClassVal(mid, year, m, area, k, volume);
                    lv += midClassVal(mid, year - 1, m, area, k, volume);
                }
            }
            Double y = midCumYoy(mid, year, area, months, k, volume);
            Row row = sheet.getRow(r0);
            if (row == null) continue;
            Cell c = row.getCell(colIdx);
            if (c == null) c = row.createCell(colIdx);
            c.setBlank();
            if (v != 0.0 && lv != 0.0) c.setCellValue(round((v - lv) / lv, 4));
            if (y != null) c.setCellValue(y);
        }
    }
@@ -2326,6 +2311,56 @@
        return volume ? arr[k][0] : arr[k][1];
    }
    /** B3:登记「该期该市州该维度有源行」。判据是源表有没有这一行,而不是算出来的值是否非 0
     *  ——因为「值为 0」可能是真实业务(如武汉市巡游出租全部为城市内,城际城乡恒为 0)。 */
    private void markMid(Map<Integer, Map<String, boolean[]>> pres, int key, String city, int k) {
        pres.computeIfAbsent(key, kk -> new HashMap<>())
            .computeIfAbsent(city, kk -> new boolean[5])[k] = true;
    }
    /**
     * B3:该月该维度是否「有源」(同比完备性校验用)。
     * k &lt;= 0 表示「总量行」,要求四维(班线/公交/出租/网约车)都有源才算齐全。
     */
    private boolean midMonthHas(Map<Integer, Map<String, double[][]>> mid, int year, int month, String area, int k, boolean volume) {
        Map<String, boolean[]> pm = midPresence.get().get(year * 100 + month);
        if (pm == null) return false;
        boolean[] b = pm.get(area);
        if (b == null) return false;
        if (k <= 0) {
            for (int kk = 1; kk <= 4; kk++) {
                if (!b[kk]) return false;
            }
            return true;
        }
        return k < b.length && b[k];
    }
    /** B3:去年 1..monthCount 月的命中月份数(用于判定同比基数是否齐全) */
    private int midCumHitMonths(Map<Integer, Map<String, double[][]>> mid, int year, String area, int monthCount, int k, boolean volume) {
        int n = 0;
        for (int m = 1; m <= monthCount; m++) {
            if (midMonthHas(mid, year, m, area, k, volume)) n++;
        }
        return n;
    }
    /** B3:当月同比——去年同月基数缺失则留空(缺数据不再当 0 计算) */
    private Double midMonthYoy(Map<Integer, Map<String, double[][]>> mid, int year, int month, String area, int k, boolean volume) {
        if (!midMonthHas(mid, year - 1, month, area, k, volume)) return null;
        double v = midClassVal(mid, year, month, area, k, volume);
        if (v == 0.0) return null;
        return yoyOf(v, midClassVal(mid, year - 1, month, area, k, volume));
    }
    /** B3:累计同比——去年 1..monthCount 各月齐全才计算,缺任何一月一律留空 */
    private Double midCumYoy(Map<Integer, Map<String, double[][]>> mid, int year, String area, int monthCount, int k, boolean volume) {
        if (midCumHitMonths(mid, year - 1, area, monthCount, k, volume) < monthCount) return null;
        double v = midCumClass(mid, year, area, monthCount, k, volume);
        if (v == 0.0) return null;
        return yoyOf(v, midCumClass(mid, year - 1, area, monthCount, k, volume));
    }
    /** 公式单元格替换为缓存数值(避免跨簿引用断链;非数值缓存置空) */
    private void keepCached(Cell c) {
        try {
@@ -2346,7 +2381,20 @@
     * 中口径分类月度数据:年*100+月 -> (市州/全省 -> double[5][2])
     * 维度 0=总量 1=公路班线(h2031+个体) 2=城际城乡公交(cityBus) 3=巡游出租 4=网约车;[客运量(万人), 周转量(万人公里)]
     */
    /** B3:最近一次 loadMidClassMap 的「维度是否有源行」登记表(key -> city -> boolean[5]),用于同比完备性校验 */
    private final ThreadLocal<Map<Integer, Map<String, boolean[]>>> midPresence =
            ThreadLocal.withInitial(HashMap::new);
    /** B4/B5:最近一次 loadMidClassMap 的数据问题清单(线程内,随导出体检输出/写生成件) */
    private final ThreadLocal<java.util.List<String>> midIssues = ThreadLocal.withInitial(java.util.ArrayList::new);
    /** B4/B5:取最近一次中口径数据加载的问题清单(只读副本) */
    public java.util.List<String> getLastMidIssues() { return new java.util.ArrayList<>(midIssues.get()); }
    private Map<Integer, Map<String, double[][]>> loadMidClassMap() {
        midIssues.get().clear();   // B5:本次加载的问题清单
        Map<Integer, Map<String, boolean[]>> pres = new HashMap<>();
        midPresence.set(pres);     // B3:本次加载的维度有源登记
        Map<Integer, Map<String, double[][]>> mid = new HashMap<>();
        for (PassengerEnterpriseMonthly e : passengerMapper.selectList(null)) {
            if (e.getReportPeriod() == null) continue;
@@ -2368,6 +2416,8 @@
                + nz(e.getTurnoverClass4()) + nz(e.getTurnoverCharter())) / 10000.0;
            addMidClass(mid, key, city, 1, pass, turn);
            addMidClass(mid, key, "湖北省", 1, pass, turn);
            markMid(pres, key, city, 1);
            markMid(pres, key, "湖北省", 1);
        }
        for (PassengerIndividualMonthly e : passengerIndividualMapper.selectList(null)) { // 个体并入公路班线/总量(09-03 口径)
            if (e.getReportPeriod() == null) continue;
@@ -2387,6 +2437,8 @@
            double turn = nz(e.getTurnover()) / 10000.0;
            addMidClass(mid, key, city, 1, pass, turn);
            addMidClass(mid, key, "湖北省", 1, pass, turn);
            markMid(pres, key, city, 1);
            markMid(pres, key, "湖北省", 1);
        }
        for (CityBusMonthly b : cityBusMapper.selectList(null)) {
            if (b.getReportPeriod() == null || b.getCity() == null) continue;
@@ -2402,8 +2454,18 @@
            String city = RegionUtil.normalizeCityName(b.getCity());
            if (city == null) continue;
            int key = y * 100 + m;
            addMidClass(mid, key, city, 2, nz(b.getPassengerChengxiang()), nz(b.getTurnoverChengxiang()));
            addMidClass(mid, key, "湖北省", 2, nz(b.getPassengerChengxiang()), nz(b.getTurnoverChengxiang()));
            // 口径统一(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);
            addMidClass(mid, key, "湖北省", 2, cxPass, cxTurn);
            markMid(pres, key, city, 2);
            markMid(pres, key, "湖北省", 2);
        }
        // 维度3:城际城乡巡游出租 = 台账页「出租!总量 − 出租!城市内」(源 city_taxi_monthly)
        for (CityTaxiMonthly t : cityTaxiMapper.selectList(null)) {
@@ -2416,6 +2478,8 @@
            double turn = nz(t.getTurnover()) - nz(t.getTurnoverCity());
            addMidClass(mid, key, city, 3, pass, turn);
            addMidClass(mid, key, "湖北省", 3, pass, turn);
            markMid(pres, key, city, 3);
            markMid(pres, key, "湖北省", 3);
        }
        // 维度4:城际城乡网约车 = 网约车拆分结果(与台账「网约车」页同源同法)
        for (WycOrderMonthly o : wycOrderMapper.selectList(null)) {
@@ -2427,12 +2491,18 @@
            try {
                wr = wycSplitCalc.calc(per);
            } catch (Exception e) {
                continue; // 该期缺订单/pin 或出租源数据,跳过(不影响其它期)
                // B5:不再静默跳过——记录「期 + 原因」并写日志,随导出体检一并输出
                String msg = per + " 网约车城际城乡维度缺失:" + e.getMessage();
                midIssues.get().add(msg);
                log.warn("loadMidClassMap {}", msg);
                continue;
            }
            for (Map.Entry<String, WycSplitCalc.WycMetrics> en : wr.getByCity().entrySet()) {
                WycSplitCalc.WycMetrics wm = en.getValue();
                addMidClass(mid, key, en.getKey(), 4, wm.getSuburbanPax(), wm.getSuburbanTurnover());
                addMidClass(mid, key, "湖北省", 4, wm.getSuburbanPax(), wm.getSuburbanTurnover());
                markMid(pres, key, en.getKey(), 4);
                markMid(pres, key, "湖北省", 4);
            }
        }
        return mid;
@@ -2586,15 +2656,11 @@
                title.getCell(10).setCellValue(currentYear + "年" + maxMonth + "月全省分市州完成道路客运生产情况");
            }
            // 全省行 r3:B3/F3 为 SUM 公式保留;D3/H3 同比填值(0-based 列 3/7)
            setValOrBlank(sheet, 2, 3, yoyOf(midCumClass(mid, currentYear, "湖北省", maxMonth, 0, true),
                midCumClass(mid, currentYear - 1, "湖北省", maxMonth, 0, true)));
            setValOrBlank(sheet, 2, 7, yoyOf(midCumClass(mid, currentYear, "湖北省", maxMonth, 0, false),
                midCumClass(mid, currentYear - 1, "湖北省", maxMonth, 0, false)));
            setValOrBlank(sheet, 2, 3, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, true));
            setValOrBlank(sheet, 2, 7, midCumYoy(mid, currentYear, "湖北省", maxMonth, 0, false));
            // 右块全省行 N3/R3:当月同比(L3/P3 为 SUM 公式保留)
            setValOrBlank(sheet, 2, 13, yoyOf(midClassVal(mid, currentYear, maxMonth, "湖北省", 0, true),
                midClassVal(mid, currentYear - 1, maxMonth, "湖北省", 0, true)));
            setValOrBlank(sheet, 2, 17, yoyOf(midClassVal(mid, currentYear, maxMonth, "湖北省", 0, false),
                midClassVal(mid, currentYear - 1, maxMonth, "湖北省", 0, false)));
            setValOrBlank(sheet, 2, 13, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, true));
            setValOrBlank(sheet, 2, 17, midMonthYoy(mid, currentYear, maxMonth, "湖北省", 0, false));
            // 市州行 r4-20:B/F 累计值、D/H 同比;C/E/G/I 排名公式保留
            List<String> cities = RegionUtil.CITY_LIST;
            for (int i = 0; i < cities.size(); i++) {
@@ -2605,18 +2671,18 @@
                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, yoyOf(pass, lastPass));
                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, yoyOf(turn, lastTurn));
                setValOrBlank(sheet, r0, 7, midCumYoy(mid, currentYear, city, maxMonth, 0, false));
                // 右块当月: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, 13, mPass == 0 ? null : yoyOf(mPass, mLastPass));
                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, 17, mTurn == 0 ? null : yoyOf(mTurn, mLastTurn));
                setValOrBlank(sheet, r0, 17, mTurn == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, city, 0, false));
            }
            recalc(wb);
            return toBytes(wb);
@@ -2678,9 +2744,9 @@
                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, 2, yoyOf(pass, lastPass));
                setValOrBlank(sheet, r0, 2, midCumYoy(mid, currentYear, "湖北省", maxMonth, dims[i], true));
                setValOrBlank(sheet, r0, 5, turn == 0 ? null : round(turn, 4));
                setValOrBlank(sheet, r0, 6, yoyOf(turn, lastTurn));
                setValOrBlank(sheet, r0, 6, midCumYoy(mid, currentYear, "湖北省", maxMonth, dims[i], false));
            }
            // 下半块(当月):标题行9、数据行11-15(0-based 8、10-14)
            Row bottomTitle = sheet.getRow(8);
@@ -2694,9 +2760,9 @@
                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, 2, pass == 0 ? null : yoyOf(pass, lastPass));
                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, 6, turn == 0 ? null : yoyOf(turn, lastTurn));
                setValOrBlank(sheet, r0, 6, turn == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, "湖北省", dims[i], false));
            }
            // 下半块占比公式由样例指向上半块(B$3/F$3),改写为本块总行(B$11/F$11),行12-15
            for (int i = 1; i < names.length; i++) {
@@ -4823,6 +4889,8 @@
            case "investFiveYearLogistics": return investFiveYearLogistics(period, mode);
            case "investBillion": return investBillion(period, mode);
            case "investCounty": return investCounty(period, mode);
            case "investPassengerDetail": return exportInvestPassengerDetail(period, mode);
            case "investLogisticsDetail": return exportInvestLogisticsDetail(period, mode);
            case "cityBusDetail": return exportCityBusDetail(period, mode);
            case "cityTaxiDetail": return exportCityTaxiDetail(period, mode);
            case "cityRailFerryDetail": return exportCityRailFerryDetail(period, mode);
@@ -4848,6 +4916,8 @@
        REPORT_FILE_NAMES.put("investFiveYearLogistics", "生成_十五五规划物流项目进展情况.xls");
        REPORT_FILE_NAMES.put("investBillion", "生成_湖北省(客货站场)亿元投资项目.xls");
        REPORT_FILE_NAMES.put("investCounty", "生成_经济强县交通物流基础设施投资统计报表.xls");
        REPORT_FILE_NAMES.put("investPassengerDetail", "生成_客运站投资明细表.xlsx");
        REPORT_FILE_NAMES.put("investLogisticsDetail", "生成_物流站场投资明细.xls");
        REPORT_FILE_NAMES.put("cityBusDetail", "生成_城市公交客运量分市州明细.xlsx");
        REPORT_FILE_NAMES.put("cityTaxiDetail", "生成_巡游出租客运量分市州明细.xlsx");
        REPORT_FILE_NAMES.put("cityRailFerryDetail", "生成_轨道轮渡客运量分市州明细.xlsx");
@@ -5399,6 +5469,355 @@
    }
    // ==================== 投资模块:客运站投资明细表 / 物流站场投资明细(模板底稿原位替换) ====================
    /** 物流站场明细表模板中的市州小节短名 -> 库内规范全名 */
    private static final Map<String, String> LOGI_CITY_LABELS = new LinkedHashMap<>();
    static {
        LOGI_CITY_LABELS.put("武汉", "武汉市");
        LOGI_CITY_LABELS.put("黄石", "黄石市");
        LOGI_CITY_LABELS.put("十堰", "十堰市");
        LOGI_CITY_LABELS.put("宜昌", "宜昌市");
        LOGI_CITY_LABELS.put("襄阳", "襄阳市");
        LOGI_CITY_LABELS.put("鄂州", "鄂州市");
        LOGI_CITY_LABELS.put("荆门", "荆门市");
        LOGI_CITY_LABELS.put("孝感", "孝感市");
        LOGI_CITY_LABELS.put("荆州", "荆州市");
        LOGI_CITY_LABELS.put("黄冈", "黄冈市");
        LOGI_CITY_LABELS.put("咸宁", "咸宁市");
        LOGI_CITY_LABELS.put("随州", "随州市");
        LOGI_CITY_LABELS.put("恩施", "恩施州");
        LOGI_CITY_LABELS.put("仙桃", "仙桃市");
        LOGI_CITY_LABELS.put("潜江", "潜江市");
        LOGI_CITY_LABELS.put("天门", "天门市");
        LOGI_CITY_LABELS.put("林区", "神农架林区");
    }
    /** 该期各项目月度数据:projectId -> InvestmentMonthly */
    private Map<Long, InvestmentMonthly> investMonthlyOf(String period) {
        Map<Long, InvestmentMonthly> map = new HashMap<>();
        for (InvestmentMonthly m : investMonthlyMapper.selectList(
                new LambdaQueryWrapper<InvestmentMonthly>().eq(InvestmentMonthly::getReportPeriod, period))) {
            map.put(m.getProjectId(), m);
        }
        return map;
    }
    /** 写单元格数值(保留样式/合并):null 清空 */
    private void putNum(Row row, int idx, Double v) {
        if (row == null) return;
        Cell c = row.getCell(idx);
        if (c == null) c = row.createCell(idx);
        c.setBlank();
        if (v != null) c.setCellValue(v);
    }
    /** 写单元格文本(保留样式/合并):空串清空 */
    private void putText(Row row, int idx, String v) {
        if (row == null) return;
        Cell c = row.getCell(idx);
        if (c == null) c = row.createCell(idx);
        c.setBlank();
        if (v != null && !v.trim().isEmpty()) c.setCellValue(v.trim());
    }
    /** 读单元格数值(空/非数值/公式无缓存一律 0) */
    private double numOf(Cell c) {
        if (c == null) return 0.0;
        try {
            if (c.getCellType() == CellType.NUMERIC || c.getCellType() == CellType.FORMULA) {
                return c.getNumericCellValue();
            }
            String s = cellText(c);
            if (s == null) return 0.0;
            s = s.replace(",", "").trim();
            return s.isEmpty() ? 0.0 : Double.parseDouble(s);
        } catch (Exception e) {
            return 0.0;
        }
    }
    /** 模板标题里的「(N月月报)」「(N月)」改为报表期月份 */
    private void fixInvestMonthTitle(Sheet sheet, int rowIdx, int colIdx, int month) {
        Row row = sheet.getRow(rowIdx);
        if (row == null) return;
        String t = cellText(row.getCell(colIdx));
        if (t == null) return;
        String nv = t.replaceAll("(\\d+月月报)", "(" + month + "月月报)")
                      .replaceAll("(\\d+月)", "(" + month + "月)");
        if (!nv.equals(t)) putText(row, colIdx, nv);
    }
    /** 模板行 A 列是否为「一二三四、」渠道分组标题 */
    private boolean isInvestChannelLabel(String a) {
        return a != null && a.matches("^[一二三四五六七八九十]+、.*");
    }
    /**
     * 生成_X月客运站投资明细表.xlsx(模板:docs/投资/模板/模板_X月客运站投资明细表.xlsx,sheet:明细 + 汇总)。
     * 口径:模板即上月台账底稿;本月只覆盖「库内有该期数据」的项目行
     * (H自开始建设累计 / I本年计划 / J自年初累计 / K本月完成 / L建设阶段 / M形象进度),
     * 库内无该期的项目行保留母版原值(等同人工「上月结转 + 本月更新」);
     * 城市/渠道/合计行沿用模板 SUM 公式,随表内数值自动重算。
     */
    public byte[] exportInvestPassengerDetail(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        List<InvestmentProject> projects = investProjectMapper.selectList(
                new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, "客运站场"));
        Map<Long, InvestmentMonthly> mByProject = investMonthlyOf(period);
        File tpl = resolveAnyTemplate(investTemplateDir, "模板_X月客运站投资明细表.xlsx");
        try (InputStream in = new FileInputStream(tpl);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheet("明细");
            if (sheet == null) throw new RuntimeException("模板《模板_X月客运站投资明细表.xlsx》缺少「明细」工作表");
            fixInvestMonthTitle(sheet, 0, 1, month);
            String curCity = null;
            Set<Long> used = new HashSet<>();
            int filled = 0, kept = 0, dup = 0;
            for (int r = 8; r <= sheet.getLastRowNum(); r++) {
                Row row = sheet.getRow(r);
                if (row == null) continue;
                String a = nvl(cellText(row.getCell(0)));
                String b = nvl(cellText(row.getCell(1)));
                if (isCityLabel(a)) { curCity = templateCityFull(a); continue; }
                if (isInvestChannelLabel(a)) continue;
                if (a.contains("合计") || a.contains("总计")) continue;
                if (!a.matches("\\d+(\\.\\d+)?")) continue;
                if (b.isEmpty() || curCity == null) continue;
                InvestmentProject p = matchInvest(projects, b, curCity, "客运站场", used);
                InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
                if (p == null || m == null) {
                    kept++;
                    // 有库内项目但当月无数据 → 记为待核对;无匹配项目 → 结转上月
                    if (p != null) dup++;
                    continue;
                }
                used.add(p.getId());
                // 口径(用户 2026-09-18 确认):只覆盖人工会随月报更新的四列——
                // H 自开始建设累计 / J 自年初累计 / K 本月完成 / M 形象进度;
                // I 本年计划投资、L 建设阶段、G 计划总投资为省定/母版口径,一律不动;库内为空则保留母版原值
                if (m.getStartCum() != null) putNum(row, 7, m.getStartCum());
                if (m.getYearCum() != null) putNum(row, 9, m.getYearCum());
                if (m.getMonthDone() != null) putNum(row, 10, m.getMonthDone());
                if (m.getProgressDesc() != null && !m.getProgressDesc().trim().isEmpty()) putText(row, 12, m.getProgressDesc());
                filled++;
            }
            Sheet sumSheet = wb.getSheet("汇总");
            if (sumSheet != null) {
                Row r2 = sumSheet.getRow(1);
                if (r2 != null) {
                    Cell c = r2.getCell(0);
                    if (c == null) c = r2.createCell(0);
                    c.setBlank();
                    c.setCellValue(java.time.LocalDate.of(year, month, 1).atStartOfDay());
                }
            }
            try {
                wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
            } catch (Exception e) {
                log.warn("客运站投资明细表公式求值失败: {}", e.getMessage());
            }
            log.info("客运站投资明细表 {}:写入 {} 行,结转母版原值 {} 行(其中库内有项目但当月无数据 {} 行)",
                    period, filled, kept, dup);
            return toBytes(wb);
        }
    }
    /**
     * 生成_X月物流站场投资明细.xls(模板:docs/投资/模板/模板_X月物流站场投资明细.xls)。
     * 覆盖「 分项目投资完成情况」项目行(口径同客运站明细表),并按表内数值重算
     * 渠道行 / 市州行 / 合计行,同步刷新「分市州规划内外项目完成情况」「市州汇总」两张汇总页。
     */
    public byte[] exportInvestLogisticsDetail(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        List<InvestmentProject> projects = investProjectMapper.selectList(
                new LambdaQueryWrapper<InvestmentProject>().eq(InvestmentProject::getCategory, "物流园区"));
        Map<Long, InvestmentMonthly> mByProject = investMonthlyOf(period);
        HSSFWorkbook wb = loadInvestTemplate("模板_X月物流站场投资明细.xls");
        HSSFSheet sheet = wb.getSheet(" 分项目投资完成情况");
        if (sheet == null) throw new RuntimeException("模板《模板_X月物流站场投资明细.xls》缺少「 分项目投资完成情况」工作表");
        fixInvestMonthTitle(sheet, 0, 0, month);
        Row head = sheet.getRow(3);
        if (head != null) {
            String t = cellText(head.getCell(0));
            if (t != null) {
                String nv = t.replaceAll("\\d{4}年\\d+月", year + "年" + month + "月");
                if (!nv.equals(t)) putText(head, 0, nv);
            }
        }
        // 1) 结构扫描:市州小节行
        List<Integer> cityRows = new ArrayList<>();
        for (int r = 7; r <= sheet.getLastRowNum(); r++) {
            HSSFRow row = sheet.getRow(r);
            if (row == null) continue;
            String a = nvl(cellText(row.getCell(0)));
            String b = nvl(cellText(row.getCell(1)));
            if (LOGI_CITY_LABELS.containsKey(a) && b.isEmpty()) cityRows.add(r);
        }
        Map<String, double[]> citySums = new LinkedHashMap<>();   // 全名 -> G,H,I,J,K
        Map<String, double[]> cityStats = new LinkedHashMap<>();  // 全名 -> {总数, 累计, 规划内数, 规划内累计, 规划外数, 规划外累计}
        Set<Long> used = new HashSet<>();
        int filled = 0, kept = 0;
        for (int i = 0; i < cityRows.size(); i++) {
            int cr = cityRows.get(i);
            String cityLabel = nvl(cellText(sheet.getRow(cr).getCell(0)));
            String city = LOGI_CITY_LABELS.getOrDefault(cityLabel, cityLabel);
            int start = cr + 1;
            int end = (i + 1 < cityRows.size() ? cityRows.get(i + 1) : sheet.getLastRowNum() + 1) - 1;
            List<Integer> chRows = new ArrayList<>();
            for (int r = start; r <= end; r++) {
                HSSFRow row = sheet.getRow(r);
                if (row == null) continue;
                if (isInvestChannelLabel(nvl(cellText(row.getCell(0))))) chRows.add(r);
            }
            // (1) 先按项目名+市州 覆盖有库内当期数据的项目行
            for (int r = start; r <= end; r++) {
                HSSFRow row = sheet.getRow(r);
                if (row == null) continue;
                String a = nvl(cellText(row.getCell(0)));
                String b = nvl(cellText(row.getCell(1)));
                if (isInvestChannelLabel(a)) continue;
                if (!a.matches("\\d+(\\.\\d+)?")) continue;
                if (b.isEmpty()) continue;
                InvestmentProject p = matchInvest(projects, b, city, "物流园区", used);
                InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
                if (p == null || m == null) { kept++; continue; }
                used.add(p.getId());
                // 口径同客运站明细表:只覆盖 H 自开始建设累计 / J 自年初累计 / K 本月完成 / M 形象进度
                if (m.getStartCum() != null) putNum(row, 7, m.getStartCum());
                if (m.getYearCum() != null) putNum(row, 9, m.getYearCum());
                if (m.getMonthDone() != null) putNum(row, 10, m.getMonthDone());
                if (m.getProgressDesc() != null && !m.getProgressDesc().trim().isEmpty()) putText(row, 12, m.getProgressDesc());
                filled++;
            }
            // (2) 再按渠道块汇总,并写回渠道行 / 市州行
            double[] citySum = new double[5];
            double[] stat = new double[6];
            for (int j = 0; j < chRows.size(); j++) {
                int r0 = chRows.get(j);
                int r1 = (j + 1 < chRows.size() ? chRows.get(j + 1) : end + 1) - 1;
                boolean planned = !nvl(cellText(sheet.getRow(r0).getCell(0))).startsWith("四、");
                double[] s = new double[5];
                for (int r = r0 + 1; r <= r1; r++) {
                    HSSFRow row = sheet.getRow(r);
                    if (row == null) continue;
                    String a = nvl(cellText(row.getCell(0)));
                    String b = nvl(cellText(row.getCell(1)));
                    if (isInvestChannelLabel(a) || !a.matches("\\d+(\\.\\d+)?")) continue;
                    if (b.isEmpty()) continue;
                    for (int k = 0; k < 5; k++) s[k] += numOf(row.getCell(6 + k));
                    stat[0] += 1;
                    stat[1] += numOf(row.getCell(9));
                    if (planned) { stat[2] += 1; stat[3] += numOf(row.getCell(9)); }
                    else { stat[4] += 1; stat[5] += numOf(row.getCell(9)); }
                }
                HSSFRow crow = sheet.getRow(r0);
                for (int k = 0; k < 5; k++) putNum(crow, 6 + k, s[k] == 0 ? null : s[k]);
                for (int k = 0; k < 5; k++) citySum[k] += s[k];
            }
            // 渠道块之外的游离项目行(模板未分组时)也计入市州合计
            for (int r = start; r <= end; r++) {
                HSSFRow row = sheet.getRow(r);
                if (row == null) continue;
                String a = nvl(cellText(row.getCell(0)));
                String b = nvl(cellText(row.getCell(1)));
                if (isInvestChannelLabel(a) || !a.matches("\\d+(\\.\\d+)?")) continue;
                if (b.isEmpty()) continue;
                boolean inChannel = false;
                for (int j = 0; j < chRows.size(); j++) {
                    int r0 = chRows.get(j);
                    int r1 = (j + 1 < chRows.size() ? chRows.get(j + 1) : end + 1) - 1;
                    if (r > r0 && r <= r1) { inChannel = true; break; }
                }
                if (inChannel) continue;
                for (int k = 0; k < 5; k++) citySum[k] += numOf(row.getCell(6 + k));
                stat[0] += 1;
                stat[1] += numOf(row.getCell(9));
            }
            HSSFRow cityRow = sheet.getRow(cr);
            for (int k = 0; k < 5; k++) putNum(cityRow, 6 + k, citySum[k] == 0 ? null : citySum[k]);
            citySums.put(city, citySum);
            cityStats.put(city, stat);
        }
        // 2) 合计行(第 7 行)
        double[] total = new double[5];
        for (double[] s : citySums.values()) {
            for (int k = 0; k < 5; k++) total[k] += s[k];
        }
        for (int k = 0; k < 5; k++) putNum(sheet.getRow(6), 6 + k, total[k] == 0 ? null : total[k]);
        // 3) 分市州规划内外项目完成情况
        HSSFSheet citySheet = wb.getSheet("分市州规划内外项目完成情况");
        if (citySheet != null) {
            Row t1 = citySheet.getRow(0);
            if (t1 != null) {
                String t = cellText(t1.getCell(0));
                if (t != null) {
                    String nv = t.replaceAll("\\d{4}年", year + "年");
                    if (!nv.equals(t)) putText(t1, 0, nv);
                }
            }
            double[] st = new double[6];
            for (int r = 3; r <= citySheet.getLastRowNum(); r++) {
                HSSFRow row = citySheet.getRow(r);
                if (row == null) continue;
                String a = nvl(cellText(row.getCell(0)));
                String b = nvl(cellText(row.getCell(1)));
                if (a.contains("合计") || b.contains("合计")) {
                    for (int k = 0; k < 6; k++) putNum(row, 2 + k, st[k] == 0 ? null : st[k]);
                    continue;
                }
                double[] s = cityStats.get(LOGI_CITY_LABELS.getOrDefault(b, b));
                if (s == null) continue;
                for (int k = 0; k < 6; k++) {
                    putNum(row, 2 + k, s[k] == 0 ? null : s[k]);
                    st[k] += s[k];
                }
            }
        }
        // 4) 市州汇总(B 年度计划投资为官方口径,保持模板原值;C/D/E 按分项目页重算)
        HSSFSheet sumSheet = wb.getSheet("市州汇总");
        if (sumSheet != null) {
            HSSFRow r2 = sumSheet.getRow(1);
            if (r2 != null) {
                putNum(r2, 0, (double) org.apache.poi.ss.usermodel.DateUtil.getExcelDate(
                        java.time.LocalDate.of(year, month, 1).atStartOfDay()));
            }
            double cT = 0.0, dT = 0.0;
            for (int r = 7; r <= 23; r++) {
                HSSFRow row = sumSheet.getRow(r);
                if (row == null) continue;
                String a = nvl(cellText(row.getCell(0)));
                double[] s = citySums.get(LOGI_CITY_LABELS.getOrDefault(a, a));
                if (s == null) continue;
                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);
                cT += s[3];
                dT += s[4];
            }
            HSSFRow tot = sumSheet.getRow(6);
            if (tot != null) {
                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);
            }
        }
        try {
            wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
        } catch (Exception e) {
            log.warn("物流站场投资明细公式求值失败: {}", e.getMessage());
        }
        log.info("物流站场投资明细 {}:写入 {} 行,结转母版原值 {} 行", period, filled, kept);
        return toBytesHssf(wb);
    }
    private static class InvestAgg {
        double total, startCum, yearPlan, yearCum;