From abf468c9946c21512ddf2093895b30910e09e536 Mon Sep 17 00:00:00 2001
From: xyc <jc_xiong@hotmail.com>
Date: 星期一, 21 九月 2026 11:33:54 +0800
Subject: [PATCH] fix(汇总表): 中口径排名累计同比基数改用母版2025静态列

---
 traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java | 6014 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 5,873 insertions(+), 141 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java b/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
index 151f652..bbe8df3 100644
--- a/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
+++ b/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;
+    /** 鍩庡競瀹㈣繍妯℃澘鐩綍锛坅pplication.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;
+    /** 鍏矾鏃呭杈撳嚭妯℃澘鐩綍锛坅pplication.yml passenger.template-dir锛� */
+    @Value("${passenger.template-dir:docs/鍏矾鏃呭+鑳借��/杈撳嚭}")
+    private String passengerTemplateDir;
+
+    /** 姹囨�诲伐浣滅翱妯℃澘鐩綍锛坅pplication.yml summary.template-dir锛� */
+    @Value("${summary.template-dir:docs/鐢熸垚姹囨�诲ぇ琛▆")
+    private String summaryTemplateDir;
+
+    /** 璐ц繍鎶ヨ〃妯℃澘鐩綍锛坅pplication.yml freight.template-dir锛� */
+    @Value("${freight.template-dir:docs/璐ц繍}")
+    private String freightTemplateDir;
+
+    /** 鎶曡祫鎶ヨ〃妯℃澘鐩綍锛坅pplication.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 绱鍙e緞锛夛紝鍙彁绀哄綋鏈堢己澶�
+                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();
+    }
+
+    /** 姹囨�绘暣鏈叧閿寚鏍囬瑙堬細璐ц繍閲�/鍛ㄨ浆閲忋�佸煄甯傚杩愩�佷腑鍙e緞鐨� 褰撴湀銆�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) + " 鍚屾湡鍏矾鏃呭鏁版嵁鏈鍏ュ簱锛屼腑鍙e緞鍚屾瘮鏆備笉鏄剧ず锛屽緟 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);
+    }
+
+    /** 鎶曡祫鎽樿锛坈ategory 闈炵┖鏃跺彧缁熻璇ョ被鍒紝濡傚杩愮珯鍦�/鐗╂祦鍥尯鎶曡祫鏄庣粏琛級 */
+    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("鏃犳姇璧勬湀搴︽暟鎹�");
+    }
+
+    /** 鑳借�楁憳瑕侊細杞﹁締璁板綍鏁� + 鐕冩补娑堣�楀悎璁★紙瀛e害琛紝鎸夋墍閫夊搴︽湯鏈堬級 */
+    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 涓哄簳绋匡細淇濈暀琛ㄥご/鍚堝苟/鍒楀/鏍峰紡锛屼粎鏇挎崲鏁版嵁锛決/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);
     }
