xyc
2026-09-21 abf468c9946c21512ddf2093895b30910e09e536
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -1,26 +1,74 @@
package com.trafficaudit.reportexport.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.EnergyVehicleQuarterly;
import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
import com.trafficaudit.dataimport.entity.InvestmentMonthly;
import com.trafficaudit.dataimport.entity.CityBusMonthly;
import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
import com.trafficaudit.dataimport.entity.InvestmentProject;
import com.trafficaudit.dataimport.entity.PassengerEnterpriseMonthly;
import com.trafficaudit.dataimport.entity.PassengerIndividualMonthly;
import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
import com.trafficaudit.dataimport.entity.WycOrderMonthly;
import com.trafficaudit.auditengine.entity.AuditResult;
import com.trafficaudit.auditengine.entity.AuditRun;
import com.trafficaudit.auditengine.mapper.AuditResultMapper;
import com.trafficaudit.auditengine.mapper.AuditRunMapper;
import com.trafficaudit.rulemanage.entity.AuditRule;
import com.trafficaudit.rulemanage.mapper.AuditRuleMapper;
import com.trafficaudit.dataimport.mapper.EnergyVehicleQuarterlyMapper;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.InvestmentMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityBusMonthlyMapper;
import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
import com.trafficaudit.dataimport.mapper.InvestmentProjectMapper;
import com.trafficaudit.dataimport.mapper.PassengerEnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.PassengerIndividualMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import com.trafficaudit.dataimport.mapper.WycOrderMonthlyMapper;
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
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.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.hssf.usermodel.HSSFCell;
import org.apache.poi.hssf.usermodel.HSSFRow;
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;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
 * 报表生成服务:3 类输出报表