+    /** 璐ц繍鎶ヨ〃妯℃澘瀹氫綅锛坉ocs/璐ц繍锛� */
+    private File resolveFreightTemplate(String fileName) throws Exception {
+        return resolveAnyTemplate(freightTemplateDir, fileName);
+    }
+
+    /** 鍐欒揣杩愭ā鏉挎暟鎹崟鍏冩牸锛氬叕寮忓垪涓嶅姩锛涙湁鍊煎厛娓呯┖鍐嶅啓锛圥OI 瀵瑰叕寮忓崟鍏冩牸鍙洿鏂扮紦瀛橈級锛屾棤鍊兼竻绌烘ā鏉挎牱渚� */
+    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);
+    }
+
+    /** 澶嶅埗鍗曟牸锛歮ove=false 鍘熸牱澶嶅埗锛沵ove=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锛変慨姝f湀浠�/骞翠唤鏂囨湰鎴栨棩鏈燂紱鏁版嵁鍖轰粎鏍峰紡+闈炶法绨垮叕寮忓厠闅� */
+    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)));
+            }
+        }
+    }
+
+    /** 鍏紡鍒楀紩鐢ㄥ钩绉伙細鏈〃鏈堝�煎垪锛圕/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 鍙e緞纭锛� */
+    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;
+    }
+
+    /** 涓彛寰勬湀鍚屾瘮锛歬=-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 鏈堬紝鍏朵綑娓呯┖锛�+ 绱/绱鍚屾瘮锛坈umCol 璧蜂袱鍒楋級 */
+    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 鍏ㄧ渷銆丷8 姝︽眽甯傗�︼級
+                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;
+        }
+    }
+
+
+
+    // ==================== 涓彛寰勬槑缁�/鎺掑悕/鍒嗘瀽锛圚203-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 浼氬湪璺ㄧ翱鍏紡锛圫heet2 鐨� [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;
+    }
+
+    /**
+     * 濉腑鍙e緞鏁版嵁鍗曞厓鏍硷細鐝嚎/鍏氦/鍑虹鍩庝埂/缃戠害杞﹀煄涔� 鍥涚淮缁熶竴鍙栧簱鍐呭�笺��
+     * 搴撳唴璇ユ湀璇ョ淮鏃犳簮锛堝彇鍊间负 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锛圥OI 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 鐨勩�岀淮搴︽槸鍚︽湁婧愯銆嶇櫥璁拌〃锛坘ey -> 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锛氬彇鏈�杩戜竴娆′腑鍙e緞鏁版嵁鍔犺浇鐨勯棶棰樻竻鍗曪紙鍙鍓湰锛� */
+    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 鍙e緞锛�
+            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;
+            // 鍙e緞缁熶竴锛�2026-09-19锛岀敤鎴锋牳瀵广�屼腑鍙e緞鎺掑悕銆嶅懆杞噺 vs 浜哄伐鍒楋級锛氬煄闄呭煄涔′竴寰嬬敤銆屾�婚噺 鈭� 鍩庡競鍐呫�嶇幇绠椼��
+            // 鍘熼�昏緫銆屽瓨鍌ㄧ殑鍩庨檯鍩庝埂 > 0 灏变紭鍏堝彇瀛楁鍊硷紝<=0 鎵嶅洖閫�鍒板樊棰濇硶銆嶄細鍦ㄤ釜鍒紒涓氳涓婁笌宸娉曞樊 卤0.01
+            // 锛堟簮鎶ヨ〃閲屾�婚噺/鍩庡競鍐�/鍩庨檯鍩庝埂鍚勮嚜涓や綅灏忔暟鑸嶅叆锛�2026 骞� 1-8 鏈堝叏鐪佸叡 72 琛岋級锛岀疮鍔犲悗
+            // 銆屼腑鍙e緞鎺掑悕銆嶉〉姣斻�屼腑鍙e緞鏄庣粏銆�/浜哄伐鍙e緞澶� 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 鍙e緞涓�鑷达級 */
+    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锛夛細涓彛寰勬帓鍚嶇殑绱鍚屾瘮鍩烘暟涓庘�滀腑鍙e緞鏄庣粏鈥濅繚鎸佸悓婧愩��
+     * 鏄庣粏椤电殑 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锛欱3/F3 涓� SUM 鍏紡淇濈暀锛汥3/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锛欱/F 绱鍊笺�丏/H 鍚屾瘮锛汣/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));
+                // 鍙冲潡褰撴湀锛歀/P 鍊笺�丯/R 褰撴湀鍚屾瘮锛圡/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);
+        }
+    }
+
+    /** 璇诲彇姣嶇増銆婁腑鍙e緞鏄庣粏銆嬬殑 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("涓彛寰勬帓鍚嶏細褰撳墠姹囨�诲伐浣滅翱鐨勪腑鍙e緞鏄庣粏鏈鍒板畬鏁� 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("涓彛寰勬帓鍚嶏細姣嶇増 {} 鐨勪腑鍙e緞鏄庣粏鏈鍒板畬鏁� 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 + "鏈堜腑鍙e緞瀹㈣繍閲忓強鍛ㄨ浆閲�");
+            }
+            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锛夛紝鏀瑰啓涓烘湰鍧楁�昏锛圔$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. 鐢熸垚_鑳借繍姹囨�昏〃锛圚204 閬撹矾璐ц繍杞﹁締鑳芥簮娑堣�楁儏鍐碉級 ====================
+
+    /** 鐕冩枡绫诲瀷鐮� -> 鍛ㄨ浆閲忓崟鑰楁姌绠楃郴鏁帮紙鍗冨厠鏍囧噯鐓ゆ姌绠楋級 */
+    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] + "瀛e害鑳借�楁眹鎬绘儏鍐佃〃";
+
+        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琛屼负 鏌存补/姹芥补/娑插寲澶╃劧/鍘嬬缉澶╃劧/绾數/鐕冩枡鐢垫睜锛汥/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锛坰etDataCell 瀵� 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锛�
+            // 褰撴湀鍧楁帓鍚�/鍗犳瘮鍏紡鐢辩疮璁″潡澶嶅埗鑰屾潵锛孯ANK 鑼冨洿涓庡崰姣斿垎姣嶉渶鏀规寚褰撴湀鍧楄嚜韬
+            repairCityMonthRankShare(sheet, 50, 66, 49);
+            repairCityMonthRankShare(sheet, 72, 88, 71);
+            recalc(wb);
+            clearFormulaErrorsAll(wb); // 鏃犲幓骞寸疮璁$殑鍗犳瘮/澧為�熷叕寮� #DIV/0! 鈫� 娓呯┖
+            return toBytes(wb);
+        }
+    }
+
+    /** 濉厖涓�涓疮璁″尯锛歴tartRow 璧锋瘡鍦板尯 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++;
+        }
+    }
+
+    /** 褰撴湀鍧楁帓鍚�/鍗犳瘮鍏紡淇锛氭ā鏉垮綋鏈堝潡鐢辩疮璁″潡澶嶅埗鑰屾潵锛孯ANK 鑼冨洿涓庡崰姣斿垎姣嶉渶鏀规寚褰撴湀鍧楄嚜韬 */
+    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;
+    }
+
+    /** 鏌愬勾鍗曟湀鍩庡競瀹㈣繍鏁版嵁锛歝ity 鈫� 8 缁存暟缁勶紙鍙e緞鍚岀疮璁★級锛屽惈"鍏ㄧ渷"锛�= 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锛圥OI 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锛宻etDataCell 瀵� 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锛圥0-P1 楠ㄦ灦锛夛細鏁存湰澶嶅埗姣嶇増銆奩YYY骞碝鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx銆嬶紝
+     * 淇濈暀鍏ㄩ儴椤电/鐗堝紡/鍏紡/2025 骞寸紦瀛樺�硷紝浠呮妸琛ㄥご骞翠唤鍔ㄦ�佸寲涓虹洰鏍囧勾锛涙暟鎹~鍏呮寜 P2 璧烽�愭鎺ュ叆銆�
+     */
+    public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
+        return exportSummaryWorkbook(period, mode, null, null);
+    }
+
+    /** 鐢熸垚_閬撹矾杩愯緭閲忔眹鎬昏〃.xlsx锛圥0-P1 楠ㄦ灦锛屾敮鎸� toMonth 瑁佸壀锛夛細
+     *  浠ャ�奩YYY骞碝鏈堥亾璺繍杈撻噺姹囨�昏〃.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) {
+            // 杩涘害鍥炶皟澶辫触涓嶅奖鍝嶅鍑�
+        }
+    }
+
+    /**
+     * 姹囨�诲伐浣滅翱鍏紡閲嶇畻锛歅OI 鍐欐枃浠朵笉甯︾紦瀛樺�硷紝瀵艰嚧銆屽叏鐪佽锛埼�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); // 娉ㄦ剰锛歟valuate() 鏃犲壇浣滅敤锛屽繀椤荤敤 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;锛坕nlineStr锛夊瓨鍌紱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 鍒ゅ畾涓恒�屾棤娉曡鍙栫殑鍐呭銆嶈�岀洿鎺ユ嫆缁濇墦寮�锛圕OM 琛ㄧ幇涓恒�屼笉鑳藉彇寰楃被 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;
+    }
+
+    // ==================== 姹囨�诲伐浣滅翱鍐呯殑鎺掑悕椤靛埛鏂� ====================
+
+    /**
+     * 姹囨�诲伐浣滅翱閲岀殑銆岃揣杩愰噺鎺掑悕 / 璐ц繍鍛ㄨ浆閲忔帓鍚� / 涓彛寰勬帓鍚嶃�嶅湪姣嶇増閲屾槸涓婁竴鎶ヨ〃鏈熺殑闈欐�佸�硷紝
+     * 瀵煎嚭鏃剁敤鐙珛鎺掑悕琛紙銆婄敓鎴恄璐ц繍閲忔帓鍚嶃�嬨�婄敓鎴恄鍛ㄨ浆閲忔帓鍚嶃�嬨�婄敓鎴恄涓彛寰勬帓鍚嶃�嬶紝涓庣敤鎴锋棩甯告牳瀵圭殑鍙e緞涓�鑷达級
+     * 鐨勭粨鏋滃埛鏂版暟鍊硷紝閬垮厤姹囨�昏〃閲岀殑鎺掑悕椤靛仠鐣欏湪鏃ф湀浠姐��
+     * 鏁板�兼牸鎼�笺�佸叕寮忔牸鎸夊亸绉诲钩绉诲叕寮忓悗鍐欏叆锛涚洰鏍囨牸鏈韩宸叉槸鍏紡鐨勶紙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锛孭OI 鐨� setCellValue 鍙啓 <v>銆�
+                            // 涓嶆洿鏂� <is>锛孍xcel 浼氱户缁樉绀烘棫鏈堜唤锛屽繀椤诲厛 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锛圫UM/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 骞村煄甯傚杩愭暟鎹��
+
+    /** 鍩庡競瀹㈣繍鍙e緞鎸囨爣浣嶏紙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;                 // 鍩庡競瀹㈣繍鍙e緞鍙栤�滃煄甯傚唴鈥�
+            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 + "鏈堜腑鍙e緞瀹㈣繍閲忓強鍛ㄨ浆閲�");
+        smSetTitle(sh, 9, 0, month + "鏈堜腑鍙e緞瀹㈣繍閲忓強鍛ㄨ浆閲�");
+        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锛涘彧鍐欏�煎垪锛圔/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锛圔-E锛�+ 宸℃父鍑虹鏈堟姤锛圖B锛夛紱
+     *  绠楁硶涓庛�婄綉绾﹁溅鎷嗗垎.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;
+        }
+    }
+
+    /** 宸℃父鍑虹鏈堟姤锛圖B锛夋寜鏈堝姞杞斤細甯傚窞 -> [瀹㈣繍閲�, 鍛ㄨ浆閲�, 鍩庡競鍐呭杩愰噺, 鍩庡競鍐呭懆杞噺, 骞冲潎杩愯窛, 鍩庡競鍐呭钩鍧囪繍璺漖 */
+    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 鐩稿悓绛栫暐锛氬湪閰嶇疆鐩綍銆乽ser.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锛歍=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锛夛紝鏀瑰啓涓恒�孻YYY骞碝鏈堛�嶆枃鏈� */
+    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骞碝鏈�").format(d));
+            }
+        }
+    }
+
+    /** 妯℃澘鏂囦欢瀹氫綅锛氶厤缃洰褰� 鈫� user.dir 鈫� user.dir/.. 閫愮骇鍥為�� */
+    private File resolveTemplate(String fileName) throws Exception {
+        return resolveAnyTemplate(templateDir, fileName);
+    }
+
+    /** 鍏矾鏃呭杈撳嚭妯℃澘瀹氫綅锛坉ocs/鍏矾鏃呭+鑳借��/杈撳嚭锛� */
+    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 妯℃澘锛圚SSFWorkbook锛屼繚鐣欏垪瀹�/琛ㄥご/鏍峰紡/鍚堝苟锛� */
+    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);
+    }
+
+    /** 鍒犻櫎鏁版嵁鍖哄浣欒锛坒romRow..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));
+    }
+
+    /** 瑙f瀽 4 浣嶅勾浠戒负鏁板�硷紙鍐欐ā鏉垮勾浠藉垪锛夛紝鏃犳硶瑙f瀽杩斿洖 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;
+        }
+    }
+
+    /** 瑙f瀽 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();
         }
     }
-}
\ No newline at end of file
+
+    /** 鎶曡祫妯℃澘 甯傚窞灏忚妭缁撴瀯锛堣〃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骞村叏鐪侀瀹夋帓璁″垝杩涘害鎯呭喌姹囨�伙紙妯℃澘搴曠鍘熶綅鏇挎崲锛氫繚鐣欏叏閮ㄨ/鍚堝苟/鍒楀锛屼粎鏇挎崲鏁版嵁锛汥B 椤圭洰澶氫簬妯℃澘棰勭暀琛屾椂鍔ㄦ�佹彃琛岋級 */
+    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 鏈堢墿娴侊級锛屼富妗i噷椤圭洰浠嶅湪锛岃嫢涓嶈ˉ璁★紝璇ュ競宸炲皬璁�/鍚堣浼氭暣鍧椾负绌恒��
+     *
+     * @param category 鍧楃被鍒紙瀹㈣繍绔欏満/鐗╂祦鍥尯锛夛紱null 琛ㄧず涓嶉檺
+     * @param city     甯傚窞鍏ㄧО锛堝 姝︽眽甯傦級锛沶ull 琛ㄧず涓嶉檺
+     */
+    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锛宻heet锛氭槑缁� + 姹囨�伙級銆�
+     * 鍙e緞锛氭ā鏉垮嵆涓婃湀鍙拌处搴曠锛涙湰鏈堝彧瑕嗙洊銆屽簱鍐呮湁璇ユ湡鏁版嵁銆嶇殑椤圭洰琛�
+     * 锛圚鑷紑濮嬪缓璁剧疮璁� / 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());
+                // 鍙e緞锛堢敤鎴� 2026-09-18 纭锛夛細鍙鐩栦汉宸ヤ細闅忔湀鎶ユ洿鏂扮殑鍥涘垪鈥斺��
+                // H 鑷紑濮嬪缓璁剧疮璁� / J 鑷勾鍒濈疮璁� / K 鏈湀瀹屾垚 / M 褰㈣薄杩涘害锛�
+                // I 鏈勾璁″垝鎶曡祫銆丩 寤鸿闃舵銆丟 璁″垝鎬绘姇璧勪负鐪佸畾/姣嶇増鍙e緞锛屼竴寰嬩笉鍔紱搴撳唴涓虹┖鍒欎繚鐣欐瘝鐗堝師鍊�
+                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());
+                // 鍙e緞鍚屽杩愮珯鏄庣粏琛細鍙鐩� 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锛�"鍗佷簲浜�"瑙勫垝鐗╂祦椤圭洰杩涘睍鎯呭喌锛堟ā鏉垮簳绋垮師浣嶆浛鎹細淇濈暀鍏ㄩ儴琛�/鍚堝苟/鍒楀锛屼粎鏇挎崲鏁版嵁锛汥B 椤圭洰澶氫簬妯℃澘棰勭暀琛屾椂鍔ㄦ�佹彃琛岋級 */
+    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锛氾紙缁忔祹寮哄幙椤圭洰锛夋箹鍖楃渷浜ら�氱墿娴佸熀纭�璁炬柦鎶曡祫缁熻鎶ヨ〃锛堟ā鏉垮簳绋垮師浣嶆浛鎹細淇濈暀鍏ㄩ儴琛�/鍚堝苟/鍒楀锛屼粎鏇挎崲鏁版嵁锛汥B 椤圭洰澶氫簬妯℃澘棰勭暀琛屾椂鍔ㄦ�佹彃琛岋級 */
+    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锛坈ol0-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);
+    }
+
+    /** 绌轰覆褰掍竴锛坈ellText 鍙兘杩斿洖 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();
+        }
+    }
+}

--
Gitblit v1.9.1