@@ -37,12 +85,727 @@
    private ScaleSplitTransportMapper scaleSplitMapper;
    @Resource
    private FreightTurnoverImportMapper freightTurnoverMapper;
    @Resource
    private PassengerEnterpriseMonthlyMapper passengerMapper;
    @Resource
    private PassengerIndividualMonthlyMapper passengerIndividualMapper;
    @Resource
    private EnergyVehicleQuarterlyMapper energyMapper;
    @Resource
    private InvestmentProjectMapper investProjectMapper;
    @Resource
    private InvestmentMonthlyMapper investMonthlyMapper;
    @Resource
    private CityBusMonthlyMapper cityBusMapper;
    @Resource
    private CityTaxiMonthlyMapper cityTaxiMapper;
    @Resource
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
    private WycOrderMonthlyMapper wycOrderMapper;
    @Resource
    private AuditResultMapper auditResultMapper;
    @Resource
    private AuditRunMapper auditRunMapper;
    @Resource
    private AuditRuleMapper auditRuleMapper;
    /** 城市客运模板目录(application.yml city-passenger.template-dir) */
    @Value("${city-passenger.template-dir:docs/城市客运}")
    private String templateDir;
    /** 能耗汇总模板目录(application.yml energy.template-dir) */
    @Value("${energy.template-dir:docs/公路旅客+能耗}")
    private String energyTemplateDir;
    /** 公路旅客输出模板目录(application.yml passenger.template-dir) */
    @Value("${passenger.template-dir:docs/公路旅客+能耗/输出}")
    private String passengerTemplateDir;
    /** 汇总工作簿模板目录(application.yml summary.template-dir) */
    @Value("${summary.template-dir:docs/生成汇总大表}")
    private String summaryTemplateDir;
    /** 货运报表模板目录(application.yml freight.template-dir) */
    @Value("${freight.template-dir:docs/货运}")
    private String freightTemplateDir;
    /** 投资报表模板目录(application.yml investment.template-dir) */
    @Value("${investment.template-dir:docs/投资/模板}")
    private String investTemplateDir;
    // ==================== 导出前数据就绪校验(P0:防止导出空表) ====================
    /** 返回每张报表依赖的数据行数与缺失提示 */
    public Map<String, Object> checkExportReady(String period, String mode, List<String> types) {
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("period", period);
        result.put("mode", mode);
        List<Map<String, Object>> items = new ArrayList<>();
        for (String t : types) {
            Map<String, Object> it = new LinkedHashMap<>();
            it.put("key", t);
            List<String> notes = new ArrayList<>();
            int rows;
            List<String> auditTypes = new ArrayList<>();
            switch (t) {
                case "cityDetail":
                case "freightRank":
                case "turnoverRank":
                    rows = freightReadyRows(period, mode, notes);
                    auditTypes.add("H2032");
                    break;
                case "passengerCityDetail":
                case "passengerMidDetail":
                case "passengerMidRank":
                case "passengerMidAnalysis":
                    rows = passengerReadyRows(period, mode, notes);
                    auditTypes.add("H2031");
                    break;
                case "cityBusDetail":
                case "cityRailFerryDetail":
                    rows = addReadyCount(() -> cityBusMapper.selectCount(periodQw(period, mode, CityBusMonthly::getReportPeriod)), "城市公交月报", notes);
                    auditTypes.add("CITY_BUS");
                    break;
                case "cityTaxiDetail":
                    rows = addReadyCount(() -> cityTaxiMapper.selectCount(periodQw(period, mode, CityTaxiMonthly::getReportPeriod)), "巡游出租月报", notes);
                    auditTypes.add("CITY_TAXI");
                    break;
                case "cityPassengerCitySum":
                case "cityPassengerDetailSum":
                case "cityPassengerSummary":
                    rows = addReadyCount(() -> cityBusMapper.selectCount(periodQw(period, mode, CityBusMonthly::getReportPeriod)), "城市公交月报", notes)
                         + addReadyCount(() -> cityTaxiMapper.selectCount(periodQw(period, mode, CityTaxiMonthly::getReportPeriod)), "巡游出租月报", notes);
                    auditTypes.add("CITY_BUS");
                    auditTypes.add("CITY_TAXI");
                    break;
                case "investPlan":
                case "investFiveYearLogistics":
                case "investBillion":
                case "investCounty":
                    rows = addReadyCount(() -> investMonthlyMapper.selectCount(periodQw(period, mode, InvestmentMonthly::getReportPeriod)), "投资市州单表/汇总数据", notes);
                    auditTypes.add("INVEST");
                    break;
                case "energySummary":
                    rows = addReadyCount(() -> energyMapper.selectCount(periodQw(period, mode, EnergyVehicleQuarterly::getReportPeriod)), "能耗车辆季度数据", notes);
                    auditTypes.add("H204");
                    break;
                case "wycSplit": {
                    rows = addReadyCount(() -> cityTaxiMapper.selectCount(periodQw(period, mode, CityTaxiMonthly::getReportPeriod)), "巡游出租月报", notes);
                    try {
                        Map<String, Object> inp = loadWycMonthlyInput(period, mode);
                        if (inp == null) {
                            rows = 0;
                            notes.add("《网约车订单及全省总量.xlsx》缺 " + period + " 行(需先维护当月订单与全省 pin)");
                        } else {
                            double pinK = (Double) inp.get("pinK"), pinCK = (Double) inp.get("pinCK");
                            double pinZ = (Double) inp.get("pinZ"), pinCZ = (Double) inp.get("pinCZ");
                            double orderSum = (Double) inp.get("orderSum");
                            if (pinK <= 0 || pinCK <= 0 || pinZ <= 0 || pinCZ <= 0 || orderSum <= 0) {
                                rows = 0;
                                notes.add("网约车输入 " + period + " 行不完整(订单/全省 pin 缺失)");
                            }
                        }
                    } catch (Exception ex) {
                        rows = 0;
                        notes.add("网约车输入读取失败:" + ex.getMessage());
                    }
                    break;
                }
                case "summaryWorkbook": {
                    rows = addReadyCount(() -> cityTaxiMapper.selectCount(periodQw(period, mode, CityTaxiMonthly::getReportPeriod)), "巡游出租月报", notes)
                         + addReadyCount(() -> cityBusMapper.selectCount(periodQw(period, mode, CityBusMonthly::getReportPeriod)), "城市公交月报", notes);
                    if (rows <= 0) notes.add("汇总表依赖的公交/出租月报数据缺失");
                    try {
                        if (resolveSummaryMother(period, mode) == null) {
                            rows = 0;
                            notes.add("docs/生成汇总大表 下缺少可复制的母版汇总表");
                        }
                    } catch (Exception ex) {
                        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:
                    rows = 0;
                    notes.add("未知报表类型:" + t);
            }
            int auditRows = 0;
            boolean audited = false;
            for (String at : auditTypes) {
                auditRows += auditRowsOf(period, mode, at);
                audited = audited || auditedMarked(period, mode, at);
            }
            if (auditTypes.isEmpty()) audited = true;
            it.put("rows", rows);
            it.put("ready", rows > 0);
            it.put("auditRows", auditRows);
            it.put("audited", audited);
            it.put("note", String.join(";", notes));
            items.add(it);
        }
        result.put("items", items);
        return result;
    }
    // ==================== 报表数据预览(轻量版):就绪 + 月度覆盖 + 核心摘要 + 问题清单 ====================
    /** 报表数据预览:在导出前展示每张表的就绪状态、月度覆盖、核心指标摘要与问题清单 */
    public Map<String, Object> reportPreview(String period, String mode, List<String> types) {
        Map<String, Object> ready = checkExportReady(period, mode, types);
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> items = (List<Map<String, Object>>) ready.get("items");
        Map<String, Map<String, Object>> byKey = new LinkedHashMap<>();
        for (Map<String, Object> it : items) byKey.put((String) it.get("key"), it);
        String year = period != null && period.length() >= 4 ? period.substring(0, 4) : "";
        String yearPrefix = year + "-";
        int month = monthOf(period, mode);
        int yearNum = 0;
        try { yearNum = Integer.parseInt(year); } catch (Exception ignore) {}
        boolean freight = false, passenger = false, cityBus = false, cityTaxi = false, invest = false, energy = false;
        for (String t : types) {
            if (isFreightType(t)) freight = true;
            else if (isPassengerType(t)) passenger = true;
            else if ("cityTaxiDetail".equals(t) || "wycSplit".equals(t)) { cityTaxi = true; }
            else if (isCityPassengerType(t) || "summaryWorkbook".equals(t)) { cityTaxi = true; cityBus = true; }
            else if (isInvestType(t)) invest = true;
            else if ("energySummary".equals(t)) energy = true;
        }
        // 月度覆盖:该年 1..month 中哪些月有源数据
        Set<Integer> freightCover = new TreeSet<>();
        Set<Integer> passengerCover = new TreeSet<>();
        Set<Integer> busCover = new TreeSet<>();
        Set<Integer> taxiCover = new TreeSet<>();
        Set<Integer> investCover = new TreeSet<>();
        if (freight) {
            FreightTurnoverImport ftProv = loadFreightTurnover(period).get("湖北省");
            for (int m = 1; m <= month; m++) {
                if (ftProv != null && freightMonth(ftProv, m) != null) freightCover.add(m);
            }
        }
        if (passenger) {
            passengerCover.addAll(monthsWithData(() -> passengerMapper.selectList(new LambdaQueryWrapper<PassengerEnterpriseMonthly>().likeRight(PassengerEnterpriseMonthly::getReportPeriod, yearPrefix)),
                o -> ((PassengerEnterpriseMonthly) o).getReportPeriod(), month));
            passengerCover.addAll(monthsWithData(() -> passengerIndividualMapper.selectList(new LambdaQueryWrapper<PassengerIndividualMonthly>().likeRight(PassengerIndividualMonthly::getReportPeriod, yearPrefix)),
                o -> ((PassengerIndividualMonthly) o).getReportPeriod(), month));
        }
        if (cityBus) {
            busCover.addAll(monthsWithData(() -> cityBusMapper.selectList(new LambdaQueryWrapper<CityBusMonthly>().likeRight(CityBusMonthly::getReportPeriod, yearPrefix)),
                o -> ((CityBusMonthly) o).getReportPeriod(), month));
        }
        if (cityTaxi) {
            taxiCover.addAll(monthsWithData(() -> cityTaxiMapper.selectList(new LambdaQueryWrapper<CityTaxiMonthly>().likeRight(CityTaxiMonthly::getReportPeriod, yearPrefix)),
                o -> ((CityTaxiMonthly) o).getReportPeriod(), month));
        }
        if (invest) {
            investCover.addAll(monthsWithData(() -> investMonthlyMapper.selectList(new LambdaQueryWrapper<InvestmentMonthly>().likeRight(InvestmentMonthly::getReportPeriod, yearPrefix)),
                o -> ((InvestmentMonthly) o).getReportPeriod(), month));
        }
        for (Map.Entry<String, Map<String, Object>> e : byKey.entrySet()) {
            String t = e.getKey();
            Map<String, Object> it = e.getValue();
            List<Map<String, Object>> summary = new ArrayList<>();
            List<String> problems = new ArrayList<>();
            List<Integer> missing = new ArrayList<>();
            List<Integer> coverage = new ArrayList<>();
            if (Boolean.FALSE.equals(it.get("audited"))) problems.add("该报表期尚未审核通过");
            if ("wycSplit".equals(t)) {
                coverage.addAll(taxiCover);
                missing = missingMonths(taxiCover, month);
                try {
                    Map<String, Object> inp = loadWycMonthlyInput(period, mode);
                    if (inp == null) {
                        problems.add("缺网约车输入:请在《网约车订单及全省总量.xlsx》补 " + period + " 行");
                    } else {
                        double pinK = (Double) inp.get("pinK"), pinCK = (Double) inp.get("pinCK");
                        double pinZ = (Double) inp.get("pinZ"), pinCZ = (Double) inp.get("pinCZ");
                        double orderSum = (Double) inp.get("orderSum");
                        if (pinK <= 0 || pinCK <= 0 || pinZ <= 0 || pinCZ <= 0 || orderSum <= 0) {
                            problems.add("网约车输入 " + period + " 行不完整(订单/全省 pin 缺失)");
                        } else {
                            addSum(summary, "全省网约车客运量", pinK, "万人");
                            addSum(summary, "其中城市内客运量", pinCK, "万人");
                            addSum(summary, "全省网约车周转量", pinZ, "万人公里");
                            addSum(summary, "其中城市内周转量", pinCZ, "万人公里");
                            addSum(summary, "部级订单合计", orderSum, "单");
                        }
                    }
                } catch (Exception ex) {
                    problems.add("网约车输入读取失败:" + ex.getMessage());
                }
            } else if ("summaryWorkbook".equals(t)) {
                java.util.Set<Integer> cov = new java.util.TreeSet<>();
                cov.addAll(busCover);
                cov.addAll(taxiCover);
                coverage.addAll(cov);
                missing = missingMonths(cov, month);
                try {
                    if (resolveSummaryMother(period, mode) == null) {
                        problems.add("docs/生成汇总大表 下缺少可复制的母版汇总表");
                    } else {
                        summaryWorkbookPreview(period, yearNum, month, summary, problems);
                    }
                } catch (Exception ex) {
                    problems.add(ex.getMessage());
                }
            } else if (isFreightType(t)) {
                coverage.addAll(freightCover);
                missing = missingMonths(freightCover, month);
                freightPreview(period, month, summary, problems);
            } else if (isPassengerType(t)) {
                coverage.addAll(passengerCover);
                missing = missingMonths(passengerCover, month);
                passengerPreview(yearNum, month, t, summary, problems);
            } else if ("cityTaxiDetail".equals(t)) {
                coverage.addAll(taxiCover);
                missing = missingMonths(taxiCover, month);
                cityPassengerPreview(yearPrefix, month, t, summary, problems);
            } else if (isCityPassengerType(t)) {
                if ("cityBusDetail".equals(t) || "cityRailFerryDetail".equals(t)) {
                    // 公交/轨道轮渡明细只依赖公交月报表(轨道/轮渡数据在公交表内)
                    coverage.addAll(busCover);
                    missing = missingMonths(busCover, month);
                } else {
                    Set<Integer> cov = new TreeSet<>();
                    cov.addAll(busCover);
                    cov.addAll(taxiCover);
                    coverage.addAll(cov);
                    missing = missingMonths(cov, month);
                }
                cityPassengerPreview(yearPrefix, month, t, summary, problems);
            } else if (isInvestType(t)) {
                coverage.addAll(investCover);
                // 投资为当月报表(无 1..N 累计口径),只提示当月缺失
                if (!investCover.contains(month)) missing.add(month);
                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);
                energyPreview(period, mode, summary, problems);
            }
            if (!missing.isEmpty()) {
                problems.add("缺月数据:" + joinMonths(missing) + "(1~" + month + "月累计需逐月齐全)");
            }
            it.put("monthCoverage", coverage);
            it.put("missingMonths", missing);
            it.put("summary", summary);
            it.put("problems", problems);
        }
        return ready;
    }
    private boolean isFreightType(String t) {
        return "cityDetail".equals(t) || "freightRank".equals(t) || "turnoverRank".equals(t);
    }
    private boolean isPassengerType(String t) {
        return "passengerCityDetail".equals(t) || "passengerMidDetail".equals(t) || "passengerMidRank".equals(t) || "passengerMidAnalysis".equals(t);
    }
    private boolean isCityPassengerType(String t) {
        return "cityBusDetail".equals(t) || "cityRailFerryDetail".equals(t) || "cityPassengerCitySum".equals(t)
            || "cityPassengerDetailSum".equals(t) || "cityPassengerSummary".equals(t);
    }
    private boolean isInvestType(String 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) {
        return "energySummary".equals(t);
    }
    /** 按 report_period like 'yyyy-' 统计 1..limit 月中有数据的月份(升序) */
    private Set<Integer> monthsWithData(java.util.function.Supplier<java.util.List<?>> rows, java.util.function.Function<Object, String> periodOf, int limit) {
        Set<Integer> set = new TreeSet<>();
        for (Object o : rows.get()) {
            String rp = periodOf.apply(o);
            if (rp == null) continue;
            String[] parts = rp.split("-");
            if (parts.length < 2) continue;
            try {
                int m = Integer.parseInt(parts[1]);
                if (m >= 1 && m <= limit) set.add(m);
            } catch (Exception ignore) {}
        }
        return set;
    }
    /** 1..limit 中缺失的月份 */
    private List<Integer> missingMonths(Set<Integer> coverage, int limit) {
        List<Integer> missing = new ArrayList<>();
        for (int m = 1; m <= limit; m++) if (!coverage.contains(m)) missing.add(m);
        return missing;
    }
    private String joinMonths(List<Integer> months) {
        StringBuilder sb = new StringBuilder();
        for (int m : months) {
            if (sb.length() > 0) sb.append("、");
            sb.append(m).append("月");
        }
        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);
        m.put("value", value);
        m.put("unit", unit);
        summary.add(m);
    }
    /** 货运摘要:全省累计货运量(合计/规上/规下)+ 周转量(拆分表全省累计行) */
    private void freightPreview(String period, int month, List<Map<String, Object>> summary, List<String> problems) {
        Map<String, FreightTurnoverImport> ft = loadFreightTurnover(period);
        Map<String, Double> h2032Cum = loadH2032FreightCumulative(period);
        ScaleSplitTransport provCum = getProvinceCumulative(period);
        Double total = freightCum(ft.get("湖北省"), month);
        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);
        Double belowWan = totalWan == null ? null : round(totalWan - aboveWan, 4);
        addSum(summary, "全省累计货运量(合计)", totalWan, "万吨");
        addSum(summary, "其中:规上", aboveWan, "万吨");
        addSum(summary, "其中:规下", belowWan, "万吨");
        if (provCum != null) {
            addSum(summary, "全省累计周转量(合计)", provCum.getTotalTurnover(), "万吨公里");
            addSum(summary, "其中:规上", provCum.getAboveScaleTurnover(), "万吨公里");
            addSum(summary, "其中:规下", provCum.getBelowScaleTurnover(), "万吨公里");
        } else {
            problems.add("拆分表无全省累计行(周转量/规上规下缺)");
        }
        if (totalWan == null) problems.add("模板_货运量周转量无全省累计,累计货运量为空");
    }
    /** 旅客摘要:企业+个体(分市州)或中口径企业(中口径 3 表) */
    private void passengerPreview(int yearNum, int month, String type, List<Map<String, Object>> summary, List<String> problems) {
        Map<Integer, Map<String, PassengerAgg>> data = loadPassengerAggMap();
        Map<Integer, Map<String, double[]>> indi = loadPassengerIndividualMap();
        double entPax = 0.0, entTurn = 0.0, indiPax = 0.0, indiTurn = 0.0;
        boolean anyEnt = false, anyIndi = false;
        for (int m = 1; m <= month; m++) {
            PassengerAgg agg = aggOf(data, yearNum, m, "湖北省");
            if (agg != null) {
                entPax += agg.passengerTotal / 10000.0;
                entTurn += agg.turnoverTotal / 10000.0;
                anyEnt = true;
            }
            Map<String, double[]> mm = indi.get(yearNum * 100 + m);
            if (mm != null) {
                double[] pv = mm.get("湖北省");
                if (pv != null) {
                    indiPax += pv[0];
                    indiTurn += pv[1];
                    anyIndi = true;
                }
            }
        }
        if ("passengerCityDetail".equals(type)) {
            addSum(summary, "企业客运量累计", round(entPax, 4), "万人次");
            addSum(summary, "个体客运量累计", round(indiPax, 4), "万人次");
            addSum(summary, "合计客运量累计", round(entPax + indiPax, 4), "万人次");
            addSum(summary, "合计周转量累计", round(entTurn + indiTurn, 4), "万人公里");
            if (!anyEnt && !anyIndi) problems.add("H203-1 旅客月报与个体数据均无");
        } else {
            addSum(summary, "中口径客运量累计", round(entPax, 4), "万人次");
            addSum(summary, "中口径周转量累计", round(entTurn, 4), "万人公里");
            if (!anyEnt) problems.add("H203-1 旅客月报无企业数据");
        }
    }
    /** 城市客运摘要:公交/出租/轨道/轮渡全省累计(指标 0..7) */
    private void cityPassengerPreview(String yearPrefix, int month, String type, List<Map<String, Object>> summary, List<String> problems) {
        double[] prov = loadCityPassengerCumulative(yearPrefix, month).getOrDefault("全省", new double[8]);
        boolean allZero = true;
        for (double v : prov) if (v != 0.0) allZero = false;
        switch (type) {
            case "cityTaxiDetail":
                addSum(summary, "出租客运量累计", round(prov[2], 4), "万人次");
                addSum(summary, "出租周转量累计", round(prov[3], 4), "万人公里");
                break;
            case "cityRailFerryDetail":
                addSum(summary, "轨道客运量累计", round(prov[4], 4), "万人次");
                addSum(summary, "轨道周转量累计", round(prov[5], 4), "万人公里");
                addSum(summary, "轮渡客运量累计", round(prov[6], 4), "万人次");
                addSum(summary, "轮渡周转量累计", round(prov[7], 4), "万人公里");
                break;
            case "cityBusDetail":
                addSum(summary, "公交客运量累计", round(prov[0], 4), "万人次");
                addSum(summary, "公交周转量累计", round(prov[1], 4), "万人公里");
                addSum(summary, "轨道客运量累计", round(prov[4], 4), "万人次");
                addSum(summary, "轨道周转量累计", round(prov[5], 4), "万人公里");
                addSum(summary, "轮渡客运量累计", round(prov[6], 4), "万人次");
                addSum(summary, "轮渡周转量累计", round(prov[7], 4), "万人公里");
                break;
            default:
                addSum(summary, "公交客运量累计", round(prov[0], 4), "万人次");
                addSum(summary, "公交周转量累计", round(prov[1], 4), "万人公里");
                addSum(summary, "出租客运量累计", round(prov[2], 4), "万人次");
                addSum(summary, "出租周转量累计", round(prov[3], 4), "万人公里");
                addSum(summary, "轨道客运量累计", round(prov[4], 4), "万人次");
                addSum(summary, "轨道周转量累计", round(prov[5], 4), "万人公里");
                addSum(summary, "轮渡客运量累计", round(prov[6], 4), "万人次");
                addSum(summary, "轮渡周转量累计", round(prov[7], 4), "万人公里");
        }
        if (allZero) problems.add("全省累计值全为 0(公交/出租月报可能未导入)");
    }
    /** 投资摘要:项目数 + 本月完成/自年初/自开始累计(全投资月报合计,不分类别) */
    private void investPreview(String period, String mode, List<Map<String, Object>> summary, List<String> problems) {
        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) {
            if (m.getProjectId() != null) proj.add(m.getProjectId());
            monthDone += nz(m.getMonthDone());
            yearCum += nz(m.getYearCum());
            startCum += nz(m.getStartCum());
        }
        addSum(summary, "有月度记录项目数", (double) proj.size(), "个");
        addSum(summary, "本月完成投资合计", round(monthDone, 2), "万元");
        addSum(summary, "自年初累计完成", round(yearCum, 2), "万元");
        addSum(summary, "自开始累计完成", round(startCum, 2), "万元");
        if (list.isEmpty()) problems.add("无投资月度数据");
    }
    /** 能耗摘要:车辆记录数 + 燃油消耗合计(季度表,按所选季度末月) */
    private void energyPreview(String period, String mode, List<Map<String, Object>> summary, List<String> problems) {
        Long cnt = energyMapper.selectCount(periodQw(period, mode, EnergyVehicleQuarterly::getReportPeriod));
        int rows = cnt == null ? 0 : cnt.intValue();
        double fuel = 0.0;
        if (rows > 0) {
            for (EnergyVehicleQuarterly e : energyMapper.selectList(periodQw(period, mode, EnergyVehicleQuarterly::getReportPeriod))) {
                fuel += nz(e.getFuelConsumption());
            }
        }
        addSum(summary, "车辆记录数", (double) rows, "条");
        addSum(summary, "燃油消耗合计", round(fuel, 2), "—");
        if (rows == 0) problems.add("该季度无能耗车辆数据");
    }
    private int freightReadyRows(String period, String mode, List<String> notes) {
        return addReadyCount(() -> freightTurnoverMapper.selectCount(periodQw(period, mode, FreightTurnoverImport::getReportPeriod)), "模板_货运量周转量", notes)
             + addReadyCount(() -> scaleSplitMapper.selectCount(periodQw(period, mode, ScaleSplitTransport::getReportPeriod)), "规上规下拆分表", notes)
             + addReadyCount(() -> h2032Mapper.selectCount(periodQw(period, mode, H2032EnterpriseMonthly::getReportPeriod)), "H203-2 货运月报", notes);
    }
    private int passengerReadyRows(String period, String mode, List<String> notes) {
        return addReadyCount(() -> passengerMapper.selectCount(periodQw(period, mode, PassengerEnterpriseMonthly::getReportPeriod)), "H203-1 旅客月报", notes)
             + addReadyCount(() -> passengerIndividualMapper.selectCount(periodQw(period, mode, PassengerIndividualMonthly::getReportPeriod)), "个体客运量/周转量", notes);
    }
    private <T> LambdaQueryWrapper<T> periodQw(String period, String mode, SFunction<T, ?> periodGetter) {
        LambdaQueryWrapper<T> qw = new LambdaQueryWrapper<>();
        if ("year".equals(mode)) {
            qw.likeRight(periodGetter, period);
        } else {
            qw.eq(periodGetter, period);
        }
        return qw;
    }
    private int addReadyCount(java.util.function.Supplier<Long> counter, String label, List<String> notes) {
        Long cnt = counter.get();
        if (cnt == null || cnt <= 0) notes.add("缺数据:" + label);
        return cnt == null ? 0 : cnt.intValue();
    }
    /** 该报表期该审核类型已产生的审核记录数(先审核再出表的前置检查) */
    private int auditRowsOf(String period, String mode, String ruleType) {
        List<AuditRule> rules = auditRuleMapper.selectList(new LambdaQueryWrapper<AuditRule>()
            .eq(AuditRule::getReportType, ruleType)
            .eq(AuditRule::getIsEnabled, 1));
        if (rules.isEmpty()) return 0;
        List<Long> ruleIds = new ArrayList<>();
        for (AuditRule r : rules) ruleIds.add(r.getId());
        LambdaQueryWrapper<AuditResult> qw = new LambdaQueryWrapper<>();
        if ("year".equals(mode)) qw.likeRight(AuditResult::getReportPeriod, period);
        else qw.eq(AuditResult::getReportPeriod, period);
        Long cnt = auditResultMapper.selectCount(qw.in(AuditResult::getRuleId, ruleIds));
        return cnt == null ? 0 : cnt.intValue();
    }
    /** 该报表期该审核类型是否已有「审核通过标记」(audit_run 表;线下审核通过/系统审核执行后写入,导出前不再提示未审核) */
    private boolean auditedMarked(String period, String mode, String ruleType) {
        LambdaQueryWrapper<AuditRun> qw = new LambdaQueryWrapper<AuditRun>()
            .eq(AuditRun::getReportType, ruleType);
        if ("year".equals(mode)) qw.likeRight(AuditRun::getReportPeriod, period);
        else qw.eq(AuditRun::getReportPeriod, period);
        Long cnt = auditRunMapper.selectCount(qw);
        return cnt != null && cnt > 0;
    }
    // ==================== 1. 生成_货运量分市州明细.xlsx ====================
    public byte[] exportCityDetail(String period) throws Exception {
        int currentMonth = parseMonth(period);
        String year = period.substring(0, 4);
    public byte[] exportCityDetail(String period, String mode) throws Exception {
        int currentMonth = monthOf(period, mode);
        int fillMonths = currentMonth; // 1..N 月(N>6 时模板自动向右扩列到 12 月)
        boolean extended = fillMonths > 6;
        Map<String, Map<Integer, ScaleSplitTransport>> monthData = loadMonthData(period);
        Map<Integer, ScaleSplitTransport> provinceMonthMap = loadProvinceMonthMap(period);
        ScaleSplitTransport provinceCum = getProvinceCumulative(period);
@@ -51,85 +814,67 @@
        Map<String, Double> h2032FreightCum = loadH2032FreightCumulative(period);
        Map<String, FreightTurnoverImport> ftMap = loadFreightTurnover(period);
        XSSFWorkbook wb = new XSSFWorkbook();
        Sheet sheet = wb.createSheet("货运量分市州明细");
        Row r1 = sheet.createRow(0);
        r1.createCell(0).setCellValue("报表期:" + year + "年" + currentMonth + "月");
        sheet.createRow(1);
        Row header = sheet.createRow(2);
        header.createCell(0).setCellValue("地区 名称");
        header.createCell(1).setCellValue("指标");
        CellStyle dateStyle = wb.createCellStyle();
        dateStyle.setDataFormat(wb.getCreationHelper().createDataFormat().getFormat("yyyy-mm-dd"));
        for (int m = 1; m <= currentMonth; m++) {
            Cell c = header.createCell(2 + (m - 1) * 2);
            c.setCellValue(java.sql.Date.valueOf(year + "-" + pad(m) + "-01"));
            c.setCellStyle(dateStyle);
            header.createCell(3 + (m - 1) * 2).setCellValue(m + "月与去年同比");
        }
        int cumCol = 2 + currentMonth * 2;
        header.createCell(cumCol).setCellValue("累计周转量");
        header.createCell(cumCol + 1).setCellValue("同比");
        sheet.createRow(3);
        int rowIdx = 4;
        String[][] provinceMetrics = {
            {"货运量         (万吨)", "freight", "total"},
            {"货物周转量     (万吨公里)", "turnover", "total"},
            {"其中规上货运量   (万吨)", "freight", "above"},
            {"其中规上货物周转量  (万吨公里)", "turnover", "above"},
            {"其中规下货运量   (万吨)", "freight", "below"},
            {"其中规下货物周转量  (万吨公里)", "turnover", "below"}
        };
        FreightTurnoverImport ftProvince = ftMap.get("湖北省");
        for (String[] metric : provinceMetrics) {
            Row row = sheet.createRow(rowIdx++);
            row.createCell(0).setCellValue("全省");
            row.createCell(1).setCellValue(metric[0]);
            for (int m = 1; m <= currentMonth; m++) {
                setNumeric(row, 2 + (m - 1) * 2, getProvinceMonthValue(provinceMonthMap, ftProvince, m, metric[1], metric[2], h2032Freight));
                setNumeric(row, 3 + (m - 1) * 2, getProvinceMonthYoy(provinceMonthMap, ftProvince, m, metric[1], metric[2]));
            }
            setNumeric(row, cumCol, getProvinceCumValue(provinceCum, ftProvince, metric[1], metric[2], h2032FreightCum, currentMonth));
            setNumeric(row, cumCol + 1, getProvinceCumYoy(provinceCum, ftProvince, metric[1], metric[2], currentMonth));
        }
        for (String city : RegionUtil.cityList()) {
            String[][] metrics = {
                {"规上+规下货运量(万吨)", "freight", "total"},
                {"规上货运量      (万吨)", "freight", "above"},
                {"规下货运量      (万吨)", "freight", "below"},
                {"规上+规下周转量  (万吨公里)", "turnover", "total"},
                {"规上货物周转量  (万吨公里)", "turnover", "above"},
                {"规下货物周转量  (万吨公里)", "turnover", "below"}
        // 以 docs/货运/生成_货运量分市州明细.xlsx 为底稿:保留标题/表头/合并/列宽/样式,仅替换数据区
        File template = resolveFreightTemplate("生成_货运量分市州明细.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            if (extended) extendMonthlyColumns(sheet, 2, Integer.parseInt(period.substring(0, 4)), 4, sheet.getLastRowNum());
            String[][] provinceMetrics = {
                {"货运量         (万吨)", "freight", "total"},
                {"货物周转量     (万吨公里)", "turnover", "total"},
                {"其中规上货运量   (万吨)", "freight", "above"},
                {"其中规上货物周转量  (万吨公里)", "turnover", "above"},
                {"其中规下货运量   (万吨)", "freight", "below"},
                {"其中规下货物周转量  (万吨公里)", "turnover", "below"}
            };
            FreightTurnoverImport ftCity = ftMap.get(city);
            ScaleSplitTransport cum = cumMap.get(city);
            for (String[] metric : metrics) {
                Row row = sheet.createRow(rowIdx++);
                row.createCell(0).setCellValue(RegionUtil.shortName(city));
                row.createCell(1).setCellValue(metric[0]);
                for (int m = 1; m <= currentMonth; m++) {
                    setNumeric(row, 2 + (m - 1) * 2, getCityMonthValue(monthData, ftCity, city, m, metric[1], metric[2], h2032Freight));
                    setNumeric(row, 3 + (m - 1) * 2, getCityMonthYoy(monthData, ftCity, city, m, metric[1], metric[2]));
                }
                setNumeric(row, cumCol, getCityCumValue(cum, ftCity, metric[1], metric[2], h2032FreightCum, city, currentMonth));
                setNumeric(row, cumCol + 1, getCityCumYoy(cum, ftCity, metric[1], metric[2], currentMonth));
            FreightTurnoverImport ftProvince = ftMap.get("湖北省");
            int rowIdx = 4; // 0-based:第5行起为 全省 6 指标
            for (String[] metric : provinceMetrics) {
                Row row = sheet.getRow(rowIdx);
                if (row == null) row = sheet.createRow(rowIdx);
                fillFreightDetailRow(row, fillMonths, extended ? 26 : 14,
                    m -> getProvinceMonthValue(provinceMonthMap, ftProvince, m, metric[1], metric[2], h2032Freight),
                    m -> getProvinceMonthYoy(provinceMonthMap, ftProvince, m, metric[1], metric[2]),
                    getProvinceCumValue(provinceCum, ftProvince, metric[1], metric[2], h2032FreightCum, currentMonth),
                    getProvinceCumYoy(provinceCum, ftProvince, metric[1], metric[2], currentMonth));
                rowIdx++;
            }
            for (String city : RegionUtil.cityList()) {
                String[][] metrics = {
                    {"规上+规下货运量(万吨)", "freight", "total"},
                    {"规上货运量      (万吨)", "freight", "above"},
                    {"规下货运量      (万吨)", "freight", "below"},
                    {"规上+规下周转量  (万吨公里)", "turnover", "total"},
                    {"规上货物周转量  (万吨公里)", "turnover", "above"},
                    {"规下货物周转量  (万吨公里)", "turnover", "below"}
                };
                FreightTurnoverImport ftCity = ftMap.get(city);
                ScaleSplitTransport cum = cumMap.get(city);
                for (String[] metric : metrics) {
                    Row row = sheet.getRow(rowIdx);
                    if (row == null) row = sheet.createRow(rowIdx);
                    fillFreightDetailRow(row, fillMonths, extended ? 26 : 14,
                        m -> getCityMonthValue(monthData, ftCity, city, m, metric[1], metric[2], h2032Freight),
                        m -> getCityMonthYoy(monthData, ftCity, city, m, metric[1], metric[2]),
                        getCityCumValue(cum, ftCity, metric[1], metric[2], h2032FreightCum, city, currentMonth),
                        getCityCumYoy(cum, ftCity, metric[1], metric[2], currentMonth));
                    rowIdx++;
                }
            }
            return toBytes(wb);
        }
        autoWidth(sheet, cumCol + 2);
        return toBytes(wb);
    }
    // ==================== 2. 生成_货运量排名.xlsx ====================
    public byte[] exportFreightRank(String period) throws Exception {
    public byte[] exportFreightRank(String period, String mode) throws Exception {
        String year = period.substring(0, 4);
        int month = parseMonth(period);
        int month = monthOf(period, mode);
        Map<String, FreightTurnoverImport> ftMap = loadFreightTurnover(period);
        Map<String, Double> h2032FreightCum = loadH2032FreightCumulative(period);
        Map<String, Double> h2032FreightYoy = computeH2032Yoy(period);
        Map<String, Double> h2032FreightCumLast = loadH2032FreightCumulative(
            (Integer.parseInt(period.substring(0, 4)) - 1) + period.substring(4));
        Map<String, Double> above = new HashMap<>();
        Map<String, Double> below = new HashMap<>();
@@ -142,94 +887,524 @@
        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 : (provAbove - provAboveLast) / provAboveLast;
        Double provTotalLast = lastFreightCum(ftMap.get("湖北省"), month);
        Double provBelowLast = provTotalLast == null ? null : provTotalLast - provAboveLast / 10000.0;
        Double provBelowYoy = provBelowLast == null || provBelowLast == 0.0 ? null
            : (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));
            belowYoy.put(city, null);
            // 规下同比 = (规下今年 - 规下去年) / 规下去年;规下去年 = 模板去年合计 - H2032去年规上
            Double tLast = lastFreightCum(ftMap.get(city), month);
            Double aLast = h2032FreightCumLast.get(city);
            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 : (b - belowLast) / belowLast);
            totalYoy.put(city, freightCumYoy(ftMap.get(city), month));
        }
        XSSFWorkbook wb = new XSSFWorkbook();
        Sheet sheet = wb.createSheet("货运量排名");
        Row title = sheet.createRow(0);
        title.createCell(0).setCellValue(year + "年1-" + month + "月全省分市州累计完成公路货运量情况");
        int rowIdx = 1;
        rowIdx = writeRankHead(sheet, rowIdx, "货运量", "吨");
        rowIdx = writeFreightRankRows(sheet, rowIdx, null, above, below, total, aboveYoy, belowYoy, totalYoy,
            provAboveWan, provBelow, provTotal, null, null, provTotalYoy, true);
        // ===== 第二页(当月)数据:规上=H2032 当月、合计=模板_货运量周转量当月列、规下=差值;同比=去年同月 =====
        Map<String, Map<Integer, Double>> h2032MonthCur = loadH2032FreightByMonth(period);
        String lastYearPeriod = (Integer.parseInt(year) - 1) + period.substring(4);
        Map<String, Map<Integer, Double>> h2032MonthLast = loadH2032FreightByMonth(lastYearPeriod);
        Map<String, Double> monthAbove = new HashMap<>();
        Map<String, Double> monthBelow = new HashMap<>();
        Map<String, Double> monthTotal = new HashMap<>();
        Map<String, Double> monthAboveYoy = new HashMap<>();
        Map<String, Double> monthBelowYoy = new HashMap<>();
        Map<String, Double> monthTotalYoy = new HashMap<>();
        double provMonthAbove = 0.0, provMonthAboveLast = 0.0;
        for (String city : RegionUtil.cityList()) {
            rowIdx = writeFreightRankRows(sheet, rowIdx, city, above, below, total, aboveYoy, belowYoy, totalYoy,
                provAboveWan, provBelow, provTotal, null, null, provTotalYoy, false);
            Double ma = monthFreightWan(h2032MonthCur, city, month);
            Double maLast = monthFreightWan(h2032MonthLast, city, month);
            Double mt = freightMonth(ftMap.get(city), month);
            Double mtLast = lastFreightMonth(ftMap.get(city), month);
            if (ma != null) provMonthAbove += ma;
            if (maLast != null) provMonthAboveLast += maLast;
            monthAbove.put(city, ma);
            monthTotal.put(city, mt);
            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 : 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 : provMonthAbove;
        Double provMonthTotal = freightMonth(ftMap.get("湖北省"), month);
        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
            : provMonthTotalLast - provMonthAboveLast;
        Double provMonthBelowYoy = growthYoy(provMonthBelow, provMonthBelowLast);
        Double provMonthTotalYoy = freightYoy(ftMap.get("湖北省"), month);
        // 本月区块(当月标题 + 累计数据,与样例结构一致)
        sheet.createRow(rowIdx++);
        Row title2 = sheet.createRow(rowIdx++);
        title2.createCell(0).setCellValue(year + "年" + month + "月全省分市州累计完成" + "公路货运量情况");
        rowIdx = writeRankHead(sheet, rowIdx, "货运量", "吨");
        rowIdx = writeFreightRankRows(sheet, rowIdx, null, above, below, total, aboveYoy, belowYoy, totalYoy,
            provAboveWan, provBelow, provTotal, null, null, provTotalYoy, true);
        for (String city : RegionUtil.cityList()) {
            rowIdx = writeFreightRankRows(sheet, rowIdx, city, above, below, total, aboveYoy, belowYoy, totalYoy,
                provAboveWan, provBelow, provTotal, null, null, provTotalYoy, false);
        // 以 docs/货运/生成_货运量排名.xlsx 为底稿:保留表头/合并/列宽/样式与 Q/R 占比公式,仅替换数据
        File template = resolveFreightTemplate("生成_货运量排名.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            sheet.getRow(0).getCell(0).setCellValue(year + "年" + cumRange(month) + "全省分市州累计完成公路货运量情况");
            sheet.getRow(22).getCell(0).setCellValue(year + "年" + month + "月全省分市州完成公路货运量情况");
            fillFreightRankBlock(sheet, 3, above, below, total, aboveYoy, belowYoy, totalYoy,
                provAboveWan, provBelow, provTotal, provAboveYoy, provBelowYoy, provTotalYoy);
            renameRankMonthHeader(sheet, 24);
            fillFreightRankBlock(sheet, 25, monthAbove, monthBelow, monthTotal,
                monthAboveYoy, monthBelowYoy, monthTotalYoy,
                provMonthAboveWan, provMonthBelow, provMonthTotal,
                provMonthAboveYoy, provMonthBelowYoy, provMonthTotalYoy);
            try {
                wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
            } catch (Exception e) {
                log.warn("货运量排名模板公式求值失败: {}", e.getMessage());
            }
            return toBytes(wb);
        }
        // 末尾空行(与样例结构对齐)
        for (int i = 0; i < 4; i++) sheet.createRow(rowIdx++);
        autoWidth(sheet, 18);
        return toBytes(wb);
    }
    // ==================== 3. 生成_周转量排名.xlsx ====================
    public byte[] exportTurnoverRank(String period) throws Exception {
    public byte[] exportTurnoverRank(String period, String mode) throws Exception {
        String year = period.substring(0, 4);
        int month = parseMonth(period);
        int month = monthOf(period, mode);
        Map<String, ScaleSplitTransport> cumMap = loadCumulativeMap(period);
        ScaleSplitTransport province = getProvinceCumulative(period);
        XSSFWorkbook wb = new XSSFWorkbook();
        Sheet sheet = wb.createSheet("周转量排名");
        Row title = sheet.createRow(0);
        title.createCell(0).setCellValue(year + "年1-" + month + "月全省分市州累计完成公路货物运输周转量情况");
        int rowIdx = 1;
        rowIdx = writeRankHead(sheet, rowIdx, "周转量", "吨公里");
        rowIdx = writeTurnoverRankRows(sheet, rowIdx, null, cumMap, province, true);
        for (String city : RegionUtil.cityList()) {
            rowIdx = writeTurnoverRankRows(sheet, rowIdx, city, cumMap, province, false);
        // 当月(第二页):周转量拆分表 MONTH 行(含当月同比)
        Map<String, ScaleSplitTransport> monthTurnover = new HashMap<>();
        for (ScaleSplitTransport rec : scaleSplitMapper.selectList(
                new LambdaQueryWrapper<ScaleSplitTransport>()
                    .eq(ScaleSplitTransport::getPeriodType, "MONTH")
                    .eq(ScaleSplitTransport::getReportPeriod, period))) {
            String name = RegionUtil.normalizeCityName(rec.getRegionName());
            if (name != null) monthTurnover.put(name, rec);
        }
        ScaleSplitTransport provinceMonth = monthTurnover.remove("湖北省");
        // 本月区块(当月标题 + 累计数据,与样例结构一致)
        sheet.createRow(rowIdx++);
        Row title2 = sheet.createRow(rowIdx++);
        title2.createCell(0).setCellValue(year + "年" + month + "月全省分市州累计完成" + "公路货物运输周转量情况");
        rowIdx = writeRankHead(sheet, rowIdx, "周转量", "吨公里");
        rowIdx = writeTurnoverRankRows(sheet, rowIdx, null, cumMap, province, true);
        for (String city : RegionUtil.cityList()) {
            rowIdx = writeTurnoverRankRows(sheet, rowIdx, city, cumMap, province, false);
        // 以 docs/货运/生成_周转量排名.xlsx 为底稿:保留表头/合并/列宽/样式,仅替换数据;Q/R 占比为模板缓存值,按新数据重算
        File template = resolveFreightTemplate("生成_周转量排名.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            sheet.getRow(0).getCell(0).setCellValue(year + "年" + cumRange(month) + "全省分市州累计完成公路货物运输周转量情况");
            sheet.getRow(22).getCell(0).setCellValue(year + "年" + month + "月全省分市州完成公路货物运输周转量情况");
            fillTurnoverRankBlock(sheet, 3, cumMap, province);
            renameRankMonthHeader(sheet, 24);
            fillTurnoverRankBlock(sheet, 25, monthTurnover, provinceMonth);
            return toBytes(wb);
        }
        // 末尾空行(与样例结构对齐)
        for (int i = 0; i < 4; i++) sheet.createRow(rowIdx++);
        autoWidth(sheet, 18);
        return toBytes(wb);
    }
    /** 货运报表模板定位(docs/货运) */
    private File resolveFreightTemplate(String fileName) throws Exception {
        return resolveAnyTemplate(freightTemplateDir, fileName);
    }
    /** 写货运模板数据单元格:公式列不动;有值先清空再写(POI 对公式单元格只更新缓存),无值清空模板样例 */
    private void setFreightCell(Row row, int idx, Double v) {
        Cell c = row.getCell(idx);
        if (c != null && c.getCellType() == CellType.FORMULA) return;
        if (c == null) c = row.createCell(idx);
        c.setBlank();
        if (v != null) c.setCellValue(v);
    }
    private void setFreightCell(Row row, int idx, Integer v) {
        if (v == null) {
            setFreightCell(row, idx, (Double) null);
        } else {
            setFreightCell(row, idx, v.doubleValue());
        }
    }
    // ==================== 月度模板自动扩列(1-6月 → 1-12月) ====================
    /** 月度模板向右扩列:累计列(14/15)移到末尾(26/27),7..12 月列由 1 月列克隆(表头/样式/公式引用同步) */
    private void extendMonthlyColumns(Sheet sheet, int headerRow, int year, int dataStartRow, int dataEndRow) {
        moveColumnPair(sheet, headerRow, 14, 26, dataEndRow); // 累计/累计同比 → AA/AB
        for (int m = 7; m <= 12; m++) {
            int dst = 2 + (m - 1) * 2;
            cloneColumnPair(sheet, headerRow, 2, 3, dst, dst + 1, year, m, dataStartRow, dataEndRow);
        }
        fixCumulativeFormulas(sheet, headerRow, dataEndRow); // AA 累计公式 = 12 个月值之和
    }
    /** 把一列对(值列+同比列)从 src 移到 dst(含表头/数据/样式/合并/列宽),src 内容清空 */
    private void moveColumnPair(Sheet sheet, int headerRow, int src, int dst, int lastRow) {
        for (int r = headerRow; r <= lastRow; r++) {
            copyCellForColumn(sheet, r, src, r, dst, false);
            copyCellForColumn(sheet, r, src + 1, r, dst + 1, false);
            clearCellContent(sheet, r, src);
            clearCellContent(sheet, r, src + 1);
        }
        moveMergedRegion(sheet, src, dst);
        moveMergedRegion(sheet, src + 1, dst + 1);
        copyColumnWidth(sheet, src, dst);
        copyColumnWidth(sheet, src + 1, dst + 1);
    }
    /** 把 1 月列对克隆为 m 月列对:表头文本/日期修正、样式复制、非跨簿公式克隆并同步列引用 */
    private void cloneColumnPair(Sheet sheet, int headerRow, int srcVal, int srcYoy, int dstVal, int dstYoy,
                                 int year, int month, int dataStartRow, int dataEndRow) {
        for (int r = headerRow; r <= dataEndRow; r++) {
            boolean dataZone = r >= dataStartRow;
            cloneCellForMonth(sheet, r, srcVal, dstVal, year, month, dataZone);
            cloneCellForMonth(sheet, r, srcYoy, dstYoy, year, month, dataZone);
        }
        cloneMergedRegion(sheet, srcVal, dstVal);
        cloneMergedRegion(sheet, srcYoy, dstYoy);
        copyColumnWidth(sheet, srcVal, dstVal);
        copyColumnWidth(sheet, srcYoy, dstYoy);
    }
    /** 复制单格:move=false 原样复制;move=true 表头文本/日期修正、数据区仅样式+非跨簿公式(列引用同步) */
    private void copyCellForColumn(Sheet sheet, int r, int srcC, int dstR, int dstC, boolean monthClone) {
        Row srcRow = sheet.getRow(r);
        if (srcRow == null) return;
        Cell src = srcRow.getCell(srcC);
        if (src == null) return;
        Cell dst = getOrCreateCell(sheet, dstR, dstC);
        dst.setBlank();
        dst.setCellStyle(src.getCellStyle());
        if (!monthClone) {
            if (src.getCellType() == CellType.FORMULA) {
                String f = src.getCellFormula();
                if (f != null && !f.contains("#REF!")) dst.setCellFormula(f);
            } else if (src.getCellType() == CellType.STRING) {
                dst.setCellValue(src.getStringCellValue());
            } else if (src.getCellType() == CellType.NUMERIC) {
                dst.setCellValue(src.getNumericCellValue());
            } else if (src.getCellType() == CellType.BOOLEAN) {
                dst.setCellValue(src.getBooleanCellValue());
            }
        }
    }
    /** 克隆单格到目标月列:表头行(dataZone=false)修正月份/年份文本或日期;数据区仅样式+非跨簿公式克隆 */
    private void cloneCellForMonth(Sheet sheet, int r, int srcC, int dstC, int year, int month, boolean dataZone) {
        Row srcRow = sheet.getRow(r);
        if (srcRow == null) return;
        Cell src = srcRow.getCell(srcC);
        if (src == null) return;
        Cell dst = getOrCreateCell(sheet, r, dstC);
        dst.setBlank();
        dst.setCellStyle(src.getCellStyle());
        if (!dataZone) {
            if (src.getCellType() == CellType.NUMERIC && org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(src)) {
                java.util.Calendar cal = java.util.Calendar.getInstance();
                cal.set(year, month - 1, 1, 0, 0, 0);
                cal.clear(java.util.Calendar.MILLISECOND);
                dst.setCellValue(cal.getTime());
            } else if (src.getCellType() == CellType.STRING) {
                String t = src.getStringCellValue();
                if (t != null && !t.isEmpty()) {
                    dst.setCellValue(t.replaceFirst("^\\d{4}年", year + "年").replaceFirst("(\\d+)月", month + "月"));
                }
            }
            return;
        }
        // 数据区:仅复制样式;非跨簿、非 #REF! 的公式克隆并同步月值列引用
        if (src.getCellType() == CellType.FORMULA) {
            String f = src.getCellFormula();
            if (f != null && !f.contains("[") && !f.contains("#REF!")) {
                dst.setCellFormula(shiftMonthFormula(f, colLetter(dstC)));
            }
        }
    }
    /** 公式列引用平移:本表月值列(C/E/G/I/K/M/O/Q/S/U/W/Y)→ 目标列字母;排除跨簿 [..]! 引用 */
    private String shiftMonthFormula(String formula, String dstColLetter) {
        return formula.replaceAll("(?<![A-Za-z0-9\\[\\]!])([CEGIKMOQSUWY])(\\d+)", dstColLetter + "$2");
    }
    /** AA 累计列公式重写为 12 个月值之和(模板原为 1-6 月之和) */
    private void fixCumulativeFormulas(Sheet sheet, int headerRow, int dataEndRow) {
        for (int r = headerRow; r <= dataEndRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            Cell c = row.getCell(26);
            if (c == null || c.getCellType() != CellType.FORMULA) continue;
            String f = c.getCellFormula();
            if (f == null) continue;
            java.util.regex.Matcher m = java.util.regex.Pattern
                .compile("(?<![A-Za-z0-9\\[\\]!])([CEGIKMOQSUWY])(\\d+)").matcher(f);
            if (!m.find()) continue;
            int rowNum = Integer.parseInt(m.group(2));
            StringBuilder sb = new StringBuilder();
            for (char col : new char[]{'C', 'E', 'G', 'I', 'K', 'M', 'O', 'Q', 'S', 'U', 'W', 'Y'}) {
                if (sb.length() > 0) sb.append("+");
                sb.append(col).append(rowNum);
            }
            c.setCellFormula(sb.toString());
        }
    }
    /** 合并单元格:src 单列合并 → dst 列(同形状),并移除原合并 */
    private void moveMergedRegion(Sheet sheet, int srcCol, int dstCol) {
        java.util.List<CellRangeAddress> moves = new java.util.ArrayList<>();
        for (CellRangeAddress m : sheet.getMergedRegions()) {
            if (m.getFirstColumn() == srcCol && m.getLastColumn() == srcCol) moves.add(m);
        }
        for (CellRangeAddress m : moves) {
            sheet.addMergedRegion(new CellRangeAddress(m.getFirstRow(), m.getLastRow(), dstCol, dstCol));
            for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
                if (sheet.getMergedRegion(i).equals(m)) {
                    sheet.removeMergedRegion(i);
                    break;
                }
            }
        }
    }
    /** 合并单元格:src 单列合并 → dst 列(同形状,保留原合并) */
    private void cloneMergedRegion(Sheet sheet, int srcCol, int dstCol) {
        for (CellRangeAddress m : sheet.getMergedRegions()) {
            if (m.getFirstColumn() == srcCol && m.getLastColumn() == srcCol) {
                sheet.addMergedRegion(new CellRangeAddress(m.getFirstRow(), m.getLastRow(), dstCol, dstCol));
            }
        }
    }
    private void copyColumnWidth(Sheet sheet, int src, int dst) {
        try {
            int w = sheet.getColumnWidth(src);
            if (w > 0) sheet.setColumnWidth(dst, w);
        } catch (Exception ignore) { }
    }
    private Cell getOrCreateCell(Sheet sheet, int r, int c) {
        Row row = sheet.getRow(r);
        if (row == null) row = sheet.createRow(r);
        Cell cell = row.getCell(c);
        if (cell == null) cell = row.createCell(c);
        return cell;
    }
    private void clearCellContent(Sheet sheet, int r, int c) {
        Row row = sheet.getRow(r);
        if (row == null) return;
        Cell cell = row.getCell(c);
        if (cell != null) cell.setBlank();
    }
    private String colLetter(int colIdx) {
        return org.apache.poi.ss.util.CellReference.convertNumToColString(colIdx);
    }
    /** 旅客分市州月值(0=客运量/1=周转量/2=个体客运量/3=个体周转量;0/1=企业H2031+个体,2026-09-03 口径确认) */
    private double passengerMonthVal(Map<Integer, Map<String, PassengerAgg>> data, Map<Integer, Map<String, double[]>> indi,
                                     int year, int month, String region, int metricIdx) {
        if (metricIdx == 0 || metricIdx == 1) { // 客运量/周转量总行:企业+个体
            PassengerAgg agg = aggOf(data, year, month, region);
            double ent = agg == null ? 0.0
                : (metricIdx == 0 ? agg.passengerTotal / 10000.0 : agg.turnoverTotal / 10000.0);
            double ind = individualVal(indi, year, month, region, metricIdx == 0 ? 0 : 1);
            return ent + ind;
        }
        return individualVal(indi, year, month, region, metricIdx - 2);
    }
    /** 旅客累计同比(去年 1..months 月累计):无去年数据返回 null */
    private Double passengerCumYoy(Map<Integer, Map<String, PassengerAgg>> data, Map<Integer, Map<String, double[]>> indi,
                                   int year, int months, String region, int metricIdx) {
        double v = 0.0, lv = 0.0;
        for (int m = 1; m <= months; m++) {
            v += passengerMonthVal(data, indi, year, m, region, metricIdx);
            lv += passengerMonthVal(data, indi, year - 1, m, region, metricIdx);
        }
        return lv == 0.0 ? null : (v - lv) / lv;
    }
    /** 中口径月同比:k=-1 表示总行(班线+公交+出租城乡+网约车城乡 四项之和);无去年数据留空 */
    private void fillMidYoyCell(Cell c, Map<Integer, Map<String, double[][]>> mid, int year, int month,
                                String area, int k, boolean volume) {
        Double y = midMonthYoy(mid, year, month, area, k, volume);
        c.setBlank();
        if (y != null) c.setCellValue(y);
    }
    /** 中口径累计同比列(1..months 月累计;四维齐全) */
    private void fillMidCumYoyColumn(Sheet sheet, Map<Integer, Map<String, double[][]>> mid, int year,
                                     int months, int colIdx) {
        List<String> areas = midTemplateAreas();
        for (int r0 = 4; r0 <= sheet.getLastRowNum(); r0++) {
            int rr = r0 + 1;
            int blockOffset = -1;
            boolean volume = false;
            if (rr >= 5 && rr <= 94) {
                blockOffset = rr - 5;
                volume = true;
            } else if (rr >= 99 && rr <= 188) {
                blockOffset = rr - 99;
                volume = false;
            }
            if (blockOffset < 0 || blockOffset % 5 == 0) continue;
            int k = blockOffset % 5;
            String area = areas.get(blockOffset / 5);
            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 (y != null) c.setCellValue(y);
        }
    }
    /** 货运分市州明细行填充:1..fillMonths 月值/同比(最多 12 月,其余清空)+ 累计/累计同比(cumCol 起两列) */
    private void fillFreightDetailRow(Row row, int fillMonths, int cumCol,
                                      java.util.function.IntFunction<Double> monthValue,
                                      java.util.function.IntFunction<Double> monthYoy,
                                      Double cumValue, Double cumYoy) {
        for (int m = 1; m <= (fillMonths > 6 ? 12 : 6); m++) {
            if (m <= fillMonths) {
                setFreightCell(row, 2 + (m - 1) * 2, monthValue.apply(m));
                setFreightCell(row, 3 + (m - 1) * 2, monthYoy.apply(m));
            } else {
                setFreightCell(row, 2 + (m - 1) * 2, (Double) null);
                setFreightCell(row, 3 + (m - 1) * 2, (Double) null);
            }
        }
        setFreightCell(row, cumCol, cumValue);
        setFreightCell(row, cumCol + 1, cumYoy);
    }
    /** H2032 规上某月合计(吨→万吨);无该月数据返回 null */
    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 : v / 10000.0;
    }
    /** 同比 = (本期-基期)/基期;任一期缺失或基期为 0 返回 null */
    private Double growthYoy(Double cur, Double last) {
        if (cur == null || last == null || last == 0.0) return null;
        return (cur - last) / last;
    }
    /** 排名表第二页列头「累计完成」→「当月完成」(仅导出内存替换,模板文件不改) */
    private void renameRankMonthHeader(Sheet sheet, int headerRowIdx) {
        Row row = sheet.getRow(headerRowIdx);
        if (row == null) return;
        for (int col : new int[]{1, 6, 11}) {
            Cell c = row.getCell(col);
            if (c == null || c.getCellType() != CellType.STRING) continue;
            String s = c.getStringCellValue();
            if (s != null && s.contains("累计完成")) c.setCellValue(s.replace("累计完成", "当月完成"));
        }
    }
    /** 货运量排名块填充:模板 1-based r4 起(全省+17市州),Q/R 占比公式保留不动 */
    private void fillFreightRankBlock(Sheet sheet, int start,
                                      Map<String, Double> above, Map<String, Double> below, Map<String, Double> total,
                                      Map<String, Double> aboveYoy, Map<String, Double> belowYoy, Map<String, Double> totalYoy,
                                      Double provAbove, Double provBelow, Double provTotal,
                                      Double provAboveYoy, Double provBelowYoy, Double provTotalYoy) {
        Row provRow = sheet.getRow(start);
        if (provRow == null) provRow = sheet.createRow(start);
        setFreightCell(provRow, 1, provAbove);
        setFreightCell(provRow, 4, provAboveYoy);
        setFreightCell(provRow, 6, provBelow);
        setFreightCell(provRow, 9, provBelowYoy);
        setFreightCell(provRow, 11, provTotal);
        setFreightCell(provRow, 14, provTotalYoy);
        int rowIdx = start + 1;
        for (String city : RegionUtil.cityList()) {
            Row row = sheet.getRow(rowIdx);
            if (row == null) row = sheet.createRow(rowIdx);
            setFreightCell(row, 1, above.get(city));
            setFreightCell(row, 2, rankOfMap(above, city));
            setFreightCell(row, 3, ratioOf(above.get(city), provAbove));
            setFreightCell(row, 4, aboveYoy.get(city));
            setFreightCell(row, 5, yoyRankOfMap(aboveYoy, city));
            setFreightCell(row, 6, below.get(city));
            setFreightCell(row, 7, rankOfMap(below, city));
            setFreightCell(row, 8, ratioOf(below.get(city), provBelow));
            setFreightCell(row, 9, belowYoy.get(city));
            setFreightCell(row, 10, yoyRankOfMap(belowYoy, city));
            setFreightCell(row, 11, total.get(city));
            setFreightCell(row, 12, rankOfMap(total, city));
            setFreightCell(row, 13, ratioOf(total.get(city), provTotal));
            setFreightCell(row, 14, totalYoy.get(city));
            setFreightCell(row, 15, yoyRankOfMap(totalYoy, city));
            rowIdx++;
        }
    }
    /** 周转量排名块填充:模板 1-based r4 起(全省+17市州) */
    private void fillTurnoverRankBlock(Sheet sheet, int start,
                                       Map<String, ScaleSplitTransport> cumMap, ScaleSplitTransport province) {
        if (province == null) province = new ScaleSplitTransport();
        Double provAbove = province.getAboveScaleTurnover();
        Double provBelow = province.getBelowScaleTurnover();
        Double provTotal = province.getTotalTurnover();
        Row provRow = sheet.getRow(start);
        if (provRow == null) provRow = sheet.createRow(start);
        setFreightCell(provRow, 1, provAbove);
        setFreightCell(provRow, 4, province.getAboveScaleYoy());
        setFreightCell(provRow, 6, provBelow);
        setFreightCell(provRow, 9, province.getBelowScaleYoy());
        setFreightCell(provRow, 11, provTotal);
        setFreightCell(provRow, 14, province.getTotalYoy());
        setFreightShare(provRow, provAbove, provTotal);
        int rowIdx = start + 1;
        for (String city : RegionUtil.cityList()) {
            Row row = sheet.getRow(rowIdx);
            if (row == null) row = sheet.createRow(rowIdx);
            ScaleSplitTransport cum = cumMap.get(city);
            Double above = cum == null ? null : cum.getAboveScaleTurnover();
            Double below = cum == null ? null : cum.getBelowScaleTurnover();
            Double total = cum == null ? null : cum.getTotalTurnover();
            setFreightCell(row, 1, above);
            setFreightCell(row, 2, cum == null ? null : rankOfTurnover(cumMap, city, 0));
            setFreightCell(row, 3, ratioOf(above, provAbove));
            setFreightCell(row, 4, cum == null ? null : cum.getAboveScaleYoy());
            setFreightCell(row, 5, cum == null ? null : yoyRankOfTurnover(cumMap, city, 0));
            setFreightCell(row, 6, below);
            setFreightCell(row, 7, cum == null ? null : rankOfTurnover(cumMap, city, 1));
            setFreightCell(row, 8, ratioOf(below, provBelow));
            setFreightCell(row, 9, cum == null ? null : cum.getBelowScaleYoy());
            setFreightCell(row, 10, cum == null ? null : yoyRankOfTurnover(cumMap, city, 1));
            setFreightCell(row, 11, total);
            setFreightCell(row, 12, cum == null ? null : rankOfTurnover(cumMap, city, 2));
            setFreightCell(row, 13, ratioOf(total, provTotal));
            setFreightCell(row, 14, cum == null ? null : cum.getTotalYoy());
            setFreightCell(row, 15, cum == null ? null : yoyRankOfTurnover(cumMap, city, 2));
            setFreightShare(row, above, total);
            rowIdx++;
        }
    }
    /** 周转量排名 Q/R 占比:模板为缓存值,按新数据重算(全精度写入,两位小数展示交给单元格格式) */
    private void setFreightShare(Row row, Double above, Double total) {
        if (total != null && total > 0 && above != null) {
            double share = above * 10.0 / total;
            setFreightCell(row, 16, share);
            setFreightCell(row, 17, 10.0 - share);
        } else {
            setFreightCell(row, 16, (Double) null);
            setFreightCell(row, 17, (Double) null);
        }
    }
    // ==================== 数据加载 ====================
    /** 当月数据: city -> month -> record(不含全省) */
@@ -306,9 +1481,12 @@
    /** H2032规上: city -> month -> 货运量(吨) */
    private Map<String, Map<Integer, Double>> loadH2032FreightByMonth(String period) {
        Map<String, Map<Integer, Double>> result = new HashMap<>();
        String yearPrefix = period.length() >= 4 ? period.substring(0, 4) : null;
        List<H2032EnterpriseMonthly> list = h2032Mapper.selectList(null);
        for (H2032EnterpriseMonthly record : list) {
            if (record.getReportPeriod() == null || record.getReportPeriod().compareTo(period) > 0) continue;
            // H2032 表同时存有往年月报,统计当年规上值时只取与报表期同年的记录,避免去年数据叠加
            if (yearPrefix != null && !record.getReportPeriod().startsWith(yearPrefix)) continue;
            String city = RegionUtil.cityByCode(record.getRegionCode());
            if (city == null) continue;
            int month = parseMonth(record.getReportPeriod());
@@ -321,9 +1499,11 @@
    /** H2032规上累计: city -> 货运量(吨) */
    private Map<String, Double> loadH2032FreightCumulative(String period) {
        Map<String, Double> result = new HashMap<>();
        String yearPrefix = period.length() >= 4 ? period.substring(0, 4) : null;
        List<H2032EnterpriseMonthly> list = h2032Mapper.selectList(null);
        for (H2032EnterpriseMonthly record : list) {
            if (record.getReportPeriod() == null || record.getReportPeriod().compareTo(period) > 0) continue;
            if (yearPrefix != null && !record.getReportPeriod().startsWith(yearPrefix)) continue;
            String city = RegionUtil.cityByCode(record.getRegionCode());
            if (city == null) continue;
            double freight = record.getFreightTotal() == null ? 0.0 : record.getFreightTotal();
@@ -428,9 +1608,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;
    }
@@ -471,9 +1651,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;
    }
@@ -493,7 +1673,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);
@@ -524,7 +1704,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);
@@ -554,7 +1734,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);
@@ -583,7 +1763,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);
@@ -771,10 +1951,1183 @@
        return rank;
    }
    // ==================== 4. 生成_公路旅客分市州明细.xlsx(月报/年报) ====================
    /**
     * 公路旅客分市州明细
     * @param period 月报: 2026-06(今年1-6月);年报: 2026(全年12个月)
     * @param mode   month/year
     */
    /**
     * 生成_公路旅客分市州.xlsx:以 docs/公路旅客+能耗/输出 模板为底稿,
     * 保留表头/合并/列宽/公式/样式,仅替换 2026 年 1..6 月月度列数据(客运量/周转量/个体行)。
     * 2025/2024 历史列保留模板值;累计/同比/全省行公式保留并重算。
     */
    public byte[] exportPassengerCityDetail(String period, String mode) throws Exception {
        int maxMonth = monthOf(period, mode);
        int currentYear = Integer.parseInt(period.substring(0, 4));
        File template = resolvePassengerTemplate("生成_公路旅客分市州.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            fixPassengerHeaderYear(sheet, currentYear); // 表头年份动态化(不写死 2026)
            Map<Integer, Map<String, PassengerAgg>> data = loadPassengerAggMap();
            Map<Integer, Map<String, double[]>> indi = loadPassengerIndividualMap();
            int fillMonths = maxMonth; // 1..N 月(N>6 时模板自动向右扩列到 12 月)
            boolean extended = fillMonths > 6;
            if (extended) extendMonthlyColumns(sheet, 1, currentYear, 3, sheet.getLastRowNum());
            String currentRegion = null;
            for (int r = 3; r <= sheet.getLastRowNum(); r++) { // 从 R4 起:A 列合并行是区域标记(R4 全省、R8 武汉市…)
                Row row = sheet.getRow(r);
                if (row == null) continue;
                Cell nameCell = row.getCell(0);
                Cell metricCell = row.getCell(1);
                if (metricCell == null) continue;
                String areaName = cellText(nameCell);
                if (areaName != null && !areaName.trim().isEmpty()) {
                    currentRegion = "全省".equals(areaName.trim()) ? "湖北省" : RegionUtil.normalizeCityName(areaName.trim());
                }
                if (currentRegion == null) continue;
                String metric = cellText(metricCell);
                if (metric == null) continue;
                int metricIdx = cityMetricIndexOf(metric);
                if (metricIdx < 0) continue;
                String region = currentRegion;
                for (int m = 1; m <= fillMonths; m++) {
                    int col = 2 + (m - 1) * 2; // C,E,G,I,K,M,O,Q,S,U,W,Y(7-12 月为扩列列)
                    Cell c = row.getCell(col);
                    boolean keepFormula = c != null && c.getCellType() == CellType.FORMULA; // 全省/累计等公式保留
                    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(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((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(cumYoy);
            }
            recalc(wb);
            return toBytes(wb);
        }
    }
    /** 分市州表指标行:0=客运量 1=周转量 2=个体客运量 3=个体周转量;无法识别返回 -1 */
    private int cityMetricIndexOf(String metric) {
        if (metric == null) return -1;
        boolean indi = metric.contains("个体");
        if (metric.contains("客运量")) return indi ? 2 : 0;
        if (metric.contains("周转量")) return indi ? 3 : 1;
        return -1;
    }
    private String cellText(Cell c) {
        if (c == null) return null;
        if (c.getCellType() == CellType.STRING) return c.getStringCellValue();
        if (c.getCellType() == CellType.NUMERIC) return String.valueOf(c.getNumericCellValue());
        return null;
    }
    /** 个体客运量/周转量(万人/万人公里) */
    private double individualVal(Map<Integer, Map<String, double[]>> indi, int year, int month, String region, int idx) {
        Map<String, double[]> mm = indi.get(year * 100 + month);
        if (mm == null) return 0;
        double[] arr = mm.get(region);
        if (arr == null) return 0;
        return idx < arr.length ? arr[idx] : 0;
    }
    /** 个体客运量/周转量数据:年*100+月 -> (市州/全省 -> [个体客运量(万人), 个体周转量(万人公里)]) */
    private Map<Integer, Map<String, double[]>> loadPassengerIndividualMap() {
        Map<Integer, Map<String, double[]>> data = new HashMap<>();
        for (PassengerIndividualMonthly e : passengerIndividualMapper.selectList(null)) {
            if (e.getReportPeriod() == null) continue;
            String[] parts = e.getReportPeriod().split("-");
            if (parts.length != 2) continue;
            int y, m;
            try {
                y = Integer.parseInt(parts[0]);
                m = Integer.parseInt(parts[1]);
            } catch (NumberFormatException ex) {
                continue;
            }
            String city = RegionUtil.cityByCode(e.getRegionCode());
            if (city == null) continue;
            int key = y * 100 + m;
            double pass = nz(e.getPassengerCount()) / 10000.0;
            double turn = nz(e.getTurnover()) / 10000.0;
            double[] arr = data.computeIfAbsent(key, k -> new HashMap<>()).computeIfAbsent(city, k -> new double[2]);
            arr[0] += pass;
            arr[1] += turn;
            double[] prov = data.get(key).computeIfAbsent("湖北省", k -> new double[2]);
            prov[0] += pass;
            prov[1] += turn;
        }
        return data;
    }
    private PassengerAgg aggOf(Map<Integer, Map<String, PassengerAgg>> data, int year, int month, String region) {
        Map<String, PassengerAgg> monthMap = data.get(year * 100 + month);
        return monthMap == null ? null : monthMap.get(region);
    }
    private Double cumOf(Map<Integer, Map<String, PassengerAgg>> data, int year, String region, int monthCount, int metricIdx) {
        double sum = 0.0;
        boolean has = false;
        for (int m = 1; m <= monthCount; m++) {
            Double v = metricValue(aggOf(data, year, m, region), metricIdx);
            if (v != null) {
                sum += v;
                has = true;
            }
        }
        return has ? sum : null;
    }
    private Double yoy(Double cur, Double base) {
        if (cur == null || base == null || base == 0.0) return null;
        return (cur - base) / base;
    }
    /** 指标取值(已换算为万人/万人公里/公里;无数据返回 null) */
    private Double metricValue(PassengerAgg agg, int metric) {
        if (agg == null) return null;
        switch (metric) {
            case 0: return agg.passengerTotal / 10000.0;
            case 1: return agg.passengerTotal / 10000.0;
            case 2: return agg.passengerClass1 / 10000.0;
            case 3: return agg.passengerClass2 / 10000.0;
            case 4: return agg.passengerClass3 / 10000.0;
            case 5: return agg.passengerClass4 / 10000.0;
            case 6: return agg.passengerCharter / 10000.0;
            case 7:
            case 8:
            case 9: return null; // 城市客运模块数据,暂留空
            case 10: return agg.turnoverTotal / 10000.0;
            case 11: return agg.turnoverClass1 / 10000.0;
            case 12: return agg.turnoverClass2 / 10000.0;
            case 13: return agg.turnoverClass3 / 10000.0;
            case 14: return agg.turnoverClass4 / 10000.0;
            case 15: return agg.turnoverCharter / 10000.0;
            case 16: return ratioOrNull(agg.turnoverTotal, agg.passengerTotal);
            case 17: return ratioOrNull(agg.turnoverClass1, agg.passengerClass1);
            case 18: return ratioOrNull(agg.turnoverClass2, agg.passengerClass2);
            case 19: return ratioOrNull(agg.turnoverClass3, agg.passengerClass3);
            case 20: return ratioOrNull(agg.turnoverClass4, agg.passengerClass4);
            case 21: return ratioOrNull(agg.turnoverCharter, agg.passengerCharter);
            default: return null;
        }
    }
    private Double ratioOrNull(double numerator, double denominator) {
        if (denominator == 0.0) return null;
        return numerator / denominator;
    }
    /** 单月全市州企业汇总 */
    private static class PassengerAgg {
        double passengerTotal;
        double passengerClass1;
        double passengerClass2;
        double passengerClass3;
        double passengerClass4;
        double passengerCharter;
        double turnoverTotal;
        double turnoverClass1;
        double turnoverClass2;
        double turnoverClass3;
        double turnoverClass4;
        double turnoverCharter;
        void add(PassengerEnterpriseMonthly e) {
            passengerTotal += nz(e.getPassengerClass1()) + nz(e.getPassengerClass2()) + nz(e.getPassengerClass3())
                + nz(e.getPassengerClass4()) + nz(e.getPassengerCharter());
            passengerClass1 += nz(e.getPassengerClass1());
            passengerClass2 += nz(e.getPassengerClass2());
            passengerClass3 += nz(e.getPassengerClass3());
            passengerClass4 += nz(e.getPassengerClass4());
            passengerCharter += nz(e.getPassengerCharter());
            turnoverTotal += nz(e.getTurnoverClass1()) + nz(e.getTurnoverClass2()) + nz(e.getTurnoverClass3())
                + nz(e.getTurnoverClass4()) + nz(e.getTurnoverCharter());
            turnoverClass1 += nz(e.getTurnoverClass1());
            turnoverClass2 += nz(e.getTurnoverClass2());
            turnoverClass3 += nz(e.getTurnoverClass3());
            turnoverClass4 += nz(e.getTurnoverClass4());
            turnoverCharter += nz(e.getTurnoverCharter());
        }
        private double nz(Double v) {
            return v == null ? 0.0 : v;
        }
    }
    // ==================== 中口径明细/排名/分析(H203-1 公路旅客) ====================
    /** 加载 年*100+月 -> (市州/全省 -> 企业汇总) 数据 */
    private Map<Integer, Map<String, PassengerAgg>> loadPassengerAggMap() {
        Map<Integer, Map<String, PassengerAgg>> data = new HashMap<>();
        List<PassengerEnterpriseMonthly> list = passengerMapper.selectList(null);
        for (PassengerEnterpriseMonthly e : list) {
            if (e.getReportPeriod() == null) continue;
            String[] parts = e.getReportPeriod().split("-");
            if (parts.length != 2) continue;
            int y;
            int m;
            try {
                y = Integer.parseInt(parts[0]);
                m = Integer.parseInt(parts[1]);
            } catch (NumberFormatException ex) {
                continue;
            }
            String city = RegionUtil.cityByCode(e.getRegionCode());
            if (city == null) continue;
            int key = y * 100 + m;
            Map<String, PassengerAgg> monthMap = data.computeIfAbsent(key, k -> new HashMap<>());
            monthMap.computeIfAbsent(city, k -> new PassengerAgg()).add(e);
            monthMap.computeIfAbsent("湖北省", k -> new PassengerAgg()).add(e);
        }
        return data;
    }
    /**
     * 中口径明细:客运量块 + 旅客周转量块(总/公路班线/城际城乡公交/巡游出租/网约车)
     */
    /**
     * 生成_中口径明细.xlsx:以样例为底稿。数据区外部引用公式(班线/公交/出租/网约车)替换为库内数值,
     * 2025 年列保留模板缓存值(去年数据),内部公式(累计/同比/求和)保留并重算。
     */
    public byte[] exportPassengerMidDetail(String period, String mode) throws Exception {
        int maxMonth = monthOf(period, mode);
        int currentYear = Integer.parseInt(period.substring(0, 4));
        File template = resolvePassengerTemplate("生成_中口径明细.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            fixPassengerHeaderYear(sheet, currentYear); // 表头年份动态化(不写死 2026)
            Map<Integer, Map<String, double[][]>> mid = loadMidClassMap();
            int fillMonths = maxMonth; // 1..N 月(N>6 时模板自动向右扩列到 12 月)
            boolean extended = fillMonths > 6;
            if (extended) extendMonthlyColumns(sheet, 2, currentYear, 4, sheet.getLastRowNum());
            List<String> areas = midTemplateAreas();
            // 2026-09-18:改为「按单元格类型无关」遍历——扩列(7~12 月)出来的值列在模板里是空格,
            // 旧实现只处理 CellType.FORMULA 的格,导致 7 月及以后月份永远填不上(8 月整列 0、累计只到 6 月)。
            for (Row row : sheet) {
                if (row == null) continue;
                for (int c0 = 0; c0 < Math.max(row.getLastCellNum(), 0); c0++) {
                    Cell c = row.getCell(c0);
                    if (c == null) continue;
                    int r = c.getRowIndex() + 1;
                    int col = c0 + 1;
                    int blockOffset = -1;
                    boolean volume = false;
                    if (r >= 5 && r <= 94) {
                        blockOffset = r - 5;
                        volume = true;
                    } else if (r >= 99 && r <= 188) {
                        blockOffset = r - 99;
                        volume = false;
                    }
                    if (blockOffset < 0) {
                        if (c.getCellType() == CellType.FORMULA) keepCached(c); // r1/r2 备注、页外跨簿引用等
                        continue;
                    }
                    boolean isVal = is2026MonthCol(col);
                    boolean isYoy = isYoyMonthCol(col);
                    if (!isVal && !isYoy) {
                        // 2025 年列等:跨簿公式取缓存值断链;内部公式(合计/累计)保留,稍后 recalc 重算
                        if (c.getCellType() == CellType.FORMULA) {
                            String f = c.getCellFormula();
                            if (f != null && f.contains("[")) keepCached(c);
                        }
                        continue;
                    }
                    String area = areas.get(blockOffset / 5);
                    if (blockOffset % 5 == 0) {
                        if (isYoy) {
                            fillMidYoyCell(c, mid, currentYear, monthOfCol(col), area, -1, volume);
                        }
                        // 总行值列:一律保留模板公式(1~6 月原公式 + 扩列克隆出来的公式),
                        // 由生成收尾的 recalcWorkbookFormulas 求值写回缓存。早期版本这里 keepCached
                        // 会把刚克隆出来的公式清成空格,导致 7 月及以后总行永远空白。
                        continue;
                    }
                    int k = blockOffset % 5; // 1=班线 2=公交 3=出租城乡 4=网约车城乡
                    if (isVal) {
                        fillMidCell(c, mid, currentYear, monthOfCol(col), area, k, volume);
                    } else {
                        fillMidYoyCell(c, mid, currentYear, monthOfCol(col), area, k, volume);
                    }
                }
            }
            // 累计同比列(模板 #REF! → 库内去年 1..N 月累计同比数值)
            fillMidCumYoyColumn(sheet, mid, currentYear, fillMonths, extended ? 27 : 15);
            // evaluateAll 会在跨簿公式(Sheet2 的 [1]xx!A1)处整本中断,累计列 AA 就永远拿不到新缓存值,
            // 改用逐格容错的 recalcWorkbookFormulas:坏格跳过、好格照算。
            int[] recalc = recalcWorkbookFormulas(wb);
            log.info("中口径明细公式重算完成:成功 {} 格,失败 {} 格", recalc[0], recalc[1]);
            clearFormulaErrorsAll(wb); // 去年同期明细页残留 #REF! 同比公式 → 清空
            blankFutureMonthColumns(sheet, fillMonths); // 报表期之后的月份列不保留克隆公式/0 值
            return toBytes(wb);
        }
    }
    /**
     * 清空「报表期之后」的月份列(扩列克隆出来的 7~12 月里、尚未到期的月份)。
     * 不清的话,克隆出来的总行公式会算出 0,报表上会出现"9~12 月全是 0"的假数据;
     * 累计列(AA)的公式/缓存值已在重算阶段算好,清空格子不影响它(空 = 0)。
     */
    private void blankFutureMonthColumns(Sheet sheet, int filledMonths) {
        for (int m = filledMonths + 1; m <= 12; m++) {
            int col = 2 * m;            // 0 基:1 月=2(C)、7 月=14(O)、12 月=24(Y)
            for (int r = 4; r <= sheet.getLastRowNum(); r++) { // 0 基第 4 行 = 第 5 行(首个数据行),表头不动
                Row row = sheet.getRow(r);
                if (row == null) continue;
                Cell val = row.getCell(col);
                if (val != null) val.setBlank();
                Cell yoy = row.getCell(col + 1);
                if (yoy != null) yoy.setBlank();
            }
        }
    }
    /** 中口径明细模板地区顺序(与 RegionUtil.CITY_LIST 一致:武汉市…神农架林区) */
    private List<String> midTemplateAreas() {
        List<String> areas = new java.util.ArrayList<>();
        areas.add("湖北省");
        areas.addAll(RegionUtil.CITY_LIST);
        return areas;
    }
    private boolean is2026MonthCol(int col) {
        return col >= 3 && col <= 25 && col % 2 == 1;
    }
    /** 月同比列(1-based 偶数列 4..26) */
    private boolean isYoyMonthCol(int col) {
        return col >= 4 && col <= 26 && col % 2 == 0;
    }
    private int monthOfCol(int col) {
        return (col - 1) / 2;
    }
    /**
     * 填中口径数据单元格:班线/公交/出租城乡/网约车城乡 四维统一取库内值。
     * 库内该月该维无源(取值为 0)时**保留母版/模板原值**——绝不写空、也不写 0。
     * 依据:模板的城市级城乡行是「台账页 总量 − 城市内」的引用、并已业务定稿;
     * 用库内缺失值覆盖会把定稿数清掉(如 2026-01~06 网约车订单缺失时,
     * 全省网约车城乡 333.39 与各市州值会被清空)。
     */
    private void fillMidCell(Cell c, Map<Integer, Map<String, double[][]>> mid, int year, int month, String area, int k, boolean volume) {
        double v = midClassVal(mid, year, month, area, k, volume);
        if (v == 0.0) return; // 库内无源:保留母版/定稿原值,缺数据不覆盖
        // POI setCellValue(double) 对公式单元格只更新缓存不移除公式,必须先 setBlank 再写值
        c.setBlank();
        c.setCellValue(v);
    }
    /** 显式写入 0(POI setCellValue(0.0) 会转 blank,需操作底层 XML) */
    private void writeExplicitZero(Cell c) {
        c.setBlank();
        if (c instanceof XSSFCell) {
            try {
                ((XSSFCell) c).getCTCell().setT(STCellType.N);
                ((XSSFCell) c).getCTCell().setV("0");
            } catch (Exception e) {
                log.warn("writeExplicitZero failed: {}", e.getMessage());
            }
        }
    }
    private double midClassVal(Map<Integer, Map<String, double[][]>> mid, int year, int month, String area, int k, boolean volume) {
        Map<String, double[][]> mm = mid.get(year * 100 + month);
        if (mm == null) return 0;
        double[][] arr = mm.get(area);
        if (arr == null) return 0;
        if (k < 0 || k >= arr.length) return 0;
        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 {
            double v = 0.0;
            boolean has = false;
            if (c.getCachedFormulaResultType() == CellType.NUMERIC) {
                v = c.getNumericCellValue();
                has = true;
            }
            c.setBlank();
            if (has && v != 0.0) c.setCellValue(v);
        } catch (Exception e) {
            try { c.setBlank(); } catch (Exception ignore) { }
        }
    }
    /**
     * 中口径分类月度数据:年*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;
            String[] parts = e.getReportPeriod().split("-");
            if (parts.length != 2) continue;
            int y, m;
            try {
                y = Integer.parseInt(parts[0]);
                m = Integer.parseInt(parts[1]);
            } catch (NumberFormatException ex) {
                continue;
            }
            String city = RegionUtil.cityByCode(e.getRegionCode());
            if (city == null) continue;
            int key = y * 100 + m;
            double pass = (nz(e.getPassengerClass1()) + nz(e.getPassengerClass2()) + nz(e.getPassengerClass3())
                + nz(e.getPassengerClass4()) + nz(e.getPassengerCharter())) / 10000.0;
            double turn = (nz(e.getTurnoverClass1()) + nz(e.getTurnoverClass2()) + nz(e.getTurnoverClass3())
                + 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;
            String[] parts = e.getReportPeriod().split("-");
            if (parts.length != 2) continue;
            int y, m;
            try {
                y = Integer.parseInt(parts[0]);
                m = Integer.parseInt(parts[1]);
            } catch (NumberFormatException ex) {
                continue;
            }
            String city = RegionUtil.cityByCode(e.getRegionCode());
            if (city == null) continue;
            int key = y * 100 + m;
            double pass = nz(e.getPassengerCount()) / 10000.0;
            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;
            String[] parts = b.getReportPeriod().split("-");
            if (parts.length != 2) continue;
            int y, m;
            try {
                y = Integer.parseInt(parts[0]);
                m = Integer.parseInt(parts[1]);
            } catch (NumberFormatException ex) {
                continue;
            }
            String city = RegionUtil.normalizeCityName(b.getCity());
            if (city == null) continue;
            int key = y * 100 + m;
            // 口径统一(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)) {
            if (t.getReportPeriod() == null || t.getCity() == null) continue;
            int key = midPeriodKey(t.getReportPeriod());
            if (key <= 0) continue;
            String city = RegionUtil.normalizeCityName(t.getCity());
            if (city == null) continue;
            double pass = nz(t.getPassengerVolume()) - nz(t.getPassengerCity());
            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)) {
            String per = o.getReportPeriod();
            if (per == null) continue;
            int key = midPeriodKey(per);
            if (key <= 0) continue;
            WycSplitCalc.WycResult wr;
            try {
                wr = wycSplitCalc.calc(per);
            } catch (Exception e) {
                // 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;
    }
    /** "yyyy-MM" -> yyyy*100+MM;非法返回 0(与 loadMidClassMap 的 key 口径一致) */
    private int midPeriodKey(String period) {
        if (period == null) return 0;
        String[] parts = period.trim().split("-");
        if (parts.length != 2) return 0;
        try {
            int y = Integer.parseInt(parts[0]);
            int m = Integer.parseInt(parts[1]);
            if (y <= 0 || m < 1 || m > 12) return 0;
            return y * 100 + m;
        } catch (NumberFormatException e) {
            return 0;
        }
    }
    /** 写入分类值并累加总量(维度0) */
    private void addMidClass(Map<Integer, Map<String, double[][]>> mid, int key, String city, int k, double pass, double turn) {
        double[][] arr = mid.computeIfAbsent(key, kk -> new HashMap<>()).computeIfAbsent(city, kk -> new double[5][2]);
        arr[k][0] += pass;
        arr[k][1] += turn;
        arr[0][0] += pass;
        arr[0][1] += turn;
    }
    private int writeMidBlock(Sheet sheet, Map<Integer, Map<String, PassengerAgg>> data, int[] years,
                              int currentYear, int maxMonth, int startRow, String title, boolean volume) {
        Row titleRow = sheet.createRow(startRow);
        titleRow.createCell(0).setCellValue(title);
        Row noteRow = sheet.createRow(startRow + 1);
        noteRow.createCell(0).setCellValue("注:中口径由公路班线、城际城乡公交、城际城乡出租、城际城乡网约车四部分构成");
        Row header = sheet.createRow(startRow + 2);
        header.createCell(0).setCellValue("地区 名称");
        header.createCell(1).setCellValue("指标");
        int col = 2;
        for (int y : years) {
            int monthCount = (y == currentYear) ? maxMonth : 12;
            for (int m = 1; m <= monthCount; m++) {
                header.createCell(col++).setCellValue(y + "年" + m + "月");
                header.createCell(col++).setCellValue(m + "月与去年同比");
            }
            header.createCell(col++).setCellValue(y + "年累计");
            header.createCell(col++).setCellValue("累计与去年同比");
        }
        sheet.createRow(startRow + 3);
        String[] metrics = volume ? new String[]{
            "总客运量(万人次)",
            "其中:公路班线客运量(万人次)",
            "城际城乡公交客运量(万人次)",
            "城际城乡巡游出租客运量(万人次)",
            "城际城乡网约车客运量(万人次)"
        } : new String[]{
            "总旅客周转量(万人公里)",
            "其中:公路班线旅客周转量(万人公里)",
            "城际城乡公交旅客周转量(万人公里)",
            "城际城乡巡游出租旅客周转量(万人公里)",
            "城际城乡网约车旅客周转量(万人公里)"
        };
        int rowIdx = startRow + 4;
        List<String> regions = new java.util.ArrayList<>();
        regions.add("湖北省");
        regions.addAll(RegionUtil.cityList());
        for (String region : regions) {
            String label = "湖北省".equals(region) ? "全省" : RegionUtil.shortName(region);
            for (int i = 0; i < metrics.length; i++) {
                Row row = sheet.createRow(rowIdx++);
                row.createCell(0).setCellValue(label);
                row.createCell(1).setCellValue(metrics[i]);
                col = 2;
                for (int y : years) {
                    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);
                        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);
                    Double cumBase = midCumOf(data, y - 1, region, monthCount, i, volume);
                    setNumeric(row, col++, yoy(cum, cumBase));
                }
            }
        }
        return rowIdx;
    }
    /** 中口径指标取值:0=总量 1=公路班线 2-4=公交/出租/网约车(城市客运模块未接入,暂空) */
    private Double midMetricValue(PassengerAgg agg, int metric, boolean volume) {
        if (agg == null) return null;
        if (volume) {
            switch (metric) {
                case 0: return agg.passengerTotal / 10000.0;
                case 1: return agg.passengerTotal / 10000.0;
                default: return null;
            }
        }
        switch (metric) {
            case 0: return agg.turnoverTotal / 10000.0;
            case 1: return agg.turnoverTotal / 10000.0;
            default: return null;
        }
    }
    private Double midCumOf(Map<Integer, Map<String, PassengerAgg>> data, int year, String region,
                            int monthCount, int metricIdx, boolean volume) {
        double sum = 0.0;
        boolean has = false;
        for (int m = 1; m <= monthCount; m++) {
            Double v = midMetricValue(aggOf(data, year, m, region), metricIdx, volume);
            if (v != null) {
                sum += v;
                has = true;
            }
        }
        return has ? sum : null;
    }
    /**
     * 中口径排名:左块=当月(客运量/周转量及排名/同比/增速排名),右块=1-N月累计(同结构)
     */
    /**
     * 生成_中口径排名.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);
            Map<Integer, Map<String, double[][]>> mid = loadMidClassMap();
            Row title = sheet.getRow(0);
            if (title != null && title.getCell(0) != null) {
                if (maxMonth == 12) {
                    title.getCell(0).setCellValue(currentYear + "年1-12月全省分市州累计完成道路客运生产情况");
                } else if (maxMonth == 1) {
                    title.getCell(0).setCellValue(currentYear + "年1月全省分市州完成道路客运生产情况");
                } else {
                    title.getCell(0).setCellValue(currentYear + "年1-" + maxMonth + "月全省分市州累计完成道路客运生产情况");
                }
            }
            // 右块(当月)标题 K1
            if (title != null && title.getCell(10) != null) {
                title.getCell(10).setCellValue(currentYear + "年" + maxMonth + "月全省分市州完成道路客运生产情况");
            }
            // 全省行 r3:B3/F3 为 SUM 公式保留;D3/H3 同比填值(0-based 列 3/7)
            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));
            // 市州行 r4-20:B/F 累计值、D/H 同比;C/E/G/I 排名公式保留
            List<String> cities = RegionUtil.CITY_LIST;
            for (int i = 0; i < cities.size(); i++) {
                String city = cities.get(i);
                int r0 = 3 + i;
                double pass = midCumClass(mid, currentYear, city, maxMonth, 0, true);
                double turn = midCumClass(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);
                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 : 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);
    }
    /** 写数值或清空(公式单元格保留不动) */
    private void setValOrBlank(Sheet sheet, int r0, int c0, Double v) {
        Row row = sheet.getRow(r0);
        if (row == null) return;
        Cell c = row.getCell(c0);
        if (c == null) return;
        if (c.getCellType() == CellType.FORMULA) return;
        if (v == null || v == 0.0) c.setBlank(); else c.setCellValue(v);
    }
    private Double yoyOf(double cur, double base) {
        if (base == 0.0) return null;
        return (cur - base) / base;
    }
    /** 中口径分类累计(1..monthCount 月求和) */
    private double midCumClass(Map<Integer, Map<String, double[][]>> mid, int year, String area, int monthCount, int k, boolean volume) {
        double sum = 0.0;
        boolean has = false;
        for (int m = 1; m <= monthCount; m++) {
            Map<String, double[][]> mm = mid.get(year * 100 + m);
            if (mm == null) continue;
            double[][] arr = mm.get(area);
            if (arr == null) continue;
            sum += volume ? arr[k][0] : arr[k][1];
            has = true;
        }
        return has ? sum : 0.0;
    }
    /**
     * 生成_中口径分析.xlsx:以样例为底稿,填累计客运量/周转量/同比,占比公式保留。
     */
    public byte[] exportPassengerMidAnalysis(String period, String mode) throws Exception {
        int maxMonth = monthOf(period, mode);
        int currentYear = Integer.parseInt(period.substring(0, 4));
        File template = resolvePassengerTemplate("生成_中口径分析.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            Map<Integer, Map<String, double[][]>> mid = loadMidClassMap();
            Row title = sheet.getRow(0);
            if (title != null && title.getCell(0) != null) {
                title.getCell(0).setCellValue(currentYear + "年" + cumRange(maxMonth) + "中口径客运量及周转量");
            }
            // r3=总 r4=班线 r5=公交 r6=出租 r7=网约车(1-based);列 B/C/F/G 填值,D/H 占比公式保留
            String[] names = {"总客运量", "公路班线", "城际城乡公交", "城际城乡巡游出租", "城际城乡网约车"};
            int[] dims = {0, 1, 2, 3, 4};
            for (int i = 0; i < names.length; i++) {
                int r0 = 2 + i;
                double pass = midCumClass(mid, currentYear, "湖北省", maxMonth, dims[i], true);
                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 : pass);
                setValOrBlank(sheet, r0, 2, midCumYoy(mid, currentYear, "湖北省", maxMonth, dims[i], true));
                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)
            Row bottomTitle = sheet.getRow(8);
            if (bottomTitle != null && bottomTitle.getCell(0) != null) {
                bottomTitle.getCell(0).setCellValue(currentYear + "年" + maxMonth + "月中口径客运量及周转量");
            }
            for (int i = 0; i < names.length; i++) {
                int r0 = 10 + i;
                double pass = midClassVal(mid, currentYear, maxMonth, "湖北省", dims[i], true);
                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 : pass);
                setValOrBlank(sheet, r0, 2, pass == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, "湖北省", dims[i], true));
                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
            for (int i = 1; i < names.length; i++) {
                int r0 = 10 + i;
                Row rr = sheet.getRow(r0);
                if (rr == null) continue;
                Cell dc = rr.getCell(3);
                if (dc != null && dc.getCellType() == CellType.FORMULA) {
                    dc.setCellFormula("B" + (r0 + 1) + "/B$11");
                }
                Cell hc = rr.getCell(7);
                if (hc != null && hc.getCellType() == CellType.FORMULA) {
                    hc.setCellFormula("F" + (r0 + 1) + "/F$11");
                }
            }
            recalc(wb);
            return toBytes(wb);
        }
    }
    private double nvlOf(Double v) {
        return v == null ? Double.NaN : v;
    }
    private Double numOrNull(double v) {
        return Double.isNaN(v) ? null : v;
    }
    /** 降序排名(无数据 NaN 排最后),返回各下标对应名次 */
    private int[] rankValues(double[] values) {
        Integer[] order = new Integer[values.length];
        for (int i = 0; i < values.length; i++) order[i] = i;
        java.util.Arrays.sort(order, (a, b) -> {
            boolean na = Double.isNaN(values[a]);
            boolean nb = Double.isNaN(values[b]);
            if (na && nb) return 0;
            if (na) return 1;
            if (nb) return -1;
            return Double.compare(values[b], values[a]);
        });
        int[] ranks = new int[values.length];
        for (int i = 0; i < order.length; i++) ranks[order[i]] = i + 1;
        return ranks;
    }
    // ==================== 5. 生成_能运汇总表(H204 道路货运车辆能源消耗情况) ====================
    /** 燃料类型码 -> 周转量单耗折算系数(千克标准煤折算) */
    private static final Map<String, Double> ENERGY_FUEL_COEFF = new HashMap<>();
    static {
        ENERGY_FUEL_COEFF.put("01", 0.73 * 1.4714);      // 汽油
        ENERGY_FUEL_COEFF.put("02", 0.86 * 1.4571);      // 柴油
        ENERGY_FUEL_COEFF.put("03", 1.7572);             // 压缩天然气
        ENERGY_FUEL_COEFF.put("04", 1.7572);             // 液化天然气
        ENERGY_FUEL_COEFF.put("07", 0.1229);             // 电动
        ENERGY_FUEL_COEFF.put("08", 0.3329 * 12.1951);   // 燃料电池(氢气)
    }
    /** 燃料码 -> 汇总表行名(与模板行顺序一致) */
    private static final String[][] ENERGY_FUEL_ROWS = {
        {"柴油车", "02"},
        {"汽油车", "01"},
        {"液化天然气车", "04"},
        {"压缩天然气车", "03"},
        {"纯电动车", "07"},
        {"燃料电池车", "08"}
    };
    /**
     * 能运汇总表:按燃料类型分组汇总,全省 + 各市州各一张 sheet
     */
    public byte[] exportEnergySummary(String period) throws Exception {
        List<EnergyVehicleQuarterly> list = energyMapper.selectList(
            new LambdaQueryWrapper<EnergyVehicleQuarterly>()
                .eq(EnergyVehicleQuarterly::getReportPeriod, period));
        int quarter = (parseMonth(period) + 2) / 3;
        String[] QUARTER_CN = {"一", "二", "三", "四"};
        String title = period.substring(0, 4) + "年第" + QUARTER_CN[quarter - 1] + "季度能耗汇总情况表";
        Map<String, FuelAgg> province = new HashMap<>();
        Map<String, Map<String, FuelAgg>> cityMap = new HashMap<>();
        for (EnergyVehicleQuarterly e : list) {
            String code = e.getFuelTypeCode();
            if (code == null || code.trim().isEmpty()) continue;
            province.computeIfAbsent(code.trim(), k -> new FuelAgg()).add(e);
            String city = RegionUtil.cityByCode(e.getRegionCode());
            if (city != null) {
                cityMap.computeIfAbsent(city, k -> new HashMap<>())
                    .computeIfAbsent(code.trim(), k2 -> new FuelAgg()).add(e);
            }
        }
        // 以 模板_能运汇总表 .xlsx 为底稿:保留标题/表头/合并/公式/列宽/样式,仅替换数据区数值
        File template = resolveAnyTemplate(energyTemplateDir, "生成_能运汇总表.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet base = wb.getSheetAt(0);
            base.getRow(0).getCell(0).setCellValue(title);
            fillEnergyTemplateSheet(base, province);
            for (String city : RegionUtil.cityList()) {
                Map<String, FuelAgg> agg = cityMap.get(city);
                if (agg == null || agg.isEmpty()) continue;
                Sheet s = wb.cloneSheet(0);
                wb.setSheetName(wb.getSheetIndex(s), RegionUtil.shortName(city) + "能耗汇总");
                s.getRow(0).getCell(0).setCellValue(title);
                fillEnergyTemplateSheet(s, agg);
            }
            recalc(wb);
            clearFormulaErrorsAll(wb); // 无去年数据的同比/增速公式 #DIV/0! → 清空
            return toBytes(wb);
        }
    }
    /** 能耗汇总模板填充:1-based 第3~8行为 柴油/汽油/液化天然/压缩天然/纯电/燃料电池;D/J/L/M 为公式列,仅填数值列 */
    private void fillEnergyTemplateSheet(Sheet sheet, Map<String, FuelAgg> data) {
        for (int i = 0; i < ENERGY_FUEL_ROWS.length; i++) {
            Row row = sheet.getRow(2 + i);
            if (row == null) row = sheet.createRow(2 + i);
            FuelAgg agg = data.get(ENERGY_FUEL_ROWS[i][1]);
            if (agg == null || agg.count == 0) {
                setEnergyValue(row, 1, null);
                setEnergyValue(row, 2, null);
                setEnergyValue(row, 4, null);
                setEnergyValue(row, 5, null);
                setEnergyValue(row, 6, null);
                setEnergyValue(row, 7, null);
                setEnergyValue(row, 8, null);
                setEnergyValue(row, 10, null);
                continue;
            }
            setEnergyValue(row, 1, (double) agg.count);
            setEnergyValue(row, 2, agg.totalTonnage);
            setEnergyValue(row, 4, agg.totalMileage);
            setEnergyValue(row, 5, agg.loadedMileage);
            setEnergyValue(row, 6, agg.emptyMileage);
            setEnergyValue(row, 7, agg.freight);
            setEnergyValue(row, 8, agg.turnover);
            setEnergyValue(row, 10, agg.fuelConsumption);
        }
    }
    /** 写能耗数值列:有值写入,无值清空模板样例;公式列(D/J/L/M)不动 */
    private void setEnergyValue(Row row, int colIdx, Double v) {
        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 == null) c.setBlank(); else c.setCellValue(v);
    }
    /** 能耗汇总:按燃料类型聚合 */
    private static class FuelAgg {
        int count;
        double totalTonnage;
        double totalMileage;
        double loadedMileage;
        double emptyMileage;
        double freight;
        double turnover;
        double fuelConsumption;
        void add(EnergyVehicleQuarterly e) {
            count++;
            totalTonnage += nz(e.getMarkedTonnage());
            totalMileage += nz(e.getTotalMileage());
            loadedMileage += nz(e.getLoadedMileage());
            emptyMileage += nz(e.getEmptyMileage());
            freight += nz(e.getFreight());
            turnover += nz(e.getTurnover());
            fuelConsumption += nz(e.getFuelConsumption());
        }
        private double nz(Double v) {
            return v == null ? 0.0 : v;
        }
    }
    // ==================== 通用辅助 ====================
    private int parseMonth(String period) {
        return Integer.parseInt(period.split("-")[1]);
    }
    private int monthOf(String period, String mode) {
        return "year".equalsIgnoreCase(mode) ? 12 : parseMonth(period);
    }
    /** 累计区间文案:1 月显示 "1月",其余显示 "1-N月"(避免出现 "1-1月") */
    private String cumRange(int month) {
        return month <= 1 ? "1月" : "1-" + month + "月";
    }
    /** 旅客分市州/中口径明细模板表头年份动态化:把 "2026年1月"…"2026年6月"、"2026年累计" 的年份换成所选年 */
    private void fixPassengerHeaderYear(Sheet sheet, int year) {
        java.util.regex.Pattern p = java.util.regex.Pattern.compile("^\\d{4}年");
        for (int r = 1; r <= 3; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (Cell cell : row) {
                if (cell.getCellType() != CellType.STRING) continue;
                String v = cell.getStringCellValue();
                if (v == null || v.isEmpty()) continue;
                String nv = p.matcher(v).replaceFirst(year + "年");
                if (!nv.equals(v)) cell.setCellValue(nv);
            }
        }
    }
    private String pad(int m) {
@@ -808,11 +3161,3390 @@
        sheet.setColumnWidth(1, 22 * 256);
    }
    private byte[] toBytes(XSSFWorkbook wb) throws Exception {
    // ==================== 城市客运(公交)分市州明细 ====================
    /** 生成_城市公交客运量分市州明细.xlsx(以 城市公交_模板.xlsx 为底稿,保留模板格式与公式) */
    public byte[] exportCityBusDetail(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        return exportCityByTemplate("生成_城市公交客运量分市州明细.xlsx", period, mode,
            loadCityBusByMonth(year + "-", month),
            loadCityBusByMonth((year - 1) + "-", month));
    }
    /** 加载某年 1..limit 月城市公交企业数据,按 月 → 市州 → [客运量,周转量,城市内客运量,城市内周转量] 汇总;"全省" 为全部市州合计 */
    private Map<Integer, Map<String, double[]>> loadCityBusByMonth(String yearPrefix, int limit) {
        Map<Integer, Map<String, double[]>> monthCity = new java.util.LinkedHashMap<>();
        List<CityBusMonthly> rows = cityBusMapper.selectList(
            new LambdaQueryWrapper<CityBusMonthly>()
                .likeRight(CityBusMonthly::getReportPeriod, yearPrefix));
        for (CityBusMonthly r : rows) {
            int m = monthOf(r.getReportPeriod());
            if (m < 1 || m > limit) continue;
            String city = r.getCity() == null ? "未知" : r.getCity();
            Map<String, double[]> cityMap = monthCity.computeIfAbsent(m, k -> new HashMap<>());
            double[] arr = cityMap.computeIfAbsent(city, k -> new double[4]);
            arr[0] += nz(r.getPassengerVolume());
            arr[1] += nz(r.getTurnover());
            arr[2] += nz(r.getPassengerCity());
            arr[3] += nz(r.getTurnoverCity());
            double[] prov = cityMap.computeIfAbsent("全省", k -> new double[4]);
            prov[0] += nz(r.getPassengerVolume());
            prov[1] += nz(r.getTurnover());
            prov[2] += nz(r.getPassengerCity());
            prov[3] += nz(r.getTurnoverCity());
        }
        return monthCity;
    }
    private int monthOf(String reportPeriod) {
        if (reportPeriod == null) return 0;
        String[] parts = reportPeriod.split("-");
        if (parts.length < 2) return 0;
        try {
            return Integer.parseInt(parts[1]);
        } catch (Exception e) {
            return 0;
        }
    }
    private double cityBusVal(Map<Integer, Map<String, double[]>> monthCity, String area, int m, int idx) {
        Map<String, double[]> cityMap = monthCity.get(m);
        if (cityMap == null) return 0;
        double[] arr = cityMap.get(area);
        if (arr == null) return 0;
        return idx < arr.length ? arr[idx] : 0;
    }
    // ==================== 城市客运(巡游出租)分市州明细 ====================
    /** 生成_巡游出租客运量分市州明细.xlsx(以 巡游出租_模板.xlsx 为底稿,保留模板格式与公式) */
    public byte[] exportCityTaxiDetail(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        return exportCityByTemplate("生成_巡游出租客运量分市州明细.xlsx", period, mode,
            loadCityTaxiByMonth(year + "-", month),
            loadCityTaxiByMonth((year - 1) + "-", month));
    }
    /** 加载某年 1..limit 月巡游出租数据,按 月 → 市州 → [客运量,周转量,城市内客运量,城市内周转量] 汇总;"全省" 为全部市州合计 */
    private Map<Integer, Map<String, double[]>> loadCityTaxiByMonth(String yearPrefix, int limit) {
        Map<Integer, Map<String, double[]>> monthCity = new java.util.LinkedHashMap<>();
        List<CityTaxiMonthly> rows = cityTaxiMapper.selectList(
            new LambdaQueryWrapper<CityTaxiMonthly>()
                .likeRight(CityTaxiMonthly::getReportPeriod, yearPrefix));
        for (CityTaxiMonthly r : rows) {
            int m = monthOf(r.getReportPeriod());
            if (m < 1 || m > limit) continue;
            String city = r.getCity() == null ? "未知" : r.getCity();
            Map<String, double[]> cityMap = monthCity.computeIfAbsent(m, k -> new HashMap<>());
            double[] arr = cityMap.computeIfAbsent(city, k -> new double[4]);
            arr[0] += nz(r.getPassengerVolume());
            arr[1] += nz(r.getTurnover());
            arr[2] += nz(r.getPassengerCity());
            arr[3] += nz(r.getTurnoverCity());
            double[] prov = cityMap.computeIfAbsent("全省", k -> new double[4]);
            prov[0] += nz(r.getPassengerVolume());
            prov[1] += nz(r.getTurnover());
            prov[2] += nz(r.getPassengerCity());
            prov[3] += nz(r.getTurnoverCity());
        }
        return monthCity;
    }
    private double cityTaxiVal(Map<Integer, Map<String, double[]>> monthCity, String area, int m, int idx) {
        Map<String, double[]> cityMap = monthCity.get(m);
        if (cityMap == null) return 0;
        double[] arr = cityMap.get(area);
        if (arr == null) return 0;
        return idx < arr.length ? arr[idx] : 0;
    }
    // ==================== 城市客运(轨道/轮渡)分市州明细 ====================
    /** 生成_轨道轮渡客运量分市州明细.xlsx(以 轨道、轮渡_模板.xlsx 为底稿,轨道=全省/武汉/黄石,轮渡=全省/武汉) */
    public byte[] exportCityRailFerryDetail(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        Map<Integer, Map<String, double[]>> monthCity = loadCityRailFerryByMonth(year + "-", month);
        File template = resolveTemplate("生成_轨道轮渡客运量分市州明细.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            dynamicTitle(sheet, year, month); // 标题按报表期动态化(不写死年份/月份)
            // 轨道:客运量行 4(全省)/6(武汉)/8(黄石),周转量行为 +1;指标 0=轨道客运量 1=轨道周转量
            fillRailFerryBlock(sheet, monthCity, month, new String[][]{{"全省", "3"}, {"武汉市", "5"}, {"黄石市", "7"}}, 0);
            // 轮渡:客运量行 13(全省)/15(武汉);指标 2=轮渡客运量 3=轮渡周转量
            fillRailFerryBlock(sheet, monthCity, month, new String[][]{{"全省", "12"}, {"武汉市", "14"}}, 2);
            recalc(wb);
            clearFormulaErrorCells(sheet, 3, sheet.getLastRowNum(), 1, 39);
            fixCityMonthHeaderText(sheet);
            return toBytes(wb);
        }
    }
    /** 加载某年 1..limit 月轨道/轮渡数据:月 → 市州 → [轨道客运量,轨道周转量,轮渡客运量,轮渡周转量],含"全省" */
    private Map<Integer, Map<String, double[]>> loadCityRailFerryByMonth(String yearPrefix, int limit) {
        Map<Integer, Map<String, double[]>> monthCity = new java.util.LinkedHashMap<>();
        List<CityBusMonthly> rows = cityBusMapper.selectList(
            new LambdaQueryWrapper<CityBusMonthly>().likeRight(CityBusMonthly::getReportPeriod, yearPrefix));
        for (CityBusMonthly r : rows) {
            int m = monthOf(r.getReportPeriod());
            if (m < 1 || m > limit) continue;
            String city = r.getCity() == null ? "未知" : r.getCity();
            Map<String, double[]> cityMap = monthCity.computeIfAbsent(m, k -> new HashMap<>());
            double[] arr = cityMap.computeIfAbsent(city, k -> new double[4]);
            arr[0] += nz(r.getRailPassengerVolume());
            arr[1] += nz(r.getRailTurnover());
            arr[2] += nz(r.getFerryPassengerVolume());
            arr[3] += nz(r.getFerryTurnover());
            double[] prov = cityMap.computeIfAbsent("全省", k -> new double[4]);
            prov[0] += nz(r.getRailPassengerVolume());
            prov[1] += nz(r.getRailTurnover());
            prov[2] += nz(r.getFerryPassengerVolume());
            prov[3] += nz(r.getFerryTurnover());
        }
        return monthCity;
    }
    /** 填充轨道/轮渡块:baseIdx 为客运量指标,周转量指标 = baseIdx+1;2026 月度列 C..O,2025 列 T.. 无数据则清空 */
    private void fillRailFerryBlock(Sheet sheet, Map<Integer, Map<String, double[]>> monthCity, int month,
                                    String[][] areaRows, int baseIdx) {
        for (String[] ar : areaRows) {
            String area = ar[0];
            int paxRow = Integer.parseInt(ar[1]);
            for (int m = 1; m <= month; m++) {
                setDataCell(sheet, paxRow, 2 + (m - 1) * 2, railFerryVal(monthCity, area, m, baseIdx));
                setDataCell(sheet, paxRow + 1, 2 + (m - 1) * 2, railFerryVal(monthCity, area, m, baseIdx + 1));
            }
            for (int m = 1; m <= 12; m++) {
                setDataCell(sheet, paxRow, 19 + (m - 1) * 2, 0);
                setDataCell(sheet, paxRow + 1, 19 + (m - 1) * 2, 0);
            }
        }
    }
    private double railFerryVal(Map<Integer, Map<String, double[]>> monthCity, String area, int m, int idx) {
        Map<String, double[]> cityMap = monthCity.get(m);
        if (cityMap == null) return 0;
        double[] arr = cityMap.get(area);
        if (arr == null) return 0;
        return idx < arr.length ? arr[idx] : 0;
    }
    /** 写数据单元格:原为公式则保留(全省求和/排名公式);否则有值写入、无值清空模板样例 */
    private void setDataCell(Sheet sheet, int rowIdx, int colIdx, double v) {
        Row row = sheet.getRow(rowIdx);
        if (row == null) row = sheet.createRow(rowIdx);
        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(v);
    }
    /** 显式写入 0(setDataCell 对 0 置空;汇总表网约车行需要显示 0) */
    private void setZeroCell(Sheet sheet, int rowIdx, int colIdx) {
        Row row = sheet.getRow(rowIdx);
        if (row == null) row = sheet.createRow(rowIdx);
        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);
        c.setCellValue(0.0);
    }
    /** 重算公式缓存值(保留公式,求值异常忽略) */
    private void recalc(XSSFWorkbook wb) {
        try {
            wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
        } catch (Exception e) {
            log.warn("城市客运模板公式求值失败: {}", e.getMessage());
        }
    }
    // ==================== 城市客运 分市州累计(城市分市州_模板 / 城市客运各市州明细表_模板) ====================
    /** 生成_城市客运客运量分市州累计.xlsx(以 城市分市州_模板.xlsx / 城市客运各市州明细表_模板.xlsx 为底稿;网约车列无源数据留空) */
    public byte[] exportCityPassengerCitySum(String templateFileName, String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        Map<String, double[]> cum = loadCityPassengerCumulative(year + "-", month);
        Map<String, double[]> monthMap = loadCityPassengerMonthMap(year + "-", month);
        File template = resolveTemplate(templateFileName);
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            dynamicTitle(sheet, year, month); // 标题按报表期动态化
            List<String> areas = new java.util.ArrayList<>();
            areas.add("全省");
            areas.addAll(RegionUtil.cityList());
            fillCitySumBlock(sheet, cum, areas, 4, 0);   // 累计客运量区(单位:万人次,POI 0 基,行5-22)
            fillCitySumBlock(sheet, cum, areas, 26, 1);  // 累计周转量区(单位:万人次公里,POI 0 基,行27-44)
            // 当月块标题(0基行44/66)与数据(0基行48-65/70-87)
            Row pTitle = sheet.getRow(44);
            if (pTitle != null && pTitle.getCell(0) != null) {
                pTitle.getCell(0).setCellValue("城市客运客运量分市州" + month + "月情况");
            }
            Row tTitle = sheet.getRow(66);
            if (tTitle != null && tTitle.getCell(0) != null) {
                tTitle.getCell(0).setCellValue("城市客运客运周转量分市州" + month + "月情况");
            }
            fillCitySumBlock(sheet, monthMap, areas, 48, 0);  // 当月客运量区(行49-66)
            fillCitySumBlock(sheet, monthMap, areas, 70, 1);  // 当月周转量区(行71-88)
            // 当月块排名/占比公式由累计块复制而来,RANK 范围与占比分母需改指当月块自身行
            repairCityMonthRankShare(sheet, 50, 66, 49);
            repairCityMonthRankShare(sheet, 72, 88, 71);
            recalc(wb);
            clearFormulaErrorsAll(wb); // 无去年累计的占比/增速公式 #DIV/0! → 清空
            return toBytes(wb);
        }
    }
    /** 填充一个累计区:startRow 起每地区 1 行;paxTurnIdx=0 用客运量、1 用周转量 */
    private void fillCitySumBlock(Sheet sheet, Map<String, double[]> cum, List<String> areas, int startRow, int paxTurnIdx) {
        int rowIdx = startRow;
        for (String area : areas) {
            double[] v = cum.get(area);
            double bus = v == null ? 0 : v[paxTurnIdx == 0 ? 0 : 1];
            double taxi = v == null ? 0 : v[paxTurnIdx == 0 ? 2 : 3];
            double rail = v == null ? 0 : v[paxTurnIdx == 0 ? 4 : 5];
            double ferry = v == null ? 0 : v[paxTurnIdx == 0 ? 6 : 7];
            double total = bus + taxi + rail + ferry; // 网约车无源数据,不计
            setDataCell(sheet, rowIdx, 1, total);   // B 总累计
            setDataCell(sheet, rowIdx, 6, bus);     // G 公交
            setDataCell(sheet, rowIdx, 11, taxi);   // L 出租
            setDataCell(sheet, rowIdx, 16, 0);      // Q 网约车(无源数据→清空样例)
            setDataCell(sheet, rowIdx, 21, rail);   // V 轨道
            setDataCell(sheet, rowIdx, 23, ferry);  // X 轮渡
            // 增速列 E/J/O/T/W/Y:无去年同期累计 → 清空模板样例(排名/占比公式保留)
            setDataCell(sheet, rowIdx, 4, 0);
            setDataCell(sheet, rowIdx, 9, 0);
            setDataCell(sheet, rowIdx, 14, 0);
            setDataCell(sheet, rowIdx, 19, 0);
            setDataCell(sheet, rowIdx, 22, 0);
            setDataCell(sheet, rowIdx, 24, 0);
            rowIdx++;
        }
    }
    /** 当月块排名/占比公式修正:模板当月块由累计块复制而来,RANK 范围与占比分母需改指当月块自身行 */
    private void repairCityMonthRankShare(Sheet sheet, int firstDataRow1, int lastDataRow1, int provRow1) {
        String[] groups = {"B", "G", "L", "Q"};
        String[] growth = {"E", "J", "O", "T"};
        for (int r = firstDataRow1; r <= lastDataRow1; r++) {
            int r0 = r - 1;
            for (int g = 0; g < 4; g++) {
                String base = groups[g];
                String grow = growth[g];
                setFormulaIfFormula(sheet, r0, 2 + g * 5,
                    "RANK(" + base + r + "," + base + "$" + firstDataRow1 + ":" + base + "$" + lastDataRow1 + ")");
                setFormulaIfFormula(sheet, r0, 3 + g * 5, base + r + "/" + base + "$" + provRow1);
                setFormulaIfFormula(sheet, r0, 5 + g * 5,
                    "RANK(" + grow + r + "," + grow + "$" + firstDataRow1 + ":" + grow + "$" + lastDataRow1 + ")");
            }
        }
    }
    /** 单元格原为公式时改写公式(排名/占比列),非公式单元格不动(保持模板式样) */
    private void setFormulaIfFormula(Sheet sheet, int rowIdx, int colIdx, String formula) {
        Row row = sheet.getRow(rowIdx);
        if (row == null) return;
        Cell c = row.getCell(colIdx);
        if (c == null || c.getCellType() != CellType.FORMULA) return;
        c.setCellFormula(formula);
    }
    /** 某年 1..limit 月城市客运累计:city → [公交客运量,公交周转量,出租客运量,出租周转量,轨道客运量,轨道周转量,轮渡客运量,轮渡周转量],含"全省" */
    private Map<String, double[]> loadCityPassengerCumulative(String yearPrefix, int limit) {
        Map<String, double[]> cum = new HashMap<>();
        for (CityBusMonthly r : cityBusMapper.selectList(
                new LambdaQueryWrapper<CityBusMonthly>().likeRight(CityBusMonthly::getReportPeriod, yearPrefix))) {
            int m = monthOf(r.getReportPeriod());
            if (m < 1 || m > limit) continue;
            String city = r.getCity() == null ? "未知" : r.getCity();
            double[] a = cum.computeIfAbsent(city, k -> new double[8]);
            a[0] += nz(r.getPassengerVolume());
            a[1] += nz(r.getTurnover());
            a[4] += nz(r.getRailPassengerVolume());
            a[5] += nz(r.getRailTurnover());
            a[6] += nz(r.getFerryPassengerVolume());
            a[7] += nz(r.getFerryTurnover());
            double[] p = cum.computeIfAbsent("全省", k -> new double[8]);
            p[0] += nz(r.getPassengerVolume());
            p[1] += nz(r.getTurnover());
            p[4] += nz(r.getRailPassengerVolume());
            p[5] += nz(r.getRailTurnover());
            p[6] += nz(r.getFerryPassengerVolume());
            p[7] += nz(r.getFerryTurnover());
        }
        for (CityTaxiMonthly r : cityTaxiMapper.selectList(
                new LambdaQueryWrapper<CityTaxiMonthly>().likeRight(CityTaxiMonthly::getReportPeriod, yearPrefix))) {
            int m = monthOf(r.getReportPeriod());
            if (m < 1 || m > limit) continue;
            String city = r.getCity() == null ? "未知" : r.getCity();
            double[] a = cum.computeIfAbsent(city, k -> new double[8]);
            a[2] += nz(r.getPassengerVolume());
            a[3] += nz(r.getTurnover());
            double[] p = cum.computeIfAbsent("全省", k -> new double[8]);
            p[2] += nz(r.getPassengerVolume());
            p[3] += nz(r.getTurnover());
        }
        return cum;
    }
    /** 某年单月城市客运数据:city → 8 维数组(口径同累计),含"全省";= month 累计 - (month-1) 累计 */
    private Map<String, double[]> loadCityPassengerMonthMap(String yearPrefix, int month) {
        Map<String, double[]> cum = loadCityPassengerCumulative(yearPrefix, month);
        if (month <= 1) return cum;
        Map<String, double[]> pre = loadCityPassengerCumulative(yearPrefix, month - 1);
        Map<String, double[]> out = new HashMap<>();
        for (Map.Entry<String, double[]> e : cum.entrySet()) {
            double[] prev = pre.get(e.getKey());
            double[] d = new double[8];
            for (int i = 0; i < 8; i++) {
                d[i] = e.getValue()[i] - (prev == null ? 0 : prev[i]);
            }
            out.put(e.getKey(), d);
        }
        return out;
    }
    // ==================== 城市客运 全省汇总(城市汇总_模板) ====================
    /** 生成_城市客运汇总.xlsx(以 城市汇总_模板.xlsx 为底稿;2026 段填库内 1..N 月累计,同比清空,2025/2024 段保留模板历史参考值) */
    public byte[] exportCityPassengerSummary(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        double[] prov = loadCityPassengerCumulative(year + "-", month).getOrDefault("全省", new double[8]);
        double busPax = prov[0], busTurn = prov[1];
        double taxiPax = prov[2], taxiTurn = prov[3];
        double railPax = prov[4], railTurn = prov[5];
        double ferryPax = prov[6], ferryTurn = prov[7];
        File template = resolveTemplate("生成_城市客运汇总.xlsx");
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            dynamicTitle(sheet, year, month); // 标题按报表期动态化
            // 行3 总客运量=公交+出租车(巡游+网约车)+轨道+轮渡;行4 公交;行5 出租车=巡游+网约车;行6 巡游;行7 网约车;行8 轨道;行9 轮渡
            setDataCell(sheet, 2, 1, busPax + taxiPax + railPax + ferryPax); // B3(POI 0 基行2)
            setDataCell(sheet, 2, 3, busTurn + taxiTurn + railTurn + ferryTurn); // D3
            setDataCell(sheet, 3, 1, busPax);   // B4 城市公交
            setDataCell(sheet, 3, 3, busTurn);  // D4
            setDataCell(sheet, 4, 1, taxiPax);  // B5 城市出租车(网约车无源数据)
            setDataCell(sheet, 4, 3, taxiTurn); // D5
            setDataCell(sheet, 5, 1, taxiPax);  // B6 其中巡游出租
            setDataCell(sheet, 5, 3, taxiTurn); // D6
            setZeroCell(sheet, 6, 1);           // B7 城市网约车(显式 0,setDataCell 对 0 会置空)
            setZeroCell(sheet, 6, 3);           // D7
            setDataCell(sheet, 7, 1, railPax);  // B8 轨道
            setDataCell(sheet, 7, 3, railTurn); // D8
            setDataCell(sheet, 8, 1, ferryPax); // B9 轮渡
            setDataCell(sheet, 8, 3, ferryTurn);// D9
            for (int r = 2; r <= 8; r++) {      // 同比 C/E:无去年同期 → 清空样例
                setDataCell(sheet, r, 2, 0);
                setDataCell(sheet, r, 4, 0);
            }
            // 底部单月表(新版式):标题行11(0基10)、数据行13-19(0基12-18),填当月完成量
            Row mTitle = sheet.getRow(10);
            if (mTitle != null && mTitle.getCell(0) != null) {
                mTitle.getCell(0).setCellValue(year + "年" + month + "月全省完成城市客运量情况");
            }
            double[] mProv = loadCityPassengerMonthMap(year + "-", month).getOrDefault("全省", new double[8]);
            double mBusPax = mProv[0], mBusTurn = mProv[1];
            double mTaxiPax = mProv[2], mTaxiTurn = mProv[3];
            double mRailPax = mProv[4], mRailTurn = mProv[5];
            double mFerryPax = mProv[6], mFerryTurn = mProv[7];
            setDataCell(sheet, 12, 1, mBusPax + mTaxiPax + mRailPax + mFerryPax); // B13 总客运量
            setDataCell(sheet, 12, 3, mBusTurn + mTaxiTurn + mRailTurn + mFerryTurn); // D13 总周转量
            setDataCell(sheet, 13, 1, mBusPax);   // B14 城市公交
            setDataCell(sheet, 13, 3, mBusTurn);  // D14
            setDataCell(sheet, 14, 1, mTaxiPax);  // B15 城市出租车
            setDataCell(sheet, 14, 3, mTaxiTurn); // D15
            setDataCell(sheet, 15, 1, mTaxiPax);  // B16 其中巡游出租
            setDataCell(sheet, 15, 3, mTaxiTurn); // D16
            setZeroCell(sheet, 16, 1);            // B17 城市网约车
            setZeroCell(sheet, 16, 3);            // D17
            setDataCell(sheet, 17, 1, mRailPax);  // B18 轨道
            setDataCell(sheet, 17, 3, mRailTurn); // D18
            setDataCell(sheet, 18, 1, mFerryPax); // B19 轮渡
            setDataCell(sheet, 18, 3, mFerryTurn);// D19
            for (int r = 12; r <= 18; r++) {      // 单月表 C/E 同比:无上年单月源 → 清空样例
                setDataCell(sheet, r, 2, 0);
                setDataCell(sheet, r, 4, 0);
            }
            recalc(wb);
            clearFormulaErrorsAll(wb); // 无去年数据的同比公式 #DIV/0! → 清空
            return toBytes(wb);
        }
    }
    // ==================== 城市客运 报表系列(一键打包) ====================
    /** 生成_城市客运报表系列.zip:公交/出租/轨道轮渡明细 + 分市州累计 + 各市州明细表 + 汇总 */
    public byte[] exportCityPassengerSeries(String period, String mode) throws Exception {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(bos)) {
            addZipEntry(zos, "生成_城市公交客运量分市州明细.xlsx", exportCityBusDetail(period, mode));
            addZipEntry(zos, "生成_巡游出租客运量分市州明细.xlsx", exportCityTaxiDetail(period, mode));
            addZipEntry(zos, "生成_轨道轮渡客运量分市州明细.xlsx", exportCityRailFerryDetail(period, mode));
            addZipEntry(zos, "生成_城市客运客运量分市州累计.xlsx", exportCityPassengerCitySum("生成_城市客运客运量分市州累计.xlsx", period, mode));
            addZipEntry(zos, "生成_城市客运各市州明细表.xlsx", exportCityPassengerCitySum("生成_城市客运各市州明细表.xlsx", period, mode));
            addZipEntry(zos, "生成_城市客运汇总.xlsx", exportCityPassengerSummary(period, mode));
        }
        return bos.toByteArray();
    }
    /** 生成_货运报表系列.zip:货运量分市州明细 + 货运量排名 + 周转量排名 */
    public byte[] exportFreightSeries(String period, String mode) throws Exception {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(bos)) {
            addZipEntry(zos, "生成_货运量分市州明细.xlsx", exportCityDetail(period, mode));
            addZipEntry(zos, "生成_货运量排名.xlsx", exportFreightRank(period, mode));
            addZipEntry(zos, "生成_周转量排名.xlsx", exportTurnoverRank(period, mode));
        }
        return bos.toByteArray();
    }
    /** 生成_公路旅客报表系列.zip:分市州明细 + 中口径明细/排名/分析 */
    public byte[] exportPassengerSeries(String period, String mode) throws Exception {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(bos)) {
            addZipEntry(zos, "生成_公路旅客分市州.xlsx", exportPassengerCityDetail(period, mode));
            addZipEntry(zos, "生成_中口径明细.xlsx", exportPassengerMidDetail(period, mode));
            addZipEntry(zos, "生成_中口径排名.xlsx", exportPassengerMidRank(period, mode));
            addZipEntry(zos, "生成_中口径分析.xlsx", exportPassengerMidAnalysis(period, mode));
        }
        return bos.toByteArray();
    }
    /**
     * 生成_道路运输量汇总表.xlsx(P0-P1 骨架):整本复制母版《YYYY年M月道路运输量汇总表.xlsx》,
     * 保留全部页签/版式/公式/2025 年缓存值,仅把表头年份动态化为目标年;数据填充按 P2 起逐步接入。
     */
    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
        return exportSummaryWorkbook(period, mode, null, 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 {
        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);
        if (template == null) {
            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)) {
                    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);
            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);
            reportProgress(progress, 45, "刷新排名页与跨页合成页");
            int rankRefreshed = refreshSummaryRankSheets(wb, effPeriod, "month");
            int cumulativeRefreshed = refreshSummaryCumulativeSheets(wb, year, effMonth); // 四张跨页合成页随报表期刷新
            int widthApplied = applySummaryReferenceLayout(wb);
            log.info("汇总工作簿数据驱动回填完成:写入当月值格数={},补写当月同比公式格数={},补写当月合计公式格数={},"
                    + "修正累计同比分母格数={},刷新排名页数值格数={},刷新四页(城市汇总/城市分市州/中口径分析/道路运输周转量)数值格数={},"
                    + "按《生成_道路运输量汇总表》对齐列宽列数={},缺数提示={}",
                    dbFilled, yoyFilled, monthFormulaFilled, cumYoyFixed, 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 才能被预览与二次处理读到
            log.info("汇总工作簿公式重算完成:成功 {} 格,失败 {} 格(失败格保留母版旧缓存值,打开时按 fullCalcOnLoad 重算)", recalc[0], recalc[1]);
            if (!wb.getCTWorkbook().isSetCalcPr()) wb.getCTWorkbook().addNewCalcPr();
            wb.getCTWorkbook().getCalcPr().setFullCalcOnLoad(true); // 还原/改写公式后打开即重算
            reportProgress(progress, 95, "写出文件");
            return toBytes(wb);
        } finally {
            ZipSecureFile.setMinInflateRatio(savedZipRatio);
        }
    }
    /** 导出进度回调(异步导出任务用):回调自身异常不影响导出 */
    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) {
            // 进度回调失败不影响导出
        }
    }
    /**
     * 汇总工作簿公式重算:POI 写文件不带缓存值,导致「全省行(Σ17市州)」「累计列」「排名 SUM」
     * 在预览/二次处理里只看到母版上一期的旧值(2026-09-18 用户报“8月没数据 / 全省行停在上一期”的根因之一)。
     * 逐格 evaluate 并把结果写回缓存值;个别不支持函数/外部引用格失败则跳过并计数,不阻塞导出。
     */
    private int[] recalcWorkbookFormulas(XSSFWorkbook wb) {
        if (wb == null) return new int[]{0, 0};
        org.apache.poi.ss.usermodel.FormulaEvaluator ev;
        try {
            ev = wb.getCreationHelper().createFormulaEvaluator();
        } catch (Exception e) {
            log.warn("汇总工作簿公式重算:创建求值器失败,跳过({})", e.getMessage());
            return new int[]{0, 0};
        }
        try {
            // 跨簿公式([1]出租!C13 之类)在外部工作簿缺失时默认抛异常,会让引用它的
            // 总行/累计/派生公式连锁失败(表现为「公式还在但没有缓存值」)。
            // 置 true 后改用公式格自身的缓存值(即定稿值)参与计算。
            ev.setIgnoreMissingWorkbooks(true);
        } catch (Exception ignore) {
            // 个别实现不支持该开关,忽略即可
        }
        int ok = 0, fail = 0;
        java.util.List<String> samples = new java.util.ArrayList<>();
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            for (Row row : sh) {
                if (row == null) continue;
                for (Cell c : row) {
                    if (c == null || c.getCellType() != CellType.FORMULA) continue;
                    try {
                        ev.evaluateFormulaCell(c); // 注意:evaluate() 无副作用,必须用 evaluateFormulaCell 才会把结果写回缓存值
                        ok++;
                    } catch (Exception ex) {
                        fail++;
                        if (samples.size() < 10) {
                            samples.add(sh.getSheetName() + "!" + c.getAddress() + " :: " + ex.getClass().getSimpleName());
                        }
                    }
                }
            }
        }
        if (!samples.isEmpty()) log.warn("汇总工作簿公式重算失败样例:{}", samples);
        return new int[]{ok, fail};
    }
    /**
     * 清掉「非 inlineStr 却残留 &lt;is&gt;」的非法单元格。
     *
     * 母版若由 openpyxl 写出,文本单元格用 &lt;is&gt;(inlineStr)存储;POI 的 setCellValue 只改写 t 与 &lt;v&gt;,
     * 不会清掉旧 &lt;is&gt;,于是产出 &lt;c t="n"&gt;&lt;v&gt;67.0&lt;/v&gt;&lt;is&gt;&lt;t&gt;67.00&lt;/t&gt;&lt;/is&gt;&lt;/c&gt; 这种非法格,
     * Excel 判定为「无法读取的内容」而直接拒绝打开(COM 表现为「不能取得类 Workbooks 的 Open 属性」)。
     * 2026-07 导出件即因此打不开(《公交》17 格 +《轨道、轮渡》2 格)。落盘前统一清理,幂等。
     */
    private int normalizeStaleInlineStrings(XSSFWorkbook wb) {
        int fixed = 0;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            org.openxmlformats.schemas.spreadsheetml.x2006.main.CTWorksheet ctw = wb.getSheetAt(i).getCTWorksheet();
            if (ctw == null || ctw.getSheetData() == null) continue;
            for (org.openxmlformats.schemas.spreadsheetml.x2006.main.CTRow row : ctw.getSheetData().getRowArray()) {
                for (org.openxmlformats.schemas.spreadsheetml.x2006.main.CTCell c : row.getCArray()) {
                    if (!c.isSetIs()) continue;
                    STCellType.Enum t = c.getT();
                    if (t == null || !STCellType.INLINE_STR.equals(t)) {
                        c.unsetIs();
                        fixed++;
                    }
                }
            }
        }
        return fixed;
    }
    /**
     * 版式对齐:按 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, wb), 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 相对 → 上级) */
    // ==================== 汇总大表:四张“跨页合成”页随报表期刷新 ====================
    // 城市汇总 / 城市分市州 / 中口径分析 / 道路运输周转量 此前整页沿用母版静态值(标题停在 1-7 月)。
    // 本段按 2026-09-13 已验证口径(与 8 月母版逐格 0 差异)从母版台账页现算:
    //   城市客运量     = 城市内公交 + 城市内巡游出租 + 城市内网约车 + 轨道 + 轮渡
    //   道路客运周转量 = 公路班线 + 城际城乡公交 + 城际城乡巡游出租 + 城际城乡网约车
    //   道路货运周转量 = 货运页 市州 规上货物周转量 + 规下货物周转量
    // 2026 年 1..N 月取台账页 2026 列(C/E/G…),去年同期取母版缓存 2025 列(V/X/Z…)——库里没有 2025 年城市客运数据。
    /** 城市客运口径指标位(0..17 城市客运,18 货运周转量) */
    private static final int SM_BUS_PAX = 0, SM_BUS_TURN = 1;
    private static final int SM_TAXI_PAX = 2, SM_TAXI_TURN = 3;
    private static final int SM_WYC_PAX = 4, SM_WYC_TURN = 5;
    private static final int SM_RAIL_PAX = 6, SM_RAIL_TURN = 7;
    private static final int SM_FERRY_PAX = 8, SM_FERRY_TURN = 9;
    private static final int SM_BX_PAX = 10, SM_BX_TURN = 11;
    private static final int SM_BUS_CX_PAX = 12, SM_BUS_CX_TURN = 13;
    private static final int SM_TAXI_CX_PAX = 14, SM_TAXI_CX_TURN = 15;
    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;
    /**
     * 刷新四张跨页合成页:当年累计(1..month 月)、当月(month 月)及对应同比;
     * 2026 与 2025 同期都取自母版台账页缓存,返回写入单元格数。
     */
    private int refreshSummaryCumulativeSheets(XSSFWorkbook wb, int year, int month) {
        if (wb == null || month <= 0) return 0;
        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);
        if (curCum.isEmpty()) {
            log.warn("汇总大表四页刷新:台账页未读到市州数据,跳过(保持母版原值)");
            return 0;
        }
        double[] pCurCum = smProvinceSum(curCum);
        double[] pLastCum = smProvinceSum(lastCum);
        double[] pCurMon = smProvinceSum(curMon);
        double[] pLastMon = smProvinceSum(lastMon);
        int n = 0;
        n += smWriteCitySummary(wb, year, month, pCurCum, pLastCum, pCurMon, pLastMon);
        n += smWriteCityByRegion(wb, month, curCum, lastCum, curMon, lastMon);
        n += smWriteMidAnalysis(wb, month, pCurCum, pLastCum, pCurMon, pLastMon);
        n += smWriteTurnoverSheets(wb, year, month, curCum, lastCum, curMon, lastMon);
        return n;
    }
    /** 17 市州合计(不含台账页“全省”公式行) */
    private double[] smProvinceSum(Map<String, double[]> m) {
        double[] p = new double[SM_DIM];
        for (String c : RegionUtil.cityList()) {
            double[] a = m.get(c);
            if (a == null) continue;
            for (int i = 0; i < SM_DIM; i++) p[i] += a[i];
        }
        return p;
    }
    /** 台账页累计取数:市州规范名 -> 19 维指标 */
    private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, boolean curYear, 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);
        return out;
    }
    /** 公交/出租/网约车页:每市州 4 行(客运量/旅客周转量/其中城市内客运量/其中城市内旅客周转量) */
    private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, boolean curYear, int[] months,
                                   Map<String, double[]> out, int paxIdx, int turnIdx, int cxPaxIdx, int cxTurnIdx) {
        Sheet sh = wb.getSheet(sheetName);
        if (sh == null) return;
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            String city = smCityOf(row.getCell(0));
            if (city == null) continue;
            int paxRow = -1, turnRow = -1, cxPaxRow = -1, cxTurnRow = -1;
            for (int k = r; k <= Math.min(last, r + 4); k++) {
                Row kr = sh.getRow(k);
                if (kr == null) continue;
                String b = smText(kr.getCell(1));
                if (b == null || b.contains("个体")) continue;
                boolean inner = b.contains("城市内");
                boolean turn = b.contains("周转量");
                if (inner) {
                    if (turn) { if (cxTurnRow < 0) cxTurnRow = k; }
                    else if (cxPaxRow < 0) cxPaxRow = k;
                } else {
                    if (turn) { if (turnRow < 0) turnRow = k; }
                    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[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
            arr[paxIdx] += cityPax;                 // 城市客运口径取“城市内”
            arr[turnIdx] += cityTurn;
            arr[cxPaxIdx] += totalPax - cityPax;    // 城际城乡 = 总量 - 城市内
            arr[cxTurnIdx] += totalTurn - cityTurn;
        }
    }
    /** 轨道、轮渡页:轨道块(武汉/黄石)+ 轮渡块(武汉) */
    private void smReadRailFerry(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet("轨道、轮渡");
        if (sh == null) return;
        int last = sh.getLastRowNum();
        int ferryTitle = -1;
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            String a = smText(row.getCell(0));
            if (a != null && a.contains("轮渡客运量")) { ferryTitle = r; break; }
        }
        smReadRailFerryBlock(sh, curYear, 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);
    }
    private void smReadRailFerryBlock(Sheet sh, boolean curYear, int[] months, Map<String, double[]> out,
                                      int start, int end, int baseIdx) {
        for (int r = start; r <= end; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            String city = smCityOf(row.getCell(0));
            if (city == null) continue;
            int paxRow = -1, turnRow = -1;
            for (int k = r; k <= Math.min(end, r + 3); k++) {
                Row kr = sh.getRow(k);
                if (kr == null) continue;
                String b = smText(kr.getCell(1));
                if (b == null) continue;
                if (b.contains("周转量")) { if (turnRow < 0) turnRow = k; }
                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);
        }
    }
    /** 班线包车页:每市州 客运量 / 旅客周转量(“其中个体”行不重复计入) */
    private void smReadBanxian(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet("班线包车");
        if (sh == null) return;
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            String city = smCityOf(row.getCell(0));
            if (city == null) continue;
            int paxRow = -1, turnRow = -1;
            for (int k = r; k <= Math.min(last, r + 4); k++) {
                Row kr = sh.getRow(k);
                if (kr == null) continue;
                String b = smText(kr.getCell(1));
                if (b == null || b.contains("个体")) continue;
                if (b.contains("周转量")) { if (turnRow < 0) turnRow = k; }
                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);
        }
    }
    /** 货运页:每市州 规上货物周转量 + 规下货物周转量(“规上+规下”行本身是求和公式,不直接取) */
    private void smReadFreightTurnover(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
        Sheet sh = wb.getSheet(" 货运");
        if (sh == null) sh = wb.getSheet("货运");
        if (sh == null) return;
        int last = sh.getLastRowNum();
        for (int r = sh.getFirstRowNum(); r <= last; r++) {
            Row row = sh.getRow(r);
            if (row == null) continue;
            String city = smCityOf(row.getCell(0));
            if (city == null) continue;
            int above = -1, below = -1;
            for (int k = r; k <= Math.min(last, r + 6); k++) {
                Row kr = sh.getRow(k);
                if (kr == null) continue;
                String b = smText(kr.getCell(1));
                if (b == null || b.contains("其中")) continue;
                if (b.contains("规上货物周转量")) { if (above < 0) above = k; }
                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);
        }
    }
    private double smSumMonths(Sheet sh, int rowIdx, boolean curYear, int[] months) {
        if (sh == null || rowIdx < 0) 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);
            s += smNum(row.getCell(col));
        }
        return s;
    }
    /** 台账页单元格取数(数字 / 数字文本 / 公式缓存) */
    private double smNum(Cell c) {
        if (c == null) return 0;
        try {
            CellType t = c.getCellType();
            if (t == CellType.NUMERIC) return c.getNumericCellValue();
            if (t == CellType.FORMULA) {
                return c.getCachedFormulaResultType() == CellType.NUMERIC ? c.getNumericCellValue() : 0;
            }
            if (t == CellType.STRING) {
                String s = c.getStringCellValue();
                if (s == null) return 0;
                s = s.replace(",", "").replace("%", "").trim();
                if (s.isEmpty()) return 0;
                return Double.parseDouble(s);
            }
        } catch (Exception ignore) {
            return 0;
        }
        return 0;
    }
    private String smText(Cell c) {
        if (c == null) return null;
        try {
            return c.getCellType() == CellType.STRING ? c.getStringCellValue() : null;
        } catch (Exception e) {
            return null;
        }
    }
    /** 列 A 文案 -> 规范市州名(“全省/湖北省”与非市州行返回 null) */
    private String smCityOf(Cell a) {
        String t = smText(a);
        if (t == null || t.trim().isEmpty()) return null;
        String norm = RegionUtil.normalizeCityName(t.trim());
        if (norm == null) return null;
        return RegionUtil.cityList().contains(norm) ? norm : null;
    }
    private double smYoy(double cur, double last) {
        return last == 0.0 ? 0.0 : (cur - last) / last;
    }
    /** 写值:公式格不动(保留母版公式,打开即重算);0 且原为空格则不新增 */
    private int smSetVal(Sheet sh, int r0, int c0, double v) {
        Row row = sh.getRow(r0);
        if (row == null) return 0;
        Cell c = row.getCell(c0);
        if (c != null && c.getCellType() == CellType.FORMULA) return 0;
        if (c == null) {
            if (v == 0.0) return 0;
            c = row.createCell(c0);
            Cell left = row.getCell(c0 - 1);
            if (left != null) c.setCellStyle(left.getCellStyle());
        } else if (v == 0.0 && c.getCellType() == CellType.BLANK) {
            return 0;
        }
        c.setBlank();
        c.setCellValue(v);
        return 1;
    }
    private void smSetTitle(Sheet sh, int r0, int c0, String text) {
        if (sh == null) return;
        Row row = sh.getRow(r0);
        if (row == null) return;
        Cell c = row.getCell(c0);
        if (c == null || c.getCellType() == CellType.FORMULA) return;
        c.setBlank();
        c.setCellValue(text);
    }
    /** 城市汇总页:块1(1..N 月累计)行 3-9,块2(当月)行 14-20;列 B/C/D/E = 客运量/同比/周转量/同比 */
    private int smWriteCitySummary(XSSFWorkbook wb, int year, int month,
                                   double[] pCurCum, double[] pLastCum, double[] pCurMon, double[] pLastMon) {
        Sheet sh = wb.getSheet("城市汇总");
        if (sh == null) return 0;
        smSetTitle(sh, 0, 0, year + "年1-" + month + "月全省累计完成城市客运量情况");
        smSetTitle(sh, 11, 0, year + "年" + month + "月全省完成城市客运量情况");
        int n = 0;
        int[] rows = {2, 3, 4, 5, 6, 7, 8};
        for (int i = 0; i < rows.length; i++) {
            double[] v = smCitySummaryRow(pCurCum, pLastCum, i);
            n += smSetVal(sh, rows[i], 1, v[0]);
            n += smSetVal(sh, rows[i], 2, v[2]);
            n += smSetVal(sh, rows[i], 3, v[1]);
            n += smSetVal(sh, rows[i], 4, v[3]);
        }
        for (int i = 0; i < rows.length; i++) {
            double[] v = smCitySummaryRow(pCurMon, pLastMon, i);
            n += smSetVal(sh, rows[i] + 11, 1, v[0]);
            n += smSetVal(sh, rows[i] + 11, 2, v[2]);
            n += smSetVal(sh, rows[i] + 11, 3, v[1]);
            n += smSetVal(sh, rows[i] + 11, 4, v[3]);
        }
        return n;
    }
    /** i=0 总客运量 1 公交 2 出租车 3 巡游出租 4 网约车 5 轨道 6 轮渡;返回 [客运量, 周转量, 客运量同比, 周转量同比] */
    private double[] smCitySummaryRow(double[] p, double[] pl, int i) {
        double pax, turn, lPax, lTurn;
        if (i == 0) {
            pax = p[SM_BUS_PAX] + p[SM_TAXI_PAX] + p[SM_WYC_PAX] + p[SM_RAIL_PAX] + p[SM_FERRY_PAX];
            turn = p[SM_BUS_TURN] + p[SM_TAXI_TURN] + p[SM_WYC_TURN] + p[SM_RAIL_TURN] + p[SM_FERRY_TURN];
            lPax = pl[SM_BUS_PAX] + pl[SM_TAXI_PAX] + pl[SM_WYC_PAX] + pl[SM_RAIL_PAX] + pl[SM_FERRY_PAX];
            lTurn = pl[SM_BUS_TURN] + pl[SM_TAXI_TURN] + pl[SM_WYC_TURN] + pl[SM_RAIL_TURN] + pl[SM_FERRY_TURN];
        } else if (i == 1) {
            pax = p[SM_BUS_PAX]; turn = p[SM_BUS_TURN]; lPax = pl[SM_BUS_PAX]; lTurn = pl[SM_BUS_TURN];
        } else if (i == 2) {
            pax = p[SM_TAXI_PAX] + p[SM_WYC_PAX]; turn = p[SM_TAXI_TURN] + p[SM_WYC_TURN];
            lPax = pl[SM_TAXI_PAX] + pl[SM_WYC_PAX]; lTurn = pl[SM_TAXI_TURN] + pl[SM_WYC_TURN];
        } else if (i == 3) {
            pax = p[SM_TAXI_PAX]; turn = p[SM_TAXI_TURN]; lPax = pl[SM_TAXI_PAX]; lTurn = pl[SM_TAXI_TURN];
        } else if (i == 4) {
            pax = p[SM_WYC_PAX]; turn = p[SM_WYC_TURN]; lPax = pl[SM_WYC_PAX]; lTurn = pl[SM_WYC_TURN];
        } else if (i == 5) {
            pax = p[SM_RAIL_PAX]; turn = p[SM_RAIL_TURN]; lPax = pl[SM_RAIL_PAX]; lTurn = pl[SM_RAIL_TURN];
        } else {
            pax = p[SM_FERRY_PAX]; turn = p[SM_FERRY_TURN]; lPax = pl[SM_FERRY_PAX]; lTurn = pl[SM_FERRY_TURN];
        }
        return new double[]{pax, turn, smYoy(pax, lPax), smYoy(turn, lTurn)};
    }
    /** 城市分市州页:4 块(累计客运量/累计周转量/当月客运量/当月周转量),每块 全省 1 行 + 17 市州 */
    private int smWriteCityByRegion(XSSFWorkbook wb, int month,
                                    Map<String, double[]> curCum, Map<String, double[]> lastCum,
                                    Map<String, double[]> curMon, Map<String, double[]> lastMon) {
        Sheet sh = wb.getSheet("城市分市州");
        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 + "月情况");
        int n = 0;
        n += smWriteCityByRegionBlock(sh, 4, true, curCum, lastCum);
        n += smWriteCityByRegionBlock(sh, 26, false, curCum, lastCum);
        n += smWriteCityByRegionBlock(sh, 49, true, curMon, lastMon);
        n += smWriteCityByRegionBlock(sh, 71, false, curMon, lastMon);
        return n;
    }
    private int smWriteCityByRegionBlock(Sheet sh, int provRow, boolean pax,
                                         Map<String, double[]> cur, Map<String, double[]> last) {
        int n = 0;
        double[] provCur = smProvinceSum(cur);
        double[] provLast = smProvinceSum(last);
        List<String> cities = RegionUtil.cityList();
        for (int i = -1; i < cities.size(); i++) {
            int r = provRow + i + 1;
            double[] c = i < 0 ? provCur : cur.get(cities.get(i));
            double[] l = i < 0 ? provLast : last.get(cities.get(i));
            if (c == null) c = new double[SM_DIM];
            if (l == null) l = new double[SM_DIM];
            double[] g = smCityGroupValues(c, l, pax);
            n += smSetVal(sh, r, 1, g[0]);
            n += smSetVal(sh, r, 4, g[6]);
            n += smSetVal(sh, r, 6, g[1]);
            n += smSetVal(sh, r, 9, g[7]);
            n += smSetVal(sh, r, 11, g[2]);
            n += smSetVal(sh, r, 14, g[8]);
            n += smSetVal(sh, r, 16, g[3]);
            n += smSetVal(sh, r, 19, g[9]);
            n += smSetVal(sh, r, 21, g[4]);
            n += smSetVal(sh, r, 22, g[10]);
            n += smSetVal(sh, r, 23, g[5]);
            n += smSetVal(sh, r, 24, g[11]);
        }
        return n;
    }
    /** 6 组值 + 6 组增速:[总量, 公交, 出租, 网约车, 轨道, 轮渡, 总量增速, 公交…, 轮渡] */
    private double[] smCityGroupValues(double[] c, double[] l, boolean pax) {
        int busI = pax ? SM_BUS_PAX : SM_BUS_TURN;
        int taxiI = pax ? SM_TAXI_PAX : SM_TAXI_TURN;
        int wycI = pax ? SM_WYC_PAX : SM_WYC_TURN;
        int railI = pax ? SM_RAIL_PAX : SM_RAIL_TURN;
        int ferryI = pax ? SM_FERRY_PAX : SM_FERRY_TURN;
        double bus = c[busI], taxi = c[taxiI], wyc = c[wycI], rail = c[railI], ferry = c[ferryI];
        double lBus = l[busI], lTaxi = l[taxiI], lWyc = l[wycI], lRail = l[railI], lFerry = l[ferryI];
        double total = bus + taxi + wyc + rail + ferry;
        double lTotal = lBus + lTaxi + lWyc + lRail + lFerry;
        return new double[]{total, bus, taxi, wyc, rail, ferry,
                smYoy(total, lTotal), smYoy(bus, lBus), smYoy(taxi, lTaxi), smYoy(wyc, lWyc),
                smYoy(rail, lRail), smYoy(ferry, lFerry)};
    }
    /** 中口径分析页:块1(累计)行 3-7,块2(当月)行 12-16;列 B/C/F/G = 客运量/同比/周转量/同比 */
    private int smWriteMidAnalysis(XSSFWorkbook wb, int month,
                                   double[] pCurCum, double[] pLastCum, double[] pCurMon, double[] pLastMon) {
        Sheet sh = wb.getSheet("中口径分析");
        if (sh == null) return 0;
        smSetTitle(sh, 0, 0, "1-" + month + "月中口径客运量及周转量");
        smSetTitle(sh, 9, 0, month + "月中口径客运量及周转量");
        int n = 0;
        int[] rows = {2, 3, 4, 5, 6};
        for (int i = 0; i < rows.length; i++) {
            double[] v = smMidRow(pCurCum, pLastCum, i);
            n += smSetVal(sh, rows[i], 1, v[0]);
            n += smSetVal(sh, rows[i], 2, v[2]);
            n += smSetVal(sh, rows[i], 5, v[1]);
            n += smSetVal(sh, rows[i], 6, v[3]);
        }
        for (int i = 0; i < rows.length; i++) {
            double[] v = smMidRow(pCurMon, pLastMon, i);
            n += smSetVal(sh, rows[i] + 9, 1, v[0]);
            n += smSetVal(sh, rows[i] + 9, 2, v[2]);
            n += smSetVal(sh, rows[i] + 9, 5, v[1]);
            n += smSetVal(sh, rows[i] + 9, 6, v[3]);
        }
        return n;
    }
    /** i=0 总 1 公路班线 2 城际城乡公交 3 城际城乡巡游出租 4 城际城乡网约车;返回 [客运量, 周转量, 客运量同比, 周转量同比] */
    private double[] smMidRow(double[] p, double[] pl, int i) {
        int[] paxIdx = {SM_BX_PAX, SM_BX_PAX, SM_BUS_CX_PAX, SM_TAXI_CX_PAX, SM_WYC_CX_PAX};
        int[] turnIdx = {SM_BX_TURN, SM_BX_TURN, SM_BUS_CX_TURN, SM_TAXI_CX_TURN, SM_WYC_CX_TURN};
        double pax, turn, lPax, lTurn;
        if (i == 0) {
            pax = p[SM_BX_PAX] + p[SM_BUS_CX_PAX] + p[SM_TAXI_CX_PAX] + p[SM_WYC_CX_PAX];
            turn = p[SM_BX_TURN] + p[SM_BUS_CX_TURN] + p[SM_TAXI_CX_TURN] + p[SM_WYC_CX_TURN];
            lPax = pl[SM_BX_PAX] + pl[SM_BUS_CX_PAX] + pl[SM_TAXI_CX_PAX] + pl[SM_WYC_CX_PAX];
            lTurn = pl[SM_BX_TURN] + pl[SM_BUS_CX_TURN] + pl[SM_TAXI_CX_TURN] + pl[SM_WYC_CX_TURN];
        } else {
            pax = p[paxIdx[i]]; turn = p[turnIdx[i]];
            lPax = pl[paxIdx[i]]; lTurn = pl[turnIdx[i]];
        }
        return new double[]{pax, turn, smYoy(pax, lPax), smYoy(turn, lTurn)};
    }
    /** 道路运输周转量页:块1(累计)行 4-21,块2(当月)行 26-43;只写值列(B/E/V 等公式保留) */
    private int smWriteTurnoverSheets(XSSFWorkbook wb, int year, int month,
                                      Map<String, double[]> curCum, Map<String, double[]> lastCum,
                                      Map<String, double[]> curMon, Map<String, double[]> lastMon) {
        Sheet sh = wb.getSheet("道路运输周转量");
        if (sh == null) return 0;
        smSetTitle(sh, 0, 0, year + "年1-" + month + "月全省累计完成交通周转量情况");
        smSetTitle(sh, 22, 0, year + "年" + month + "月全省完成交通周转量情况");
        int n = 0;
        n += smWriteTurnoverBlock(sh, 3, curCum, lastCum);
        n += smWriteTurnoverBlock(sh, 25, curMon, lastMon);
        return n;
    }
    private int smWriteTurnoverBlock(Sheet sh, int provRow, Map<String, double[]> cur, Map<String, double[]> last) {
        int n = 0;
        double[] provCur = smProvinceSum(cur);
        double[] provLast = smProvinceSum(last);
        List<String> cities = RegionUtil.cityList();
        for (int i = -1; i < cities.size(); i++) {
            int r = provRow + i + 1;
            double[] c = i < 0 ? provCur : cur.get(cities.get(i));
            double[] l = i < 0 ? provLast : last.get(cities.get(i));
            if (c == null) c = new double[SM_DIM];
            if (l == null) l = new double[SM_DIM];
            double freight = c[SM_FREIGHT_TURN], lFreight = l[SM_FREIGHT_TURN];
            double pax = c[SM_BUS_PAX] + c[SM_TAXI_PAX] + c[SM_WYC_PAX] + c[SM_RAIL_PAX] + c[SM_FERRY_PAX];
            double lPax = l[SM_BUS_PAX] + l[SM_TAXI_PAX] + l[SM_WYC_PAX] + l[SM_RAIL_PAX] + l[SM_FERRY_PAX];
            double road = c[SM_BX_TURN] + c[SM_BUS_CX_TURN] + c[SM_TAXI_CX_TURN] + c[SM_WYC_CX_TURN];
            double lRoad = l[SM_BX_TURN] + l[SM_BUS_CX_TURN] + l[SM_TAXI_CX_TURN] + l[SM_WYC_CX_TURN];
            n += smSetVal(sh, r, 6, freight);
            n += smSetVal(sh, r, 9, smYoy(freight, lFreight));
            n += smSetVal(sh, r, 11, pax);
            n += smSetVal(sh, r, 14, smYoy(pax, lPax));
            n += smSetVal(sh, r, 16, road);
            n += smSetVal(sh, r, 19, smYoy(road, lRoad));
            n += smSetVal(sh, r, 22, lFreight);
            n += smSetVal(sh, r, 23, lPax);
            n += smSetVal(sh, r, 24, lRoad);
        }
        return n;
    }
    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 骨架期不允许跨月/跨年静默回退,
     *  目标期无对应母版时明确报错,月份扩展规则待 P0 母版定稿后再放开) */
    private File resolveSummaryMother(String period, String mode) throws Exception {
        String year = period != null && period.contains("-") ? period.split("-")[0] : (period == null ? "" : period);
        int month = period != null && period.contains("-") ? monthOf(period, mode) : 12;
        String name = year + "年" + month + "月道路运输量汇总表.xlsx";
        return resolveAnyTemplate(summaryTemplateDir, name);
    }
    // ==================== 网约车客运量拆分表(月度) ====================
    /** 网约车客运量拆分表:输入=部级订单(网约车订单及全省总量.xlsx G-W)+ 全省 pin(B-E)+ 巡游出租月报(DB);
     *  算法与《网约车拆分.xlsx》月页一致:客运量=订单占比×全省总量;城市内=巡游出租城市内占比代理、武汉保持自身其余按剩余分摊;
     *  周转量=客运量×巡游出租平均运距占比分摊全省周转量 pin,城市内周转量同法。 */
    public byte[] exportWycSplit(String period, String mode) throws Exception {
        if (period == null || !period.contains("-")) {
            throw new RuntimeException("网约车客运量拆分表仅支持按月生成,请选择具体月份");
        }
        int year = Integer.parseInt(period.split("-")[0]);
        int month = parseMonth(period);
        Map<String, Object> inp = loadWycMonthlyInput(period, mode);
        if (inp == null) {
            throw new RuntimeException("《网约车订单及全省总量.xlsx》缺少报表期 " + period + " 的数据行,请先维护当月订单与全省 pin");
        }
        double pinK = (Double) inp.get("pinK");
        double pinCK = (Double) inp.get("pinCK");
        double pinZ = (Double) inp.get("pinZ");
        double pinCZ = (Double) inp.get("pinCZ");
        double orderSum = (Double) inp.get("orderSum");
        double[] orderArr = (double[]) inp.get("orders");
        if (pinK <= 0 || pinCK <= 0 || pinZ <= 0 || pinCZ <= 0 || orderSum <= 0) {
            throw new RuntimeException("网约车输入 " + period + " 行不完整:全省 pin / 订单缺失,请核对《网约车订单及全省总量.xlsx》");
        }
        List<String> cities = RegionUtil.cityList();
        int n = cities.size();
        if (orderArr == null || orderArr.length < n) {
            throw new RuntimeException("网约车订单列数不足(应为 17 市州订单列)");
        }
        Map<String, double[]> taxi = taxiMonthlyOf(period);
        java.util.List<String> missingTaxi = new java.util.ArrayList<>();
        double[] Dtax = new double[n];
        double[] Etax = new double[n];
        double[] avgD = new double[n];
        double[] avgDc = new double[n];
        for (int i = 0; i < n; i++) {
            double[] a = taxi.get(cities.get(i));
            if (a == null || a[0] <= 0) {
                missingTaxi.add(cities.get(i));
                continue;
            }
            Dtax[i] = a[0];
            Etax[i] = a[2];
            avgD[i] = a[4];
            avgDc[i] = a[5];
        }
        if (!missingTaxi.isEmpty()) {
            throw new RuntimeException("巡游出租月报缺少以下市州数据:" + String.join("、", missingTaxi));
        }
        double[] C = new double[n];
        double[] H = new double[n];
        double[] I = new double[n];
        double[] Gl = new double[n];
        double[] Hl = new double[n];
        double[] Il = new double[n];
        double sumG = 0;
        double sumDl = 0;
        double sumEl = 0;
        for (int i = 0; i < n; i++) {
            C[i] = orderArr[i] / orderSum * pinK;
            double F = Etax[i] / Dtax[i];
            double G = C[i] * F;
            sumG += G;
            double Dl = C[i] * avgD[i];
            double El = H[i] * avgDc[i];
            sumDl += Dl;
            sumEl += El;
        }
        // 城市内:武汉保持自身,其余按 G 剩余占比分摊至全省城市内 pin
        for (int i = 0; i < n; i++) {
            if (i == 0) {
                H[i] = C[i] * (Etax[i] / Dtax[i]);
            } else {
                H[i] = (C[i] * (Etax[i] / Dtax[i])) / (sumG - C[0] * (Etax[0] / Dtax[0])) * (pinCK - C[0] * (Etax[0] / Dtax[0]));
            }
            I[i] = C[i] - H[i];
        }
        // 周转量:按 客运量×巡游出租平均运距 占比分摊全省周转量 pin
        double whG = C[0] * (Etax[0] / Dtax[0]);
        double whDl = C[0] * avgD[0];
        double[] El2 = new double[n];
        double sumEl2 = 0;
        for (int i = 0; i < n; i++) {
            double Dl = C[i] * avgD[i];
            double El2v = H[i] * avgDc[i];
            El2[i] = El2v;
            sumEl2 += El2v;
            Gl[i] = Dl / sumDl * pinZ;
        }
        for (int i = 0; i < n; i++) {
            if (i == 0) {
                Hl[i] = Gl[i];
            } else {
                Hl[i] = El2[i] / (sumEl2 - El2[0]) * (pinCZ - Gl[0]);
            }
            Il[i] = Gl[i] - Hl[i];
        }
        return writeWycSplitWorkbook(period, cities, orderArr, C, Dtax, Etax, H, I, avgD, avgDc, Gl, Hl, Il, pinK, pinCK, pinZ, pinCZ, orderSum);
    }
    private byte[] writeWycSplitWorkbook(String period, List<String> cities, double[] orders, double[] C, double[] Dtax,
                                         double[] Etax, double[] H, double[] I, double[] avgD, double[] avgDc,
                                         double[] Gl, double[] Hl, double[] Il,
                                         double pinK, double pinCK, double pinZ, double pinCZ, double orderSum) throws Exception {
        XSSFWorkbook wb = new XSSFWorkbook();
        XSSFSheet sh = wb.createSheet(period + "拆分");
        int n = cities.size();
        // 客运量块
        Row t = sh.createRow(0);
        t.createCell(0).setCellValue("网约车客运量拆分(万人)  报表期:" + period);
        String[] head1 = {"市州", "订单总数(单)", "网约车客运量(万人)", "巡游出租客运量(万人)", "巡游出租城市内客运量(万人)", "城市内占比", "网约车城市内客运量(万人)", "网约车城际城乡客运量(万人)"};
        Row h1 = sh.createRow(1);
        for (int j = 0; j < head1.length; j++) h1.createCell(j).setCellValue(head1[j]);
        int r = 2;
        writeWycRow(sh, r++, "全省", orderSum, pinK, sum(Dtax), sum(Etax), pinK > 0 ? pinCK / pinK : 0, pinCK, pinK - pinCK);
        for (int i = 0; i < n; i++) {
            writeWycRow(sh, r++, cities.get(i), orders[i], C[i], Dtax[i], Etax[i], Dtax[i] > 0 ? Etax[i] / Dtax[i] : 0, H[i], I[i]);
        }
        r += 1;
        Row t2 = sh.createRow(r++);
        t2.createCell(0).setCellValue("旅客周转量拆分(万人公里)");
        String[] head2 = {"市州", "出租车平均运距(公里)", "出租车城市内平均运距(公里)", "网约车旅客周转量(万人公里)", "网约车城市内旅客周转量(万人公里)", "网约车城际城乡旅客周转量(万人公里)"};
        Row h2 = sh.createRow(r++);
        for (int j = 0; j < head2.length; j++) h2.createCell(j).setCellValue(head2[j]);
        writeWycRow2(sh, r++, "全省", 0, 0, pinZ, pinCZ, pinZ - pinCZ);
        for (int i = 0; i < n; i++) {
            writeWycRow2(sh, r++, cities.get(i), avgD[i], avgDc[i], Gl[i], Hl[i], Il[i]);
        }
        r += 1;
        String[] notes = {
            "注:1) 客运量 C = 订单占比 × 全省网约车客运量;城市内 H:武汉=自身,其余按 G 剩余占比分摊至全省城市内 pin;城际城乡 = 总量 - 城市内。",
            "    2) 周转量 G = 客运量 × 巡游出租平均运距 的占比 × 全省网约车周转量 pin;城市内周转量同法(用城市内平均运距)。",
            "    3) 输入:订单 = 当月分析报告附件1(17 市州订单数);全省 pin = 网约车总数.xlsx;巡游出租 = 巡游出租月报(系统导入,含平均运距)。",
            "    4) 武汉无城际城乡;数值保留全精度,界面/文件显示两位小数。"
        };
        for (String nt : notes) {
            Row nr = sh.createRow(r++);
            nr.createCell(0).setCellValue(nt);
        }
        for (int j = 0; j < 8; j++) sh.setColumnWidth(j, 18 * 256);
        return toBytes(wb);
    }
    private void writeWycRow(Sheet sh, int r, String name, double ord, double k, double dt, double et, double f, double h, double i) {
        Row row = sh.createRow(r);
        row.createCell(0).setCellValue(name);
        row.createCell(1).setCellValue(ord);
        row.createCell(2).setCellValue(k);
        row.createCell(3).setCellValue(dt);
        row.createCell(4).setCellValue(et);
        row.createCell(5).setCellValue(f);
        row.createCell(6).setCellValue(h);
        row.createCell(7).setCellValue(i);
    }
    private void writeWycRow2(Sheet sh, int r, String name, double ad, double adc, double g, double h, double i) {
        Row row = sh.createRow(r);
        row.createCell(0).setCellValue(name);
        row.createCell(1).setCellValue(ad);
        row.createCell(2).setCellValue(adc);
        row.createCell(3).setCellValue(g);
        row.createCell(4).setCellValue(h);
        row.createCell(5).setCellValue(i);
    }
    private double sum(double[] a) {
        double s = 0;
        for (double v : a) s += v;
        return s;
    }
    /** 读取 docs/城市客运/网约车订单及全省总量.xlsx:按 period(yyyy-MM) 行取 pin(B-E) 与 17 市州订单(G-W) */
    private Map<String, Object> loadWycMonthlyInput(String period, String mode) throws Exception {
        String target = period;
        File f;
        try {
            f = resolveAnyTemplate(templateDir, "网约车订单及全省总量.xlsx");
        } catch (Exception e) {
            throw new RuntimeException("未找到《网约车订单及全省总量.xlsx》,请放到 docs/城市客运 目录");
        }
        try (InputStream in = new FileInputStream(f);
             org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in)) {
            org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(0);
            org.apache.poi.ss.usermodel.DataFormatter df = new org.apache.poi.ss.usermodel.DataFormatter();
            List<String> cities = RegionUtil.cityList();
            int n = cities.size();
            for (int i = 1; i <= sh.getLastRowNum(); i++) {
                Row row = sh.getRow(i);
                if (row == null) continue;
                Cell c0 = row.getCell(0);
                if (c0 == null) continue;
                String m = df.formatCellValue(c0).trim();
                if (m == null || m.isEmpty()) continue;
                if (m.length() > 7) m = m.substring(0, 7);
                if (!target.equals(m)) continue;
                Map<String, Object> map = new HashMap<>();
                map.put("pinK", cellNum(row, 1, df, 0.0));
                map.put("pinCK", cellNum(row, 2, df, 0.0));
                map.put("pinZ", cellNum(row, 3, df, 0.0));
                map.put("pinCZ", cellNum(row, 4, df, 0.0));
                double[] orders = new double[n];
                double orderSum = 0;
                for (int k = 0; k < n; k++) {
                    orders[k] = cellNum(row, 6 + k, df, 0.0);
                    orderSum += orders[k];
                }
                if (orderSum <= 0) {
                    orderSum = cellNum(row, 5, df, 0.0); // F 列合计兜底
                }
                map.put("orders", orders);
                map.put("orderSum", orderSum);
                return map;
            }
            return null;
        }
    }
    private double cellNum(Row row, int idx, org.apache.poi.ss.usermodel.DataFormatter df, double def) {
        Cell c = row.getCell(idx);
        if (c == null) return def;
        if (c.getCellType() == CellType.NUMERIC) return c.getNumericCellValue();
        String s = df.formatCellValue(c).replace(",", "").trim();
        if (s == null || s.isEmpty()) return def;
        try {
            return Double.parseDouble(s);
        } catch (Exception e) {
            return def;
        }
    }
    /** 巡游出租月报(DB)按月加载:市州 -> [客运量, 周转量, 城市内客运量, 城市内周转量, 平均运距, 城市内平均运距] */
    private Map<String, double[]> taxiMonthlyOf(String period) {
        Map<String, double[]> map = new java.util.LinkedHashMap<>();
        List<CityTaxiMonthly> rows = cityTaxiMapper.selectList(
            new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, period));
        for (CityTaxiMonthly r : rows) {
            String city = RegionUtil.normalizeCityName(r.getCity());
            if (city == null || city.isEmpty()) city = "未知";
            double[] a = map.computeIfAbsent(city, k -> new double[6]);
            a[0] += nz(r.getPassengerVolume());
            a[1] += nz(r.getTurnover());
            a[2] += nz(r.getPassengerCity());
            a[3] += nz(r.getTurnoverCity());
            if (r.getAvgDistance() != null && r.getAvgDistance() > 0) a[4] = r.getAvgDistance();
            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] = a[1] / a[0];
            if (a[5] <= 0 && a[2] > 0) a[5] = a[3] / a[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());
            // 《 货运》页 2025..2021 年度块(T 列起,含各年累计/累计同比列)在母版里是「市州派生公式」,
            // 备份母版同区仍是旧的写死常量(全省规下等),回填会让 2021/2022/2023 累计又对不上(“生成又复活”),故该区不回填。
            boolean skipFreightHistoryBlocks = " 货运".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;
                    if (skipFreightHistoryBlocks && dc.getColumnIndex() >= 19) continue; // T 列及以后 = 2025..2021 年度块
                    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 参考列不动) */
    private void dynamicSummaryYear(XSSFWorkbook wb, int year) {
        String oldPrefix = "2026年";
        String newPrefix = year + "年";
        if (oldPrefix.equals(newPrefix)) return;
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet sh = wb.getSheetAt(i);
            if (sh == null) continue;
            for (Row row : sh) {
                if (row == null) continue;
                for (Cell c : row) {
                    if (c == null || c.getCellType() != CellType.STRING) continue;
                    String v = c.getStringCellValue();
                    if (v != null && v.contains(oldPrefix)) {
                        c.setCellValue(v.replace(oldPrefix, newPrefix));
                    }
                }
            }
        }
    }
    /** 生成_投资报表系列.zip:预安排 + 十五五 + 亿元 + 经济强县 */
    public byte[] exportInvestSeries(String period, String mode) throws Exception {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(bos)) {
            addZipEntry(zos, "生成_全省预安排计划进度情况汇总.xls", investPlan(period, mode));
            addZipEntry(zos, "生成_十五五规划物流项目进展情况.xls", investFiveYearLogistics(period, mode));
            addZipEntry(zos, "生成_湖北省(客货站场)亿元投资项目.xls", investBillion(period, mode));
            addZipEntry(zos, "生成_经济强县交通物流基础设施投资统计报表.xls", investCounty(period, mode));
        }
        return bos.toByteArray();
    }
    /** 生成_所选报表.zip:按 type 列表打包(前端"生成选中"多选时用) */
    public byte[] exportSelectedSeries(String period, String mode, List<String> types) throws Exception {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(bos)) {
            if (types != null) {
                for (String t : types) {
                    if (t == null || t.trim().isEmpty()) continue;
                    byte[] data = selectedReportData(period, mode, t);
                    if (data != null) {
                        String name = REPORT_FILE_NAMES.get(t);
                        if (name == null) name = "生成_" + t + ".xlsx";
                        addZipEntry(zos, name, data);
                    }
                }
            }
        }
        return bos.toByteArray();
    }
    private byte[] selectedReportData(String period, String mode, String type) throws Exception {
        switch (type) {
            case "cityDetail": return exportCityDetail(period, mode);
            case "freightRank": return exportFreightRank(period, mode);
            case "turnoverRank": return exportTurnoverRank(period, mode);
            case "passengerCityDetail": return exportPassengerCityDetail(period, mode);
            case "passengerMidDetail": return exportPassengerMidDetail(period, mode);
            case "passengerMidRank": return exportPassengerMidRank(period, mode);
            case "passengerMidAnalysis": return exportPassengerMidAnalysis(period, mode);
            case "summaryWorkbook": return exportSummaryWorkbook(period, mode);
            case "energySummary": return exportEnergySummary(period);
            case "investPlan": return investPlan(period, mode);
            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);
            case "cityPassengerCitySum": return exportCityPassengerCitySum("生成_城市客运客运量分市州累计.xlsx", period, mode);
            case "cityPassengerDetailSum": return exportCityPassengerCitySum("生成_城市客运各市州明细表.xlsx", period, mode);
            case "cityPassengerSummary": return exportCityPassengerSummary(period, mode);
            case "wycSplit": return exportWycSplit(period, mode);
            default: return null;
        }
    }
    private static final Map<String, String> REPORT_FILE_NAMES = new HashMap<>();
    static {
        REPORT_FILE_NAMES.put("cityDetail", "生成_货运量分市州明细.xlsx");
        REPORT_FILE_NAMES.put("freightRank", "生成_货运量排名.xlsx");
        REPORT_FILE_NAMES.put("turnoverRank", "生成_周转量排名.xlsx");
        REPORT_FILE_NAMES.put("passengerCityDetail", "生成_公路旅客分市州明细.xlsx");
        REPORT_FILE_NAMES.put("passengerMidDetail", "生成_公路旅客中口径明细.xlsx");
        REPORT_FILE_NAMES.put("passengerMidRank", "生成_公路旅客中口径排名.xlsx");
        REPORT_FILE_NAMES.put("passengerMidAnalysis", "生成_公路旅客中口径分析.xlsx");
        REPORT_FILE_NAMES.put("energySummary", "生成_能运汇总表.xlsx");
        REPORT_FILE_NAMES.put("investPlan", "生成_全省预安排计划进度情况汇总.xls");
        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");
        REPORT_FILE_NAMES.put("cityPassengerCitySum", "生成_城市客运客运量分市州累计.xlsx");
        REPORT_FILE_NAMES.put("cityPassengerDetailSum", "生成_城市客运各市州明细表.xlsx");
        REPORT_FILE_NAMES.put("cityPassengerSummary", "生成_城市客运汇总.xlsx");
        REPORT_FILE_NAMES.put("summaryWorkbook", "生成_道路运输量汇总表.xlsx");
        REPORT_FILE_NAMES.put("wycSplit", "生成_网约车客运量拆分表.xlsx");
    }
    private void addZipEntry(java.util.zip.ZipOutputStream zos, String name, byte[] data) throws Exception {
        zos.putNextEntry(new java.util.zip.ZipEntry(name));
        zos.write(data);
        zos.closeEntry();
    }
    /** 城市客运模板填充:以目录下模板为底稿,保留标题/表头/合并/公式/样式,仅替换数据区市州月度值 */
    public byte[] exportCityByTemplate(String templateFileName, String period, String mode,
                                       Map<Integer, Map<String, double[]>> monthCity,
                                       Map<Integer, Map<String, double[]>> lastYearMonthCity) throws Exception {
        int month = monthOf(period, mode);
        int year = Integer.parseInt(period.split("-")[0]);
        File template = resolveTemplate(templateFileName);
        try (InputStream in = new FileInputStream(template);
             XSSFWorkbook wb = new XSSFWorkbook(in)) {
            Sheet sheet = wb.getSheetAt(0);
            dynamicTitle(sheet, year, month); // 标题按报表期动态化(不写死年份/月份)
            // 结构守卫:数据区应为 全省+17市州 × 4 指标 = 72 行(1-based 第5行起)
            if (sheet.getLastRowNum() < 75) {
                throw new RuntimeException("城市客运模板数据区行数不足,请确认模板未改版: " + templateFileName);
            }
            List<String> areas = new java.util.ArrayList<>();
            areas.add("全省");
            areas.addAll(RegionUtil.cityList());
            // 模板数据区:1-based 第5行起,每地区4行(客运量/周转量/城市内客运量/城市内周转量)
            int rowIdx = 4;
            for (int a = 0; a < areas.size(); a++) {
                String area = areas.get(a);
                for (int k = 0; k < 4; k++) {
                    Row row = sheet.getRow(rowIdx);
                    if (row == null) row = sheet.createRow(rowIdx);
                    if (a > 0) {
                        // 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(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(v);
                        }
                    }
                    rowIdx++;
                }
            }
            // 重算公式缓存值(全省求和/同比/累计,保留公式;求值异常忽略)
            try {
                wb.getCreationHelper().createFormulaEvaluator().evaluateAll();
            } catch (Exception e) {
                log.warn("城市客运模板公式求值失败: {}", e.getMessage());
            }
            clearFormulaErrorCells(sheet, 4, sheet.getLastRowNum(), 3, 39);
            fixCityMonthHeaderText(sheet);
            return toBytes(wb);
        }
    }
    /** 清空求值结果为错误的公式单元格(模板跨簿同比公式残留 #REF! 时移除公式,避免导出显示错误) */
    private void clearFormulaErrorCells(Sheet sheet, int startRow, int endRow, int startCol, int endCol) {
        for (int r = startRow; r <= endRow; r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (int c = startCol; c <= endCol; c++) {
                Cell cell = row.getCell(c);
                if (cell == null || cell.getCellType() != CellType.FORMULA) continue;
                try {
                    if (cell.getCachedFormulaResultType() == CellType.ERROR) cell.setBlank();
                } catch (Exception ignore) {
                    // 缓存类型读取失败时忽略
                }
            }
        }
    }
    /** 清空工作簿全部工作表内求值结果为错误的公式单元格(跨簿/跨表 #REF!、缺去年数据 #DIV/0! 等) */
    private void clearFormulaErrorsAll(XSSFWorkbook wb) {
        for (int i = 0; i < wb.getNumberOfSheets(); i++) {
            Sheet s = wb.getSheetAt(i);
            clearFormulaErrorCells(s, 2, s.getLastRowNum(), 1, 60);
        }
    }
    /** 城市客运类模板表头月份列若为未格式化日期序列号(如 46082),改写为「YYYY年M月」文本 */
    private void fixCityMonthHeaderText(Sheet sheet) {
        for (int r = 0; r <= Math.min(sheet.getLastRowNum(), 20); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            Cell nameCell = row.getCell(0);
            String nameText = nameCell == null ? null : cellText(nameCell);
            if (nameText == null || !nameText.contains("地区")) continue;
            for (int c = 2; c <= 40; c += 2) {
                Cell cell = row.getCell(c);
                if (cell == null || cell.getCellType() != CellType.NUMERIC) continue;
                double v = cell.getNumericCellValue();
                if (v < 40000 || v > 60000) continue;
                java.util.Date d = org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v);
                cell.setCellValue(new java.text.SimpleDateFormat("yyyy年M月").format(d));
            }
        }
    }
    /** 模板文件定位:配置目录 → user.dir → user.dir/.. 逐级回退 */
    private File resolveTemplate(String fileName) throws Exception {
        return resolveAnyTemplate(templateDir, fileName);
    }
    /** 公路旅客输出模板定位(docs/公路旅客+能耗/输出) */
    private File resolvePassengerTemplate(String fileName) throws Exception {
        return resolveAnyTemplate(passengerTemplateDir, fileName);
    }
    /** 通用模板文件定位(用于城市客运/能耗等不同模板目录) */
    private File resolveAnyTemplate(String dir, String fileName) throws Exception {
        String rel = dir;
        while (rel.startsWith("./")) rel = rel.substring(2);
        String[] roots = {
            dir,
            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, fileName);
            if (f.exists() && f.isFile()) return f;
        }
        throw new RuntimeException("未找到模板文件: " + fileName);
    }
    /** 城市客运模板标题动态化:扫描全部文本单元格,替换写死的年份与累计区间(如 "2026年1-6月" → "2027年1-8月"),列标题 "2026年1月" 只换年份;"1-12月" 整年对比块(2024/2025)标题保持原样 */
    private void dynamicTitle(Sheet sheet, int year, int month) {
        java.util.regex.Pattern yearP = java.util.regex.Pattern.compile("\\d{4}年");
        java.util.regex.Pattern rangeP = java.util.regex.Pattern.compile("(\\d+)-(\\d+)月");
        String cum = cumRange(month);
        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
            Row row = sheet.getRow(r);
            if (row == null) continue;
            for (Cell cell : row) {
                if (cell.getCellType() != CellType.STRING) continue;
                String v = cell.getStringCellValue();
                if (v == null || v.isEmpty() || v.contains("1-12月")) continue; // 整年对比块标题保持原样
                String nv = yearP.matcher(v).replaceAll(year + "年");
                nv = rangeP.matcher(nv).replaceAll(java.util.regex.Matcher.quoteReplacement(cum));
                if (!nv.equals(v)) cell.setCellValue(nv);
            }
        }
    }
    /** 城市客运分市州取值(月 → 市州 → 指标数组) */
    private double cityVal(Map<Integer, Map<String, double[]>> monthCity, String area, int m, int idx) {
        Map<String, double[]> cityMap = monthCity.get(m);
        if (cityMap == null) return 0;
        double[] arr = cityMap.get(area);
        if (arr == null) return 0;
        return idx < arr.length ? arr[idx] : 0;
    }
    // ==================== 投资模块 4 张领导报表(模板底稿生成,输出 .xls) ====================
    /** 加载投资 .xls 模板(HSSFWorkbook,保留列宽/表头/样式/合并) */
    private HSSFWorkbook loadInvestTemplate(String fileName) throws Exception {
        File f = resolveAnyTemplate(investTemplateDir, fileName);
        try (InputStream in = new FileInputStream(f)) {
            return new HSSFWorkbook(in);
        }
    }
    /** 模板单元格写文本:先清空(保留样式/合并)再写,null/空白留空 */
    private void hssfText(Row row, int idx, String v) {
        Cell c = row.getCell(idx);
        if (c == null) c = row.createCell(idx);
        c.setBlank();
        if (v != null && !v.trim().isEmpty()) c.setCellValue(v.trim());
    }
    /** 模板单元格写数值:先清空(保留样式/合并)再写 */
    private void hssfNum(Row row, int idx, Double v) {
        Cell c = row.getCell(idx);
        if (c == null) c = row.createCell(idx);
        c.setBlank();
        if (v != null) c.setCellValue(v);
    }
    /** 删除数据区多余行(fromRow..lastKeepRow-1):先移除合并单元格再删行记录;表头合并不受影响 */
    private void trimHssfTail(HSSFSheet sheet, int fromRow, int lastKeepRow) {
        for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
            CellRangeAddress m = sheet.getMergedRegion(i);
            if (m.getFirstRow() >= fromRow && m.getFirstRow() < lastKeepRow) {
                sheet.removeMergedRegion(i);
            }
        }
        for (int r = lastKeepRow - 1; r >= fromRow; r--) {
            HSSFRow row = sheet.getRow(r);
            if (row != null) sheet.removeRow(row);
        }
    }
    private void trimHssfTail(HSSFSheet sheet, int fromRow) {
        trimHssfTail(sheet, fromRow, sheet.getLastRowNum() + 1);
    }
    /** 新建行并复制模板行样式(含行高),保证与模板数据区外观一致 */
    private HSSFRow createRowLike(HSSFSheet sheet, int rowIdx, HSSFRow styleRow) {
        HSSFRow row = sheet.createRow(rowIdx);
        if (styleRow != null) {
            row.setHeight(styleRow.getHeight());
            for (int c = 0; c < styleRow.getLastCellNum(); c++) {
                HSSFCell sc = styleRow.getCell(c);
                if (sc == null) continue;
                row.createCell(c).setCellStyle(sc.getCellStyle());
            }
        }
        return row;
    }
    /** 添加单行合并单元格(与模板数据区合并规则一致) */
    private void addMerge(HSSFSheet sheet, int r, int firstCol, int lastCol) {
        sheet.addMergedRegion(new CellRangeAddress(r, r, firstCol, lastCol));
    }
    /** 解析 4 位年份为数值(写模板年份列),无法解析返回 null */
    private Double parseYearNum(String t) {
        if (t == null || t.trim().isEmpty()) return null;
        String s = t.trim().substring(0, Math.min(4, t.trim().length()));
        try {
            return Double.parseDouble(s);
        } catch (Exception e) {
            return null;
        }
    }
    /** 解析 6 位 YYYYMM 为数值(写模板开工时间列) */
    private Double parseTimeYMNum(String t) {
        if (t == null || t.trim().isEmpty()) return null;
        String x = padTimeYM(t.trim()).replaceAll("[^0-9]", "");
        if (x.length() < 6) return null;
        try {
            return Double.parseDouble(x.substring(0, 6));
        } catch (Exception e) {
            return null;
        }
    }
    private byte[] toBytesHssf(HSSFWorkbook wb) throws Exception {
        applyTwoDecimalFormat(wb);
        try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            wb.write(out);
            wb.close();
            return out.toByteArray();
        }
    }
}
    /** 投资模板 市州小节结构(表1/表2 动态插行用) */
    private static class InvestSection {
        String blockCategory;
        String city;
        int startRow;
        int styleRowIdx = -1; // 该小节首个项目行(复制样式用)
        List<Integer> projectRows = new ArrayList<>();
        Set<Long> matched = new HashSet<>();
    }
    /** 在 pos 行之后插入 n 行:下方行下移(合并区先摘除再按新行号恢复),新行复制 styleRow 样式/行高/单行合并;返回首个新行索引 */
    private int insertRowsDown(HSSFSheet sheet, int pos, int n, HSSFRow styleRow) {
        if (n <= 0) return -1;
        List<CellRangeAddress> below = new ArrayList<>();
        for (int i = sheet.getNumMergedRegions() - 1; i >= 0; i--) {
            CellRangeAddress m = sheet.getMergedRegion(i);
            if (m.getFirstRow() > pos) {
                below.add(m);
                sheet.removeMergedRegion(i);
            }
        }
        int last = sheet.getLastRowNum();
        if (pos < last) {
            sheet.shiftRows(pos + 1, last, n, true, false);
        }
        for (CellRangeAddress m : below) {
            sheet.addMergedRegion(new CellRangeAddress(m.getFirstRow() + n, m.getLastRow() + n, m.getFirstColumn(), m.getLastColumn()));
        }
        int first = pos + 1;
        for (int i = 0; i < n; i++) {
            HSSFRow row = createRowLike(sheet, first + i, styleRow);
            if (styleRow != null) copyRowMerges(sheet, styleRow.getRowNum(), first + i);
        }
        return first;
    }
    /** 复制 styleRow 所在行的单行合并区到 toRow(插入行外观与模板行一致) */
    private void copyRowMerges(HSSFSheet sheet, int fromRow, int toRow) {
        for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
            CellRangeAddress m = sheet.getMergedRegion(i);
            if (m.getFirstRow() == fromRow && m.getLastRow() == fromRow) {
                sheet.addMergedRegion(new CellRangeAddress(toRow, toRow, m.getFirstColumn(), m.getLastColumn()));
            }
        }
    }
    /** 投资表 序号重排:表1 按块重置、表2 按市州小节重置、表4 全表连续(模板/插入行统一排序) */
    private void renumberInvestSeq(HSSFSheet sheet, String kind) {
        int seq = 0;
        int lastRow = sheet.getLastRowNum();
        for (int r = 4; r <= lastRow; r++) {
            HSSFRow row = sheet.getRow(r);
            if (row == null) continue;
            String a = nvl(cellText(row.getCell(0)));
            if ("investPlan".equals(kind)) {
                if ("一、客运站场基础设施建设".equals(a) || "二、交通物流基础设施建设".equals(a) || "三、老旧营运货车报废更新".equals(a)) {
                    seq = 0;
                    continue;
                }
            } else if ("investFiveYear".equals(kind)) {
                if (isCityLabel(a)) {
                    seq = 0;
                    continue;
                }
            }
            String b = nvl(cellText(row.getCell(1)));
            if (b.isEmpty()) continue;
            hssfNum(row, 0, (double) (++seq));
        }
    }
    /** 表1 项目行填充(模板行/插入行共用;cityText 与模板 B 列格式一致) */
    private void fillInvestPlanProjectRow(HSSFRow row, InvestmentProject p, InvestmentMonthly m, int seq, String cityText, String displayName) {
        hssfNum(row, 0, (double) seq);
        hssfText(row, 1, cityText);
        hssfText(row, 2, valueOf(p.getCounty()));
        hssfText(row, 3, displayName != null && !displayName.trim().isEmpty() ? displayName.trim() : p.getProjectName());
        hssfText(row, 4, valueOf(p.getConstructNature()));
        hssfNum(row, 5, parseYearNum(p.getStartTime()));
        hssfNum(row, 6, parseYearNum(p.getEndTime()));
        hssfNum(row, 7, p.getTotalInvestment());
        hssfText(row, 20, p.getApprovalGk());
        hssfText(row, 21, p.getApprovalCs());
        if (m != null) {
            hssfNum(row, 8, m.getStartCum());
            hssfNum(row, 9, m.getYearPlan());
            hssfText(row, 13, m.getProgressDesc());
            hssfNum(row, 22, m.getYearCum());
            hssfText(row, 23, valueOf(m.getProgressStage()));
        }
    }
    /** 表2 项目行填充(模板行/插入行共用) */
    private void fillFiveYearProjectRow(HSSFRow row, InvestmentProject p, InvestmentMonthly m, int seq) {
        hssfNum(row, 0, (double) seq);
        hssfText(row, 1, p.getProjectName());
        hssfText(row, 2, valueOf(p.getBuilderName()));
        hssfNum(row, 3, parseYearNum(p.getStartTime()));
        hssfNum(row, 4, parseYearNum(p.getEndTime()));
        hssfNum(row, 5, p.getTotalInvestment());
        if (m != null) {
            hssfNum(row, 6, m.getStartCum());
            hssfNum(row, 7, m.getYearCum());
            hssfText(row, 8, valueOf(m.getProgressStage()));
            hssfText(row, 9, m.getProgressDesc());
        }
    }
    /** 表4 项目行填充(模板行/插入行共用) */
    private void fillCountyProjectRow(HSSFRow row, InvestmentProject p, InvestmentMonthly m, int seq) {
        hssfNum(row, 0, (double) seq);
        hssfText(row, 1, p.getProjectName());
        hssfText(row, 2, valueOf(p.getBuilderName()));
        hssfNum(row, 3, p.getTotalInvestment());
        if (m != null) {
            hssfNum(row, 4, m.getStartCum());
            hssfNum(row, 5, m.getYearPlan());
            hssfNum(row, 6, m.getYearCum());
            hssfNum(row, 7, m.getMonthDone());
            hssfText(row, 8, valueOf(m.getProgressStage()));
            hssfText(row, 9, m.getProgressDesc());
            if (m.getBuildingArea() != null && m.getBuildingArea() > 0) hssfNum(row, 12, m.getBuildingArea());
        }
        hssfText(row, 15, p.getApprovalGk());
        hssfText(row, 16, p.getApprovalCs());
    }
    /** 表1:2026年全省预安排计划进度情况汇总(模板底稿原位替换:保留全部行/合并/列宽,仅替换数据;DB 项目多于模板预留行时动态插行) */
    public byte[] investPlan(String period, String mode) throws Exception {
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        List<InvestmentMonthly> monthlies = investMonthlyMapper.selectList(
            new LambdaQueryWrapper<InvestmentMonthly>()
                .eq(InvestmentMonthly::getReportPeriod, period));
        Map<Long, InvestmentProject> pmap = new HashMap<>();
        List<InvestmentProject> projects = investProjectMapper.selectList(null);
        for (InvestmentProject p : projects) pmap.put(p.getId(), p);
        Map<Long, InvestmentMonthly> mByProject = new HashMap<>();
        for (InvestmentMonthly m : monthlies) mByProject.put(m.getProjectId(), m);
        HSSFWorkbook wb = loadInvestTemplate("模板_全省预安排计划进度情况汇总.xls");
        HSSFSheet sheet = wb.getSheetAt(0);
        // 标题/表头年份与累计月份
        hssfText(sheet.getRow(1), 0, year + "年全省客货运站场建设预安排计划表");
        hssfText(sheet.getRow(2), 0, "蓝色项目为" + year + "年市州拟申报资金项目        单位:万元");
        hssfText(sheet.getRow(3), 22, cumRange(month) + "累计完成投资");
        // 结构扫描:块(客运/物流/老旧) -> 市州小节 -> 项目行
        List<InvestSection> sections = new ArrayList<>();
        String blockCategory = null;
        InvestSection cur = null;
        int firstProjectRow = -1;
        int lastRow = sheet.getLastRowNum();
        for (int r = 5; r <= lastRow; r++) {
            HSSFRow row = sheet.getRow(r);
            if (row == null) continue;
            String a = nvl(cellText(row.getCell(0)));
            String b = nvl(cellText(row.getCell(1)));
            String d = nvl(cellText(row.getCell(3)));
            if (r == 5) continue; // 全省合计行单独处理
            if ("一、客运站场基础设施建设".equals(a) || "二、交通物流基础设施建设".equals(a) || "三、老旧营运货车报废更新".equals(a)) {
                blockCategory = a.startsWith("一、") ? "客运站场" : (a.startsWith("二、") ? "物流园区" : null);
                cur = null;
                continue;
            }
            if (a.startsWith("一、") || a.startsWith("二、")) continue; // 预安排计划内/外 分组标题:归当前小节
            if (b.isEmpty() && d.isEmpty()) {
                cur = new InvestSection();
                cur.blockCategory = blockCategory;
                cur.city = templateCityFull(a);
                cur.startRow = r;
                sections.add(cur);
                continue;
            }
            if (cur != null) {
                cur.projectRows.add(r);
                if (cur.styleRowIdx < 0) cur.styleRowIdx = r;
                if (firstProjectRow < 0) firstProjectRow = r;
            }
        }
        HSSFRow globalStyle = firstProjectRow >= 0 ? sheet.getRow(firstProjectRow) : null;
        // 原位逐行替换:合计/块标题/市州小节/分组/项目行;未匹配的模板行清空样例数据(结构/合并/列宽不变)
        int seq = 0;
        int si = -1;
        java.util.Set<Long> usedProjectIds = new HashSet<>();
        for (int r = 5; r <= lastRow; r++) {
            HSSFRow row = sheet.getRow(r);
            if (row == null) continue;
            String a = nvl(cellText(row.getCell(0)));
            String b = nvl(cellText(row.getCell(1)));
            String d = nvl(cellText(row.getCell(3)));
            if (si + 1 < sections.size() && r >= sections.get(si + 1).startRow) si++;
            InvestSection sec = si >= 0 && si < sections.size() ? sections.get(si) : null;
            if (r == 5) { // 全省合计行
                blankInvestRow(row, 25);
                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;
            }
            if ("一、客运站场基础设施建设".equals(a) || "二、交通物流基础设施建设".equals(a)) {
                blockCategory = a.startsWith("一、") ? "客运站场" : "物流园区";
                blankInvestRow(row, 25);
                hssfText(row, 0, a);
                InvestAgg agg = new InvestAgg();
                for (InvestmentMonthly m : monthlies) {
                    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;
            }
            if ("三、老旧营运货车报废更新".equals(a)) {
                blockCategory = null; // 暂无数据源:整块清空
                blankInvestRow(row, 25);
                hssfText(row, 0, a);
                seq = 0;
                continue;
            }
            if (a.startsWith("一、") || a.startsWith("二、")) {
                // 预安排计划内/外 分组标题行:无数据,仅保留标签
                blankInvestRow(row, 25);
                hssfText(row, 0, a);
                continue;
            }
            if (b.isEmpty() && d.isEmpty()) {
                // 市州小节行:重算该市州(当前块类别)合计
                String city = templateCityFull(a);
                blankInvestRow(row, 25);
                hssfText(row, 0, a);
                InvestAgg agg = new InvestAgg();
                for (InvestmentMonthly m : monthlies) {
                    InvestmentProject p = pmap.get(m.getProjectId());
                    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;
            }
            // 项目行:按 模板名称+市州+块类别 匹配 DB 项目,未匹配整行清空
            blankInvestRow(row, 25);
            if (blockCategory == null) continue; // 老旧货车段无数据源
            InvestmentProject p = matchInvest(projects, d, b, blockCategory, usedProjectIds);
            if (p == null) continue;
            usedProjectIds.add(p.getId());
            if (sec != null) sec.matched.add(p.getId());
            InvestmentMonthly m = mByProject.get(p.getId());
            fillInvestPlanProjectRow(row, p, m, ++seq, b, d);
        }
        // 动态插行:DB 项目数 > 模板预留行数时,在市州小节末尾补齐(自底向上插行,避免行号错位)
        for (int i = sections.size() - 1; i >= 0; i--) {
            InvestSection s = sections.get(i);
            if (s.blockCategory == null || s.city == null) continue; // 老旧货车段无数据源
            List<InvestmentProject> extra = new ArrayList<>();
            for (InvestmentProject p : projects) {
                if (s.blockCategory.equals(p.getCategory())
                    && s.city.equals(RegionUtil.normalizeCityName(p.getCity()))
                    && !s.matched.contains(p.getId())) extra.add(p);
            }
            if (extra.isEmpty()) continue;
            HSSFRow styleRow = s.styleRowIdx >= 0 ? sheet.getRow(s.styleRowIdx) : globalStyle;
            int pos = s.projectRows.isEmpty() ? s.startRow : s.projectRows.get(s.projectRows.size() - 1);
            int first = insertRowsDown(sheet, pos, extra.size(), styleRow);
            for (int j = 0; j < extra.size(); j++) {
                HSSFRow row = sheet.getRow(first + j);
                if (row == null) continue;
                InvestmentProject p = extra.get(j);
                fillInvestPlanProjectRow(row, p, mByProject.get(p.getId()), 0, p.getCity(), p.getProjectName());
            }
        }
        renumberInvestSeq(sheet, "investPlan");
        return toBytesHssf(wb);
    }
    /** 市州小节/合计行数值:7总投资 8自开始累计 9本年计划 22自年初累计 23实际进度(部/省资金与自筹无数据源,留空) */
    private void writeInvestAggHssf(HSSFRow row, InvestAgg agg) {
        hssfNum(row, 7, agg.total == 0 ? null : agg.total);
        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, 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();
        }
    }
    // ==================== 投资模块:客运站投资明细表 / 物流站场投资明细(模板底稿原位替换) ====================
    /** 物流站场明细表模板中的市州小节短名 -> 库内规范全名 */
    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 ? s[3] / b * 100 : 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 ? cT / b * 100 : 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;
        void add(InvestmentMonthly m, InvestmentProject p) {
            if (p != null) total += nz(p.getTotalInvestment());
            startCum += nz(m.getStartCum());
            yearPlan += nz(m.getYearPlan());
            yearCum += nz(m.getYearCum());
        }
        private double nz(Double v) {
            return v == null ? 0.0 : v;
        }
    }
    /** 表2:"十五五"规划物流项目进展情况(模板底稿原位替换:保留全部行/合并/列宽,仅替换数据;DB 项目多于模板预留行时动态插行) */
    public byte[] investFiveYearLogistics(String period, String mode) throws Exception {
        List<InvestmentMonthly> monthlies = investMonthlyMapper.selectList(
            new LambdaQueryWrapper<InvestmentMonthly>()
                .eq(InvestmentMonthly::getReportPeriod, period));
        Map<Long, InvestmentProject> pmap = new HashMap<>();
        List<InvestmentProject> fiveProjects = new java.util.ArrayList<>();
        for (InvestmentProject p : investProjectMapper.selectList(null)) {
            pmap.put(p.getId(), p);
            if (Integer.valueOf(1).equals(p.getIsFiveYear())) fiveProjects.add(p);
        }
        Map<Long, InvestmentMonthly> mByProject = new HashMap<>();
        for (InvestmentMonthly m : monthlies) mByProject.put(m.getProjectId(), m);
        List<InvestmentMonthly> mine = new java.util.ArrayList<>();
        for (InvestmentMonthly m : monthlies) {
            InvestmentProject p = pmap.get(m.getProjectId());
            if (p != null && Integer.valueOf(1).equals(p.getIsFiveYear())) mine.add(m);
        }
        HSSFWorkbook wb = loadInvestTemplate("模板_十五五规划物流项目进展情况.xls");
        HSSFSheet sheet = wb.getSheetAt(1); // 「 分项目投资完成情况」
        int lastRow = sheet.getLastRowNum();
        // 结构扫描:市州小节 -> 项目行
        List<InvestSection> sections = new ArrayList<>();
        InvestSection cur = null;
        int firstProjectRow = -1;
        for (int r = 4; r <= lastRow; 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 (r == 4) continue; // 全省合计行单独处理
            if (b.isEmpty() && isCityLabel(a)) {
                cur = new InvestSection();
                cur.city = templateCityFull(a);
                cur.startRow = r;
                sections.add(cur);
                continue;
            }
            if (cur != null) {
                cur.projectRows.add(r);
                if (cur.styleRowIdx < 0) cur.styleRowIdx = r;
                if (firstProjectRow < 0) firstProjectRow = r;
            }
        }
        HSSFRow globalStyle = firstProjectRow >= 0 ? sheet.getRow(firstProjectRow) : null;
        String currentCity = null;
        int seq = 0;
        int si = -1;
        for (int r = 4; r <= lastRow; 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 (si + 1 < sections.size() && r >= sections.get(si + 1).startRow) si++;
            InvestSection sec = si >= 0 && si < sections.size() ? sections.get(si) : null;
            if (r == 4) { // 全省合计行
                blankInvestRow(row, 12);
                hssfText(row, 0, "合计");
                InvestAgg total = new InvestAgg();
                for (InvestmentMonthly m : mine) total.add(m, pmap.get(m.getProjectId()));
                hssfNum(row, 5, total.total == 0 ? null : total.total);
                hssfNum(row, 6, total.startCum == 0 ? null : total.startCum);
                hssfNum(row, 7, total.yearCum == 0 ? null : total.yearCum);
                continue;
            }
            if (b.isEmpty() && isCityLabel(a)) {
                currentCity = templateCityFull(a);
                seq = 0;
                blankInvestRow(row, 12);
                hssfText(row, 0, a);
                InvestAgg agg = new InvestAgg();
                for (InvestmentMonthly m : mine) {
                    InvestmentProject p = pmap.get(m.getProjectId());
                    if (p != null && currentCity.equals(p.getCity())) agg.add(m, p);
                }
                hssfNum(row, 5, agg.total == 0 ? null : agg.total);
                hssfNum(row, 6, agg.startCum == 0 ? null : agg.startCum);
                hssfNum(row, 7, agg.yearCum == 0 ? null : agg.yearCum);
                continue;
            }
            // 项目行:按 模板名称+市州 匹配 DB 十五五项目,未匹配整行清空
            blankInvestRow(row, 12);
            InvestmentProject p = matchInvest(fiveProjects, b, currentCity, null);
            if (p == null) continue;
            if (sec != null) sec.matched.add(p.getId());
            InvestmentMonthly m = mByProject.get(p.getId());
            fillFiveYearProjectRow(row, p, m, ++seq);
        }
        // 动态插行:DB 十五五项目数 > 模板预留行数时,在市州小节末尾补齐
        for (int i = sections.size() - 1; i >= 0; i--) {
            InvestSection s = sections.get(i);
            if (s.city == null) continue;
            List<InvestmentProject> extra = new ArrayList<>();
            for (InvestmentProject p : fiveProjects) {
                if (s.city.equals(RegionUtil.normalizeCityName(p.getCity()))
                    && !s.matched.contains(p.getId())) extra.add(p);
            }
            if (extra.isEmpty()) continue;
            HSSFRow styleRow = s.styleRowIdx >= 0 ? sheet.getRow(s.styleRowIdx) : globalStyle;
            int pos = s.projectRows.isEmpty() ? s.startRow : s.projectRows.get(s.projectRows.size() - 1);
            int first = insertRowsDown(sheet, pos, extra.size(), styleRow);
            for (int j = 0; j < extra.size(); j++) {
                HSSFRow row = sheet.getRow(first + j);
                if (row == null) continue;
                InvestmentProject p = extra.get(j);
                fillFiveYearProjectRow(row, p, mByProject.get(p.getId()), 0);
            }
        }
        renumberInvestSeq(sheet, "investFiveYear");
        return toBytesHssf(wb);
    }
    /** 表4:(经济强县项目)湖北省交通物流基础设施投资统计报表(模板底稿原位替换:保留全部行/合并/列宽,仅替换数据;DB 项目多于模板预留行时动态插行) */
    public byte[] investCounty(String period, String mode) throws Exception {
        List<InvestmentMonthly> monthlies = investMonthlyMapper.selectList(
            new LambdaQueryWrapper<InvestmentMonthly>()
                .eq(InvestmentMonthly::getReportPeriod, period));
        Map<Long, InvestmentProject> pmap = new HashMap<>();
        List<InvestmentProject> countyProjects = new java.util.ArrayList<>();
        for (InvestmentProject p : investProjectMapper.selectList(null)) {
            pmap.put(p.getId(), p);
            if (Integer.valueOf(1).equals(p.getIsCounty())) countyProjects.add(p);
        }
        Map<Long, InvestmentMonthly> mByProject = new HashMap<>();
        for (InvestmentMonthly m : monthlies) mByProject.put(m.getProjectId(), m);
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        HSSFWorkbook wb = loadInvestTemplate("模板_经济强县投资统计报表.xls");
        HSSFSheet sheet = wb.getSheetAt(0);
        hssfText(sheet.getRow(0), 0, "(经济强县项目)湖北省交通物流基础设施投资统计报表(" + month + "月月报)");
        hssfText(sheet.getRow(1), 0, "全省货运(物流)基础设施建设投资月报(" + year + "年" + month + "月)");
        int lastRow = Math.min(sheet.getLastRowNum(), 65533);
        int seq = 0;
        int lastProjectRow = -1;
        Set<Long> matched = new HashSet<>();
        for (int r = 4; r <= lastRow; r++) {
            HSSFRow row = sheet.getRow(r);
            if (row == null) continue;
            String name = nvl(cellText(row.getCell(1)));
            if (name.isEmpty()) continue; // 模板尾部空行不处理
            lastProjectRow = r;
            blankInvestRow(row, 20);
            InvestmentProject p = matchInvest(countyProjects, name, null, null);
            if (p == null) continue;
            matched.add(p.getId());
            InvestmentMonthly m = mByProject.get(p.getId());
            fillCountyProjectRow(row, p, m, ++seq);
        }
        // 动态插行:DB 经济强县项目数 > 模板预留行数时,在项目区末尾补齐
        List<InvestmentProject> extra = new ArrayList<>();
        for (InvestmentProject p : countyProjects) {
            if (!matched.contains(p.getId())) extra.add(p);
        }
        if (!extra.isEmpty()) {
            HSSFRow styleRow = sheet.getRow(4);
            int pos = lastProjectRow >= 4 ? lastProjectRow : 3;
            int first = insertRowsDown(sheet, pos, extra.size(), styleRow);
            for (int j = 0; j < extra.size(); j++) {
                HSSFRow row = sheet.getRow(first + j);
                if (row == null) continue;
                InvestmentProject p = extra.get(j);
                fillCountyProjectRow(row, p, mByProject.get(p.getId()), 0);
            }
        }
        renumberInvestSeq(sheet, "investCounty");
        return toBytesHssf(wb);
    }
    /** 表3:湖北省(客货站场)亿元投资项目(模板底稿 .xls,投资总表 + 规上项目表 两 sheet) */
    public byte[] investBillion(String period, String mode) throws Exception {
        List<InvestmentMonthly> monthlies = investMonthlyMapper.selectList(
            new LambdaQueryWrapper<InvestmentMonthly>()
                .eq(InvestmentMonthly::getReportPeriod, period));
        Map<Long, InvestmentProject> pmap = new HashMap<>();
        for (InvestmentProject p : investProjectMapper.selectList(null)) pmap.put(p.getId(), p);
        List<InvestmentMonthly> mine = new java.util.ArrayList<>();
        for (InvestmentMonthly m : monthlies) {
            InvestmentProject p = pmap.get(m.getProjectId());
            if (p != null && Integer.valueOf(1).equals(p.getIsBillion())) mine.add(m);
        }
        int year = Integer.parseInt(period.split("-")[0]);
        int month = monthOf(period, mode);
        HSSFWorkbook wb = loadInvestTemplate("模板_亿元投资项目.xls");
        // ---- sheet1 投资总表:固定骨架,仅填(五)其他 全年预计(全省本年计划折亿) ----
        HSSFSheet s1 = wb.getSheetAt(0);
        hssfText(s1.getRow(0), 0, year + "年固定资产投资预计完成情况");
        double provinceYearPlan = 0;
        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 : provinceYearPlan / 10000);
        // ---- sheet2 规上项目表:表头年份 + 合计 + 项目行 ----
        HSSFSheet s2 = wb.getSheetAt(1);
        hssfText(s2.getRow(0), 0, year + "年交通建设项目储备情况(规模以上项目)");
        hssfText(s2.getRow(2), 5, "截止到" + year + "年" + month + "月底建设状态");
        hssfText(s2.getRow(2), 9, "截至" + (year - 1) + "年年底实际完成投资(万元)");
        hssfText(s2.getRow(2), 10, year + "年1-3月实际完成投资(万元)");
        hssfText(s2.getRow(2), 11, year + "年4月计划完成投资(万元)");
        hssfText(s2.getRow(2), 12, year + "年第二季度计划完成投资(万元)");
        hssfText(s2.getRow(2), 13, year + "年全年计划完成投资(万元)");
        hssfText(s2.getRow(2), 16, year + "年1-4月计划完成投资(万元)");
        hssfText(s2.getRow(2), 17, year + "年4月实际完成投资(万元)");
        hssfText(s2.getRow(2), 18, year + "年1-4月累计完成投资(万元)");
        hssfText(s2.getRow(2), 19, year + "年5月实际完成投资(万元)");
        hssfText(s2.getRow(2), 21, year + "年6月实际完成投资(万元)");
        hssfText(s2.getRow(2), 22, year + "年第二季度实际完成投资(万元)");
        hssfText(s2.getRow(2), 23, year + "年" + month + "月实际完成投资");
        // 合计行 R4(col0-1 属于表头合并区,保留;清空并重写数据列)
        HSSFRow totalRow = s2.getRow(4);
        for (int c = 2; c <= 23; c++) hssfNum(totalRow, c, null);
        InvestAgg total = new InvestAgg();
        for (InvestmentMonthly m : mine) total.add(m, pmap.get(m.getProjectId()));
        hssfNum(totalRow, 8, total.total == 0 ? null : total.total);
        hssfNum(totalRow, 13, total.yearPlan == 0 ? null : total.yearPlan);
        hssfNum(totalRow, 23, sumMonth(mine) == 0 ? null : sumMonth(mine));
        // 项目行:保留模板 R79 注释行
        HSSFRow styleProject = s2.getRow(5);
        trimHssfTail(s2, 5, 79);
        int rowIdx = 5;
        for (InvestmentMonthly m : mine) {
            InvestmentProject p = pmap.get(m.getProjectId());
            HSSFRow row = createRowLike(s2, rowIdx++, styleProject);
            hssfText(row, 0, "湖北省");
            hssfText(row, 1, RegionUtil.shortName(p.getCity()));
            hssfText(row, 2, p.getProjectName());
            hssfText(row, 3, "客运站场".equals(p.getCategory()) ? "综合客运枢纽" : "综合货运枢纽");
            hssfText(row, 5, valueOf(m.getProgressStage()));
            hssfText(row, 6, valueOf(p.getConstructNature()));
            hssfNum(row, 7, parseTimeYMNum(p.getStartTime()));
            hssfNum(row, 8, p.getTotalInvestment());
            hssfNum(row, 13, m.getYearPlan());
            hssfNum(row, 23, m.getMonthDone());
        }
        trimHssfTail(s2, rowIdx, 79);
        return toBytesHssf(wb);
    }
    /** 空串归一(cellText 可能返回 null) */
    private String nvl(String v) {
        return v == null ? "" : v.trim();
    }
    /** 清空模板数据行指定范围单元格(保留样式与合并单元格) */
    private void blankInvestRow(HSSFRow row, int lastCol) {
        if (row == null) return;
        for (int c = 0; c <= lastCol; c++) {
            Cell cell = row.getCell(c);
            if (cell != null) cell.setBlank();
        }
    }
    /** 模板市州短名 -> 规范市州名(武汉->武汉市、林区->神农架林区、恩施州->恩施州) */
    private String templateCityFull(String label) {
        if (label == null) return null;
        String t = label.trim();
        String canon = RegionUtil.normalizeCityName(t);
        for (String city : RegionUtil.cityList()) {
            if (city.equals(canon)) return city;
        }
        for (String city : RegionUtil.cityList()) {
            if (RegionUtil.shortName(city).equals(canon) || RegionUtil.shortName(city).equals(t)) return city;
        }
        return canon;
    }
    /** 判断模板行 A 列是否为市州小节标签 */
    private boolean isCityLabel(String label) {
        if (label == null || label.isEmpty()) return false;
        String full = templateCityFull(label);
        return full != null && RegionUtil.cityList().contains(full);
    }
    /** 在候选项目池中按名称匹配 DB 项目(可选 市州/类别 过滤 + 县区前缀消歧),未命中返回 null */
    private InvestmentProject matchInvest(List<InvestmentProject> pool, String name, String cityFull, String category) {
        return matchInvest(pool, name, cityFull, category, null);
    }
    /** 带已占用排除的匹配:同一 DB 项目只允许填充一行(避免模板一期/二期同名行重复输出) */
    private InvestmentProject matchInvest(List<InvestmentProject> pool, String name, String cityFull, String category, Set<Long> excludeIds) {
        if (name == null || name.trim().isEmpty()) return null;
        String norm = normInvestName(name);
        if (norm.isEmpty()) return null;
        String cityCanon = cityFull == null || cityFull.trim().isEmpty() ? null : RegionUtil.normalizeCityName(cityFull);
        String county = extractInvestCounty(name);
        List<InvestmentProject> cands = new java.util.ArrayList<>();
        for (InvestmentProject p : pool) {
            if (excludeIds != null && excludeIds.contains(p.getId())) continue;
            if (category != null && !category.equals(p.getCategory())) continue;
            if (cityCanon != null && !cityCanon.equals(RegionUtil.normalizeCityName(p.getCity()))) continue;
            cands.add(p);
        }
        if (cands.isEmpty()) return null;
        // 归一化同名多候选:县区前缀消歧(如 罗田县综合物流园 vs 咸丰县综合物流园)
        if (!county.isEmpty()) {
            for (InvestmentProject p : cands) {
                if (normInvestName(p.getProjectName()).equals(norm)
                    && p.getProjectName() != null && p.getProjectName().contains(county)) {
                    return p;
                }
            }
        }
        InvestmentProject best = null;
        int bestScore = Integer.MIN_VALUE;
        for (InvestmentProject p : cands) {
            String pn = normInvestName(p.getProjectName());
            if (pn.isEmpty()) continue;
            int s = investNameScore(norm, pn, county, p.getProjectName());
            if (s > bestScore) {
                bestScore = s;
                best = p;
            }
        }
        return bestScore > 0 ? best : null;
    }
    /** 项目名归一化:去括号字符、连接符,罗马数字统一,去省/市/县/区前缀与空白(与审核引擎一致) */
    private String normInvestName(String name) {
        if (name == null) return "";
        String s = name.trim();
        s = s.replaceAll("[((]", "").replaceAll("[))]", "");
        s = s.replaceAll("[·•—-_\\-]", "");
        s = s.replaceAll("[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹ]", "I");
        s = s.replaceAll("^(湖北省|武汉市|黄石市|十堰市|宜昌市|襄阳市|鄂州市|荆门市|孝感市|荆州市|黄冈市|咸宁市|随州市|恩施州|仙桃市|潜江市|天门市|神农架林区)", "");
        s = s.replaceAll("^(?:[\\u4e00-\\u9fa5]{2,4}(?:县|市|区))", "");
        s = s.replaceAll("\\s+", "");
        return s;
    }
    /** 名称相似度评分:精确>前缀包含>中后部包含>5字>子序列>最长公共子串;县区前缀不一致拒绝/大扣分 */
    private int investNameScore(String norm, String key, String county, String rawB) {
        if (norm.equals(key)) {
            // 归一化后同名:若模板带县区前缀而候选原始名不含该县区 → 拒绝(防 宜都市 vs 当阳市 误配)
            if (county != null && !county.isEmpty() && (rawB == null || !rawB.contains(county))) return -1;
            return 2000;
        }
        if (key.length() < 5 || norm.length() < 5) return -1;
        String shortSide = key.length() <= norm.length() ? key : norm;
        String longSide = key.length() > norm.length() ? key : norm;
        int idx = longSide.indexOf(shortSide);
        int lenDiff = Math.abs(key.length() - norm.length());
        int base;
        if (idx == 0) {
            base = 1000 - lenDiff * 2;
        } else if (idx > 0 && shortSide.length() >= 6) {
            base = 500 - idx * 5 - lenDiff * 2 + shortSide.length();
        } else if (idx > 0 && shortSide.length() == 5 && lenDiff <= 5) {
            String diff = longSide.substring(0, idx) + longSide.substring(idx + shortSide.length());
            if (diff.matches(".*(县|市|州).*")) return -1;
            base = 400 - idx * 5 - lenDiff;
        } else if (isInvestSubsequence(shortSide, longSide) && lenDiff <= 8) {
            base = 300 - lenDiff * 3;
        } else {
            int l = investLcs(norm, key);
            if (l >= 6) {
                base = 200 + l * 5 - lenDiff;
            } else if (l == 5 && lenDiff <= 4 && county != null && !county.isEmpty()
                       && rawB != null && rawB.contains(county)) {
                base = 150 + l * 5 - lenDiff;
            } else {
                return -1;
            }
        }
        if (county != null && !county.isEmpty() && (rawB == null || !rawB.contains(county))) {
            base -= 400;
        }
        return base;
    }
    /** 判断短名是否为长名的字符子序列 */
    private boolean isInvestSubsequence(String shortSide, String longSide) {
        int i = 0;
        for (int j = 0; i < shortSide.length() && j < longSide.length(); j++) {
            if (shortSide.charAt(i) == longSide.charAt(j)) i++;
        }
        return i == shortSide.length();
    }
    /** 最长公共子串长度 */
    private int investLcs(String a, String b) {
        int n = a.length(), m = b.length();
        if (n == 0 || m == 0) return 0;
        int[][] dp = new int[n + 1][m + 1];
        int max = 0;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                if (a.charAt(i - 1) == b.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                    if (dp[i][j] > max) max = dp[i][j];
                }
            }
        }
        return max;
    }
    /** 提取名称中的 县/市/区 前缀(如 咸丰县、宜都市) */
    private String extractInvestCounty(String name) {
        if (name == null) return "";
        java.util.regex.Matcher mm = java.util.regex.Pattern.compile("([\\u4e00-\\u9fa5]{2,4}(?:县|市|区))").matcher(name);
        if (mm.find()) return mm.group(1);
        return "";
    }
    private double sumMonth(List<InvestmentMonthly> list) {
        double s = 0;
        for (InvestmentMonthly m : list) s += nz(m.getMonthDone());
        return s;
    }
    private String padTimeYM(String t) {
        if (t == null) return null;
        String x = t.trim();
        if (x.matches("\\d{4}")) return x + "01";
        return x;
    }
    private String valueOf(String v) {
        return v == null || v.trim().isEmpty() ? "" : v.trim();
    }
    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;
        org.apache.poi.ss.usermodel.DataFormat df = wb.createDataFormat();
        short fmtTwo = df.getFormat("0.00");
        for (int s = 0; s < wb.getNumberOfSheets(); s++) {
            org.apache.poi.ss.usermodel.Sheet sh = wb.getSheetAt(s);
            if (sh == null) continue;
            for (org.apache.poi.ss.usermodel.Row row : sh) {
                if (row == null) continue;
                for (org.apache.poi.ss.usermodel.Cell cell : row) {
                    if (cell == null) continue;
                    try {
                        CellType ct = cell.getCellType();
                        if (ct != CellType.NUMERIC && ct != CellType.FORMULA) continue;
                        if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) continue;
                        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("@")) 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(normalized == null ? fmtTwo : df.getFormat(normalized));
                        cell.setCellStyle(ns);
                    } catch (Exception ignore) {
                        // 个别单元格格式异常不阻塞导出
                    }
                }
            }
        }
    }
    /** 汇总表导出后按内容自动加宽列宽(只加宽不缩窄;跳过合并单元格标题,防长标题把单列撑爆) */
    /** 汇总大表输出前清理母版残留下来的筛选与行隐藏:筛选箭头/隐藏行会让整页看起来“缺数据” */
    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()) {
            wb.write(out);
            wb.close();
            return out.toByteArray();
        }
    }
}