From f2056aa286179f5d29f3fb6fce1479035cca856a Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期一, 12 十月 2026 17:14:15 +0800
Subject: [PATCH] Merge remote-tracking branch 'origin/main'

---
 traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java |  534 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 477 insertions(+), 57 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 03e2803..59ceab0 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
@@ -34,6 +34,9 @@
 import com.trafficaudit.reportexport.calc.MidCalc;
 import com.trafficaudit.reportexport.calc.PaxCalc;
 import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
+import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
+import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck;
+import com.trafficaudit.reportexport.layout.SummaryLayoutExpander;
 import com.trafficaudit.reportexport.calc.WycSplitCalc;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.poi.ss.usermodel.Cell;
@@ -101,6 +104,12 @@
     private CityTaxiMonthlyMapper cityTaxiMapper;
     @Resource
     private SummaryWorkbookFiller summaryWorkbookFiller;
+    @Resource
+    private SummaryLayoutExpander summaryLayoutExpander;
+    @Resource
+    private FreightReadinessCheck freightReadinessCheck;
+    @Resource
+    private SummaryMissingDataCheck missingDataCheck;
     @Resource
     private WycSplitCalc wycSplitCalc;
     @Resource
@@ -1430,10 +1439,11 @@
         Map<String, ScaleSplitTransport> result = new HashMap<>();
         List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
             new LambdaQueryWrapper<ScaleSplitTransport>()
-                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
+                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
+                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12锛氬彧璁ょ洰鏍囨湡锛岀己鏁扮暀绌恒�佷笉闈欓粯鍥為��涓婁竴鏈�
         for (ScaleSplitTransport record : list) {
             if (record.getReportPeriod() == null) continue;
-            if (record.getReportPeriod().compareTo(period) > 0) continue;
+            if (!period.equals(record.getReportPeriod())) continue;
             String city = RegionUtil.normalizeCityName(record.getRegionName());
             if (city == null) continue;
             if ("婀栧寳鐪�".equals(city)) continue;
@@ -1466,11 +1476,12 @@
     private ScaleSplitTransport getProvinceCumulative(String period) {
         List<ScaleSplitTransport> list = scaleSplitMapper.selectList(
             new LambdaQueryWrapper<ScaleSplitTransport>()
-                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE"));
+                .eq(ScaleSplitTransport::getPeriodType, "CUMULATIVE")
+                .eq(ScaleSplitTransport::getReportPeriod, period));   // 2026-10-12锛氬悓涓婏紙鍙鐩爣鏈燂級
         ScaleSplitTransport best = null;
         for (ScaleSplitTransport record : list) {
             if (record.getReportPeriod() == null) continue;
-            if (record.getReportPeriod().compareTo(period) > 0) continue;
+            if (!period.equals(record.getReportPeriod())) continue;
             String name = RegionUtil.normalizeCityName(record.getRegionName());
             if (!"婀栧寳鐪�".equals(name)) continue;
             if (best == null || record.getReportPeriod().compareTo(best.getReportPeriod()) > 0) best = record;
@@ -2681,9 +2692,19 @@
      * 鐢熸垚_涓彛寰勬帓鍚�.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);
@@ -2703,8 +2724,8 @@
                 title.getCell(10).setCellValue(currentYear + "骞�" + maxMonth + "鏈堝叏鐪佸垎甯傚窞瀹屾垚閬撹矾瀹㈣繍鐢熶骇鎯呭喌");
             }
             // 鍏ㄧ渷琛� r3锛欱3/F3 涓� SUM 鍏紡淇濈暀锛汥3/H3 鍚屾瘮濉�硷紙0-based 鍒� 3/7锛�
-            setValOrBlank(sheet, 2, 3, midCumYoy(mid, currentYear, "婀栧寳鐪�", maxMonth, 0, true));
-            setValOrBlank(sheet, 2, 7, midCumYoy(mid, currentYear, "婀栧寳鐪�", maxMonth, 0, false));
+            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));
@@ -2715,17 +2736,13 @@
                 int r0 = 3 + i;
                 double pass = midCumClass(mid, currentYear, city, maxMonth, 0, true);
                 double turn = midCumClass(mid, currentYear, city, maxMonth, 0, false);
-                double lastPass = midCumClass(mid, currentYear - 1, city, maxMonth, 0, true);
-                double lastTurn = midCumClass(mid, currentYear - 1, city, maxMonth, 0, false);
                 setValOrBlank(sheet, r0, 1, pass == 0 ? null : pass);
-                setValOrBlank(sheet, r0, 3, midCumYoy(mid, currentYear, city, maxMonth, 0, true));
+                setValOrBlank(sheet, r0, 3, midRankCumYoy(mid, currentYear, city, maxMonth, true, lastYearCumBase));
                 setValOrBlank(sheet, r0, 5, turn == 0 ? null : turn);
-                setValOrBlank(sheet, r0, 7, midCumYoy(mid, currentYear, city, maxMonth, 0, false));
+                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);
-                double mLastPass = midClassVal(mid, currentYear - 1, maxMonth, city, 0, true);
-                double mLastTurn = midClassVal(mid, currentYear - 1, maxMonth, city, 0, false);
                 setValOrBlank(sheet, r0, 11, mPass == 0 ? null : mPass);
                 setValOrBlank(sheet, r0, 13, mPass == 0 ? null : midMonthYoy(mid, currentYear, maxMonth, city, 0, true));
                 setValOrBlank(sheet, r0, 15, mTurn == 0 ? null : mTurn);
@@ -2734,6 +2751,112 @@
             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);
     }
 
     /** 鍐欐暟鍊兼垨娓呯┖锛堝叕寮忓崟鍏冩牸淇濈暀涓嶅姩锛� */
@@ -3507,7 +3630,87 @@
      * 淇濈暀鍏ㄩ儴椤电/鐗堝紡/鍏紡/2025 骞寸紦瀛樺�硷紝浠呮妸琛ㄥご骞翠唤鍔ㄦ�佸寲涓虹洰鏍囧勾锛涙暟鎹~鍏呮寜 P2 璧烽�愭鎺ュ叆銆�
      */
     public byte[] exportSummaryWorkbook(String period, String mode) throws Exception {
-        return exportSummaryWorkbook(period, mode, null);
+        return exportSummaryWorkbook(period, mode, null, null);
+    }
+
+    /**
+     * 姹囨�诲ぇ琛ㄣ�屾寜鏈堟墿鍒椼�嶏紙2026-10-09 Step 1锛夛細鎶� period 褰撴湀姣嶇増鎵╁垪鎴愪笅涓�鏈堢増寮忋��
+     * 鍙仛鐗堝紡鈥斺�斿綋鏈堟暟鎹垪鐣欑┖锛涗笉钀界洏锛岀敱璋冪敤鏂瑰喅瀹氭槸鍚︾‘璁ゅ悗鍙﹀瓨涓轰笅鏈熸瘝鐗堛��
+     */
+    public byte[] expandSummaryWorkbook(String period) throws Exception {
+        if (period == null || !period.contains("-")) {
+            throw new RuntimeException("鎵╁垪浠呮敮鎸佹寜鏈堬紝璇锋寚瀹� period=YYYY-MM");
+        }
+        int year = Integer.parseInt(period.split("-")[0]);
+        int month = monthOf(period, "month");
+        if (month >= 12) {
+            throw new RuntimeException("鎵╁垪锛�12 鏈堜箣鍚庨渶鏂板紑涓�鏈紝璺ㄥ勾瑙勫垯鍙﹁澶勭悊");
+        }
+        // 2026-10-12锛氭墿鍒椾互銆屽綋鏈熶骇鐗┿�嶄负搴曪紙浜х墿鍚綋鏈熷垪鐨勫�间笌鍏紡锛涙瘝鐗堢殑褰撴湡鍒楁槸绌虹殑锛�
+        // 鐩存帴鎷挎瘝鐗堟墿鍒椾細鎶婂綋鏈熸暟鎹涪鎴愮┖娲烇紝瀵艰嚧绱婕忔湀锛夈�備骇鐗╁彇鑷鍑鸿惤鐩樼紦瀛橈紱缂轰骇鐗╂椂鍥為��姣嶇増骞跺憡璀︺��
+        File source = resolveLatestProduct(period);
+        boolean fromProduct = source != null;
+        if (source == null) {
+            source = resolveSummaryMother(period, "month");
+            if (source != null) {
+                log.warn("姹囨�诲ぇ琛ㄦ墿鍒楋細鏈壘鍒� {} 鐨勪骇鐗╃紦瀛橈紝鍥為��鐢ㄦ瘝鐗堟墿鍒椻�斺�斿巻鍙插垪閲岀己灏戝綋鏈熸暟鎹紝绱鍙兘婕忔湀", period);
+            }
+        }
+        if (source == null) {
+            throw new RuntimeException("鏈壘鍒版眹鎬昏〃姣嶇増鎴栦骇鐗╋紙docs/鐢熸垚姹囨�诲ぇ琛級锛�" + year + "骞�" + month + "鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx");
+        }
+        try (InputStream in = new FileInputStream(source)) {
+            byte[] out = summaryLayoutExpander.expand(org.apache.poi.util.IOUtils.toByteArray(in), year, month + 1);
+            log.info("姹囨�诲ぇ琛ㄦ墿鍒楀畬鎴愶細{}{} -> {}骞磠}鏈堢増寮忥紙鍙仛鐗堝紡銆佸綋鏈熷垪鐣欑┖锛�",
+                    fromProduct ? "浜х墿" : "姣嶇増", source.getName(), year, month + 1);
+            return out;
+        }
+    }
+
+    /** 鍙栬鏈熴�屾渶鏂颁骇鐗┿�嶏細瀵煎嚭钀界洏缂撳瓨 _cache/summaryWorkbook_<period>_month_v*.xlsx锛堟寜淇敼鏃堕棿鍙栨渶鏂帮級 */
+    private File resolveLatestProduct(String period) {
+        try {
+            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
+            };
+            String prefix = "summaryWorkbook_" + period + "_month_";
+            for (String root : roots) {
+                if (root == null || root.trim().isEmpty()) continue;
+                File dir = new File(root, "_cache");
+                if (!dir.isDirectory()) continue;
+                File[] files = dir.listFiles((d, name) -> name.startsWith(prefix) && name.endsWith(".xlsx"));
+                if (files == null || files.length == 0) continue;
+                File latest = files[0];
+                for (File f : files) {
+                    if (f.lastModified() > latest.lastModified()) latest = f;
+                }
+                return latest;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("姹囨�诲ぇ琛ㄦ墿鍒楋細鏌ユ壘浜х墿缂撳瓨澶辫触锛堝拷鐣ワ級锛歿}", e.getMessage());
+            return null;
+        }
+    }
+
+    /** 鍙慨姝e綋鏈熸瘝鐗堥噷 7 涓爣棰橀〉鐨勬爣棰樻枃瀛楋紙鎸夋瘝鐗堟湡娆¢噸鍐欙紝鐢ㄤ簬淇婊炲悗鏍囬锛夈�� */
+    public byte[] fixSummaryTitles(String period) throws Exception {
+        if (period == null || !period.contains("-")) {
+            throw new RuntimeException("鏍囬淇浠呮敮鎸佹寜鏈堬紝璇锋寚瀹� period=YYYY-MM");
+        }
+        int year = Integer.parseInt(period.split("-")[0]);
+        int month = monthOf(period, "month");
+        File mother = resolveSummaryMother(period, "month");
+        if (mother == null) {
+            throw new RuntimeException("鏈壘鍒版眹鎬昏〃姣嶇増锛坉ocs/鐢熸垚姹囨�诲ぇ琛級锛�" + year + "骞�" + month + "鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx");
+        }
+        try (InputStream in = new FileInputStream(mother)) {
+            return summaryLayoutExpander.fixTitles(org.apache.poi.util.IOUtils.toByteArray(in), year, month);
+        }
     }
 
     /** 鐢熸垚_閬撹矾杩愯緭閲忔眹鎬昏〃.xlsx锛圥0-P1 楠ㄦ灦锛屾敮鎸� toMonth 瑁佸壀锛夛細
@@ -3515,6 +3718,13 @@
      *  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);
@@ -3522,9 +3732,11 @@
             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)) {
@@ -3543,26 +3755,46 @@
             int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
             List<String> dbProblems = new ArrayList<>();
             String effPeriod = String.format("%04d-%02d", year, effMonth);
+            dbProblems.addAll(freightReadinessCheck.check(effPeriod));   // B4锛氳揣杩愬彛寰勫鍑哄墠浣撴锛�6 鏉¤鍒欙級
+            dbProblems.addAll(missingDataCheck.check(effPeriod));        // 缂烘暟娓呭崟锛堣鏈夌殑鏁版病鏈夛級
+            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);
+            // 2026-10-12锛氬仠鐢ㄣ�屽鐢ㄥ閮ㄥ弬鐓т欢銆婄敓鎴恄閬撹矾杩愯緭閲忔眹鎬昏〃.xlsx銆嬪垪瀹姐�嶁�斺�旂増寮忎竴寰嬩互褰撴湡姣嶇増涓哄噯锛�
+            // 閬垮厤姣嶇増涓庡弬鐓т欢涓ゅ缁存姢锛堟瘝鐗堝凡骞跺叆鍙傜収浠剁殑鍒楀/闅愯棌锛夈��
+            int widthApplied = 0;
             log.info("姹囨�诲伐浣滅翱鏁版嵁椹卞姩鍥炲~瀹屾垚锛氬啓鍏ュ綋鏈堝�兼牸鏁�={}锛岃ˉ鍐欏綋鏈堝悓姣斿叕寮忔牸鏁�={}锛岃ˉ鍐欏綋鏈堝悎璁″叕寮忔牸鏁�={}锛�"
-                    + "鍒锋柊鎺掑悕椤垫暟鍊兼牸鏁�={}锛屽埛鏂板洓椤碉紙鍩庡競姹囨��/鍩庡競鍒嗗競宸�/涓彛寰勫垎鏋�/閬撹矾杩愯緭鍛ㄨ浆閲忥級鏁板�兼牸鏁�={}锛�"
+                    + "淇绱鍚屾瘮鍒嗘瘝鏍兼暟={}锛屽埛鏂版帓鍚嶉〉鏁板�兼牸鏁�={}锛屽埛鏂板洓椤碉紙鍩庡競姹囨��/鍩庡競鍒嗗競宸�/涓彛寰勫垎鏋�/閬撹矾杩愯緭鍛ㄨ浆閲忥級鏁板�兼牸鏁�={}锛�"
                     + "鎸夈�婄敓鎴恄閬撹矾杩愯緭閲忔眹鎬昏〃銆嬪榻愬垪瀹藉垪鏁�={}锛岀己鏁版彁绀�={}",
-                    dbFilled, yoyFilled, monthFormulaFilled, rankRefreshed, cumulativeRefreshed, widthApplied, dbProblems);
+                    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) {
+            // 杩涘害鍥炶皟澶辫触涓嶅奖鍝嶅鍑�
         }
     }
 
@@ -3729,7 +3961,7 @@
         int n = 0;
         n += copyRankValues(wb, "璐ц繍閲忔帓鍚�", exportFreightRank(period, mode), new int[][]{{0, 0}, {22, 0}});
         n += copyRankValues(wb, "璐ц繍鍛ㄨ浆閲忔帓鍚�", exportTurnoverRank(period, mode), new int[][]{{0, 0}, {22, 0}});
-        n += copyRankValues(wb, "涓彛寰勬帓鍚�", exportPassengerMidRank(period, mode), new int[][]{{0, 0}, {0, 10}});
+        n += copyRankValues(wb, "涓彛寰勬帓鍚�", exportPassengerMidRank(period, mode, wb), new int[][]{{0, 0}, {0, 10}});
         return n;
     }
 
@@ -3758,15 +3990,38 @@
                 int colOff = dstTitle.getColumnIndex() - sc0;
                 int h = rankBlockHeight(src, sr0, sc0);
                 int w = rankBlockWidth(src, sr0, sc0);
+                // 2026-10-12锛氭暣鍧楃己鏁帮紙婧愪晶涓�涓暟鍊奸兘娌℃湁锛屽 9 鏈堢己鈶♀憿鏃剁殑鍛ㄨ浆閲忔帓鍚嶏級鈫� 鐩爣鍧楀彧鐣欐爣棰樹笌鏍囩锛�
+                // 鏁版嵁/鎺掑悕/鍗犳瘮涓�寰嬬暀绌猴紱鍚﹀垯姣嶇増鐨� RANK/鍗犳瘮鍏紡浼氬湪绌哄潡涓婄畻鍑恒�屽叏 1 + #DIV/0!銆嶃��
+                boolean blockHasData = false;
+                for (int r = 1; r < h && !blockHasData; r++) {
+                    Row sr = src.getRow(sr0 + r);
+                    if (sr == null) continue;
+                    for (int c = 0; c < w; c++) {
+                        Cell sc0c = sr.getCell(sc0 + c);
+                        if (sc0c != null && rankNumeric(sc0c, ev) != null) {
+                            blockHasData = true;
+                            break;
+                        }
+                    }
+                }
                 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;
+                        Cell sc = srcRow.getCell(sc0 + c);
+                        if (!blockHasData && r > 0) {
+                            // 鏁村潡缂烘暟锛氬彧淇濈暀鏍囩锛堝競宸炲悕/鎸囨爣鍚嶏級锛屾暟鎹笌鎺掑悕/鍗犳瘮閲嶇畻缁撴灉涓�寰嬬暀绌�
+                            if (sc == null || !isRankLabel(sc)) clearRankCell(dstRow, dc);
+                            continue;
+                        }
+                        if (sc == null) {
+                            // 2026-10-12锛氭簮渚ф暣鏍肩己澶� = 缂烘暟 鈫� 鐩爣鏍兼寜銆岀己鏁扮暀绌恒�嶆竻绌猴紙涓嶅啀娌跨敤姣嶇増涓婁竴鎶ヨ〃鏈熺殑鏃у�硷級
+                            if (r > 0) clearRankCell(dstRow, dc);
+                            continue;
+                        }
                         if (r == 0) {
                             // 鏍囬琛岋細鍚屾鍧楁爣棰樻枃妗堛�傛瘝鐗堟爣棰樻槸 inlineStr锛孭OI 鐨� setCellValue 鍙啓 <v>銆�
                             // 涓嶆洿鏂� <is>锛孍xcel 浼氱户缁樉绀烘棫鏈堜唤锛屽繀椤诲厛 setBlank 娓呮帀鍐嶅啓銆�
@@ -3801,7 +4056,12 @@
                             continue;
                         }
                         Double v = rankNumeric(sc, ev);
-                        if (v == null) continue;
+                        if (v == null) {
+                            // 2026-10-12锛氭簮渚ф暟鍊间綅涓虹┖锛堝缂衡憽鍏ㄥ彛寰勬椂鎺掑悕琛ㄧ殑銆岃涓嬨�嶅垪锛�= 缂烘暟 鈫� 鐣欑┖锛�
+                            // 婧愪晶鏄爣绛炬枃鏈紙甯傚窞鍚�/鎸囨爣鍚嶏級鏃朵笉鏄己鏁帮紝淇濈暀姣嶇増鑷甫鍐呭銆�
+                            if (!isRankLabel(sc)) clearRankCell(dstRow, dc);
+                            continue;
+                        }
                         if (dcCell == null) dcCell = rankCell(dstRow, dc);
                         dcCell.setCellValue(v);
                         copied++;
@@ -3818,6 +4078,34 @@
         Cell left = col > 0 ? row.getCell(col - 1) : null;
         if (left != null) c.setCellStyle(left.getCellStyle());
         return c;
+    }
+
+    /**
+     * 鐩爣鏍兼寜銆岀己鏁扮暀绌恒�嶆竻绌恒��
+     *
+     * <p>2026-10-12锛氳繖閲岃繛姣嶇増鑷甫鐨勬帓鍚�/鍗犳瘮鍏紡鏍间竴璧锋竻锛堢己鏁版湡涓嶆樉绀�"涓婁竴鏈熸畫鐣欏�� + 鍏� 1 鎺掑悕 + #DIV/0!"锛夛紝
+     * 涓庛�婄敓鎴恄璐ц繍閲忔帓鍚�/鍛ㄨ浆閲忔帓鍚�/涓彛寰勬帓鍚嶃�嬭繖浜涚敤鎴锋棩甯告牳瀵逛欢鐨勭┖鎬佷竴鑷达紱涓嬫湡婧愪晶鏈夋暟鏃舵寜婧愪晶閲嶅啓鍥炴潵銆�
+     * 鏂囧瓧鏍囩鏍间笉鍔ㄣ��
+     */
+    private void clearRankCell(Row dstRow, int col) {
+        Cell c = dstRow.getCell(col);
+        if (c == null) return;
+        if (c.getCellType() != CellType.STRING) c.setBlank();
+    }
+
+    /** 婧愪晶鏄惁涓烘枃瀛楁爣绛撅紙甯傚窞鍚�/鎸囨爣鍚嶏級鈥斺�旀爣绛句笉鏄己鏁帮紝涓嶆竻绌虹洰鏍囨牸 */
+    private boolean isRankLabel(Cell c) {
+        if (c.getCellType() != CellType.STRING) return false;
+        String v = c.getStringCellValue();
+        if (v == null) return false;
+        String x = v.replace(",", "").trim();
+        if (x.isEmpty()) return false;
+        try {
+            Double.parseDouble(x);
+            return false;
+        } catch (NumberFormatException ignore) {
+            return true;
+        }
     }
 
     /** 鍏紡鍐呮墍鏈� A1 寮曠敤鏁翠綋骞崇Щ锛氬垪 +dCol銆佽 +dRow锛圫UM/RANK 绛夊嚱鏁板悕鍚庢帴鎷彿涓嶄細琚尮閰嶏級 */
@@ -3948,9 +4236,8 @@
     private static final int SM_WYC_CX_PAX = 16, SM_WYC_CX_TURN = 17;
     private static final int SM_FREIGHT_TURN = 18;
     private static final int SM_DIM = 19;
-    /** 2026 骞� 1 鏈堝綋鏈堝�煎垪锛�0 鍩猴紝C 鍒楋級锛�2025 骞� 1 鏈堝綋鏈堝�煎垪锛�0 鍩猴紝V 鍒楋級 */
-    private static final int SM_CUR_COL0 = 2;
-    private static final int SM_LAST_COL0 = 21;
+    // 2026-10-09锛氬師鍏堝啓姝荤殑 SM_CUR_COL0=2锛�2026骞�1鏈�=C锛�/ SM_LAST_COL0=21锛�2025骞�1鏈�=V锛夊凡搴熷純锛�
+    // 鏀逛负鎸夎〃澶村姩鎬佸畾浣嶏紙瑙� smMonthCols / smYearMonthOf锛夛紝鍏煎銆屾枃鏈� / 鏃ユ湡搴忓垪鍙� / 鏃ユ湡鏍煎紡銆嶄笁绉嶈〃澶村啓娉曘��
 
     /**
      * 鍒锋柊鍥涘紶璺ㄩ〉鍚堟垚椤碉細褰撳勾绱锛�1..month 鏈堬級銆佸綋鏈堬紙month 鏈堬級鍙婂搴斿悓姣旓紱
@@ -3961,10 +4248,10 @@
         int[] cumMonths = new int[month];
         for (int m = 1; m <= month; m++) cumMonths[m - 1] = m;
         int[] oneMonth = new int[]{month};
-        Map<String, double[]> curCum = smReadLedgerMetrics(wb, true, cumMonths);
-        Map<String, double[]> lastCum = smReadLedgerMetrics(wb, false, cumMonths);
-        Map<String, double[]> curMon = smReadLedgerMetrics(wb, true, oneMonth);
-        Map<String, double[]> lastMon = smReadLedgerMetrics(wb, false, oneMonth);
+        Map<String, double[]> curCum = smReadLedgerMetrics(wb, year, cumMonths);
+        Map<String, double[]> lastCum = smReadLedgerMetrics(wb, year - 1, cumMonths);
+        Map<String, double[]> curMon = smReadLedgerMetrics(wb, year, oneMonth);
+        Map<String, double[]> lastMon = smReadLedgerMetrics(wb, year - 1, oneMonth);
         if (curCum.isEmpty()) {
             log.warn("姹囨�诲ぇ琛ㄥ洓椤靛埛鏂帮細鍙拌处椤垫湭璇诲埌甯傚窞鏁版嵁锛岃烦杩囷紙淇濇寔姣嶇増鍘熷�硷級");
             return 0;
@@ -3993,22 +4280,23 @@
     }
 
     /** 鍙拌处椤电疮璁″彇鏁帮細甯傚窞瑙勮寖鍚� -> 19 缁存寚鏍� */
-    private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, boolean curYear, int[] months) {
+    private Map<String, double[]> smReadLedgerMetrics(XSSFWorkbook wb, int blockYear, int[] months) {
         Map<String, double[]> out = new LinkedHashMap<>();
-        smReadCityPaxPage(wb, "鍏氦", curYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN);
-        smReadCityPaxPage(wb, "鍑虹", curYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN);
-        smReadCityPaxPage(wb, "缃戠害杞�", curYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN);
-        smReadRailFerry(wb, curYear, months, out);
-        smReadBanxian(wb, curYear, months, out);
-        smReadFreightTurnover(wb, curYear, months, out);
+        smReadCityPaxPage(wb, "鍏氦", blockYear, months, out, SM_BUS_PAX, SM_BUS_TURN, SM_BUS_CX_PAX, SM_BUS_CX_TURN);
+        smReadCityPaxPage(wb, "鍑虹", blockYear, months, out, SM_TAXI_PAX, SM_TAXI_TURN, SM_TAXI_CX_PAX, SM_TAXI_CX_TURN);
+        smReadCityPaxPage(wb, "缃戠害杞�", blockYear, months, out, SM_WYC_PAX, SM_WYC_TURN, SM_WYC_CX_PAX, SM_WYC_CX_TURN);
+        smReadRailFerry(wb, blockYear, months, out);
+        smReadBanxian(wb, blockYear, months, out);
+        smReadFreightTurnover(wb, blockYear, months, out);
         return out;
     }
 
     /** 鍏氦/鍑虹/缃戠害杞﹂〉锛氭瘡甯傚窞 4 琛岋紙瀹㈣繍閲�/鏃呭鍛ㄨ浆閲�/鍏朵腑鍩庡競鍐呭杩愰噺/鍏朵腑鍩庡競鍐呮梾瀹㈠懆杞噺锛� */
-    private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, boolean curYear, int[] months,
+    private void smReadCityPaxPage(XSSFWorkbook wb, String sheetName, int blockYear, int[] months,
                                    Map<String, double[]> out, int paxIdx, int turnIdx, int cxPaxIdx, int cxTurnIdx) {
         Sheet sh = wb.getSheet(sheetName);
         if (sh == null) return;
+        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
         int last = sh.getLastRowNum();
         for (int r = sh.getFirstRowNum(); r <= last; r++) {
             Row row = sh.getRow(r);
@@ -4031,10 +4319,10 @@
                     else if (paxRow < 0) paxRow = k;
                 }
             }
-            double totalPax = smSumMonths(sh, paxRow, curYear, months);
-            double totalTurn = smSumMonths(sh, turnRow, curYear, months);
-            double cityPax = smSumMonths(sh, cxPaxRow, curYear, months);
-            double cityTurn = smSumMonths(sh, cxTurnRow, curYear, months);
+            double totalPax = smSumMonths(sh, paxRow, monthCols, months);
+            double totalTurn = smSumMonths(sh, turnRow, monthCols, months);
+            double cityPax = smSumMonths(sh, cxPaxRow, monthCols, months);
+            double cityTurn = smSumMonths(sh, cxTurnRow, monthCols, months);
             double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
             arr[paxIdx] += cityPax;                 // 鍩庡競瀹㈣繍鍙e緞鍙栤�滃煄甯傚唴鈥�
             arr[turnIdx] += cityTurn;
@@ -4044,9 +4332,10 @@
     }
 
     /** 杞ㄩ亾銆佽疆娓¢〉锛氳建閬撳潡锛堟姹�/榛勭煶锛�+ 杞浮鍧楋紙姝︽眽锛� */
-    private void smReadRailFerry(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
+    private void smReadRailFerry(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
         Sheet sh = wb.getSheet("杞ㄩ亾銆佽疆娓�");
         if (sh == null) return;
+        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
         int last = sh.getLastRowNum();
         int ferryTitle = -1;
         for (int r = sh.getFirstRowNum(); r <= last; r++) {
@@ -4055,12 +4344,12 @@
             String a = smText(row.getCell(0));
             if (a != null && a.contains("杞浮瀹㈣繍閲�")) { ferryTitle = r; break; }
         }
-        smReadRailFerryBlock(sh, curYear, months, out, sh.getFirstRowNum(),
+        smReadRailFerryBlock(sh, monthCols, months, out, sh.getFirstRowNum(),
                 ferryTitle < 0 ? last : ferryTitle - 1, SM_RAIL_PAX);
-        if (ferryTitle >= 0) smReadRailFerryBlock(sh, curYear, months, out, ferryTitle, last, SM_FERRY_PAX);
+        if (ferryTitle >= 0) smReadRailFerryBlock(sh, monthCols, months, out, ferryTitle, last, SM_FERRY_PAX);
     }
 
-    private void smReadRailFerryBlock(Sheet sh, boolean curYear, int[] months, Map<String, double[]> out,
+    private void smReadRailFerryBlock(Sheet sh, Map<Integer, Integer> monthCols, int[] months, Map<String, double[]> out,
                                       int start, int end, int baseIdx) {
         for (int r = start; r <= end; r++) {
             Row row = sh.getRow(r);
@@ -4077,15 +4366,16 @@
                 else if (paxRow < 0) paxRow = k;
             }
             double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
-            arr[baseIdx] += smSumMonths(sh, paxRow, curYear, months);
-            arr[baseIdx + 1] += smSumMonths(sh, turnRow, curYear, months);
+            arr[baseIdx] += smSumMonths(sh, paxRow, monthCols, months);
+            arr[baseIdx + 1] += smSumMonths(sh, turnRow, monthCols, months);
         }
     }
 
     /** 鐝嚎鍖呰溅椤碉細姣忓競宸� 瀹㈣繍閲� / 鏃呭鍛ㄨ浆閲忥紙鈥滃叾涓釜浣撯�濊涓嶉噸澶嶈鍏ワ級 */
-    private void smReadBanxian(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
+    private void smReadBanxian(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
         Sheet sh = wb.getSheet("鐝嚎鍖呰溅");
         if (sh == null) return;
+        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
         int last = sh.getLastRowNum();
         for (int r = sh.getFirstRowNum(); r <= last; r++) {
             Row row = sh.getRow(r);
@@ -4102,16 +4392,17 @@
                 else if (b.contains("瀹㈣繍閲�") && paxRow < 0) paxRow = k;
             }
             double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
-            arr[SM_BX_PAX] += smSumMonths(sh, paxRow, curYear, months);
-            arr[SM_BX_TURN] += smSumMonths(sh, turnRow, curYear, months);
+            arr[SM_BX_PAX] += smSumMonths(sh, paxRow, monthCols, months);
+            arr[SM_BX_TURN] += smSumMonths(sh, turnRow, monthCols, months);
         }
     }
 
     /** 璐ц繍椤碉細姣忓競宸� 瑙勪笂璐х墿鍛ㄨ浆閲� + 瑙勪笅璐х墿鍛ㄨ浆閲忥紙鈥滆涓�+瑙勪笅鈥濊鏈韩鏄眰鍜屽叕寮忥紝涓嶇洿鎺ュ彇锛� */
-    private void smReadFreightTurnover(XSSFWorkbook wb, boolean curYear, int[] months, Map<String, double[]> out) {
+    private void smReadFreightTurnover(XSSFWorkbook wb, int blockYear, int[] months, Map<String, double[]> out) {
         Sheet sh = wb.getSheet(" 璐ц繍");
         if (sh == null) sh = wb.getSheet("璐ц繍");
         if (sh == null) return;
+        Map<Integer, Integer> monthCols = smMonthCols(sh, blockYear);
         int last = sh.getLastRowNum();
         for (int r = sh.getFirstRowNum(); r <= last; r++) {
             Row row = sh.getRow(r);
@@ -4128,20 +4419,74 @@
                 else if (b.contains("瑙勪笅璐х墿鍛ㄨ浆閲�")) { if (below < 0) below = k; }
             }
             double[] arr = out.computeIfAbsent(city, k -> new double[SM_DIM]);
-            arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, curYear, months) + smSumMonths(sh, below, curYear, months);
+            arr[SM_FREIGHT_TURN] += smSumMonths(sh, above, monthCols, months) + smSumMonths(sh, below, monthCols, months);
         }
     }
 
-    private double smSumMonths(Sheet sh, int rowIdx, boolean curYear, int[] months) {
-        if (sh == null || rowIdx < 0) return 0;
+    private double smSumMonths(Sheet sh, int rowIdx, Map<Integer, Integer> monthCols, int[] months) {
+        if (sh == null || rowIdx < 0 || monthCols == null) return 0;
         Row row = sh.getRow(rowIdx);
         if (row == null) return 0;
         double s = 0;
         for (int m : months) {
-            int col = (curYear ? SM_CUR_COL0 : SM_LAST_COL0) + 2 * (m - 1);
+            Integer col = monthCols.get(m);
+            if (col == null) continue;
             s += smNum(row.getCell(col));
         }
         return s;
+    }
+
+    /** 鍙拌处椤点�屾煇骞� M 鏈堛�嶇殑 0 鍩哄垪鍙凤細鎸夎〃澶村姩鎬佸畾浣嶏紙涓嶅啀鍐欐 C/V锛夛紝鍏煎鏂囨湰 / 鏃ユ湡搴忓垪鍙� / 鏃ユ湡鏍煎紡涓夌鍐欐硶 */
+    private Map<Integer, Integer> smMonthCols(Sheet sh, int wantedYear) {
+        Map<Integer, Integer> cols = new LinkedHashMap<>();
+        if (sh == null) return cols;
+        int lastRow = Math.min(sh.getLastRowNum(), sh.getFirstRowNum() + 4);
+        for (int r = sh.getFirstRowNum(); r <= lastRow; r++) {
+            Row row = sh.getRow(r);
+            if (row == null) continue;
+            int lastCol = row.getLastCellNum();
+            for (int c = row.getFirstCellNum(); c >= 0 && c < lastCol; c++) {
+                Integer ym = smYearMonthOf(row.getCell(c));
+                if (ym == null || ym / 100 != wantedYear) continue;
+                int mo = ym % 100;
+                if (!cols.containsKey(mo)) cols.put(mo, c);
+            }
+        }
+        if (cols.isEmpty()) {
+            log.warn("姹囨�诲ぇ琛ㄥ洓椤靛埛鏂帮細{} 椤垫湭璇嗗埆鍒� {} 骞寸殑鏈堜唤琛ㄥご鍒楋紙鏀寔鏂囨湰/搴忓垪鍙�/鏃ユ湡涓夌褰㈠紡锛�", sh.getSheetName(), wantedYear);
+        }
+        return cols;
+    }
+
+    /** 琛ㄥご鍗曞厓鏍� -> 骞�*100+鏈堬紱鍙銆孻YYY骞碝鏈堛�嶆枃鏈垨銆屾煇鏈� 1 鍙枫�嶇殑鏃ユ湡搴忓垪鍙凤紝鎺掗櫎绱/鍚屾瘮/鐜瘮 */
+    private Integer smYearMonthOf(Cell c) {
+        if (c == null) return null;
+        try {
+            CellType t = c.getCellType();
+            if (t == CellType.STRING) {
+                String v = c.getStringCellValue();
+                if (v == null) return null;
+                v = v.trim();
+                if (v.isEmpty() || v.contains("绱") || v.contains("鍚屾瘮") || v.contains("鐜瘮")) return null;
+                java.util.regex.Matcher m = java.util.regex.Pattern
+                        .compile("^(\\d{4})\\s*骞碶\s*(\\d{1,2})\\s*鏈�$").matcher(v);
+                if (!m.matches()) return null;
+                int y = Integer.parseInt(m.group(1));
+                int mo = Integer.parseInt(m.group(2));
+                return mo >= 1 && mo <= 12 ? y * 100 + mo : null;
+            }
+            if (t == CellType.NUMERIC) {
+                double v = c.getNumericCellValue();
+                if (v < 36526 || v > 73415) return null;
+                java.util.Calendar cal = java.util.Calendar.getInstance();
+                cal.setTime(org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v));
+                if (cal.get(java.util.Calendar.DAY_OF_MONTH) != 1) return null;
+                return cal.get(java.util.Calendar.YEAR) * 100 + (cal.get(java.util.Calendar.MONTH) + 1);
+            }
+        } catch (Exception ignore) {
+            return null;
+        }
+        return null;
     }
 
     /** 鍙拌处椤靛崟鍏冩牸鍙栨暟锛堟暟瀛� / 鏁板瓧鏂囨湰 / 鍏紡缂撳瓨锛� */
@@ -4276,8 +4621,8 @@
         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 + "鏈堟儏鍐�");
+        smSetTitle(sh, 44, 0, "鍩庡競瀹㈣繍瀹㈣繍閲忓垎甯傚窞" + month + "鏈堟儏鍐�");   // 0鍩�44 = Excel 绗�45琛岋紙姣嶇増閲屾爣棰樺湪 A45锛孉46 鏄�"鍗曚綅"琛岋級
+        smSetTitle(sh, 66, 0, "鍩庡競瀹㈣繍瀹㈣繍鍛ㄨ浆閲忓垎甯傚窞" + month + "鏈堟儏鍐�");   // 0鍩�66 = Excel 绗�67琛岋紙鍚屼笂锛�
         int n = 0;
         n += smWriteCityByRegionBlock(sh, 4, true, curCum, lastCum);
         n += smWriteCityByRegionBlock(sh, 26, false, curCum, lastCum);
@@ -5428,6 +5773,7 @@
                 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;
             }
@@ -5440,6 +5786,7 @@
                     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;
@@ -5468,6 +5815,7 @@
                     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;
             }
@@ -5513,6 +5861,24 @@
         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();
+        }
     }
 
 
@@ -5623,6 +5989,8 @@
             String curCity = null;
             Set<Long> used = new HashSet<>();
             int filled = 0, kept = 0, dup = 0;
+            List<String> skipped = new ArrayList<>();
+            Map<String, Integer> skippedByCity = new LinkedHashMap<>();
             for (int r = 8; r <= sheet.getLastRowNum(); r++) {
                 Row row = sheet.getRow(r);
                 if (row == null) continue;
@@ -5634,11 +6002,16 @@
                 if (!a.matches("\\d+(\\.\\d+)?")) continue;
                 if (b.isEmpty() || curCity == null) continue;
                 InvestmentProject p = matchInvest(projects, b, curCity, "瀹㈣繍绔欏満", used);
+                p = preferMonthlyCandidate(projects, p, mByProject, "瀹㈣繍绔欏満", used);
                 InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
                 if (p == null || m == null) {
                     kept++;
                     // 鏈夊簱鍐呴」鐩絾褰撴湀鏃犳暟鎹� 鈫� 璁颁负寰呮牳瀵癸紱鏃犲尮閰嶉」鐩� 鈫� 缁撹浆涓婃湀
                     if (p != null) dup++;
+                    // 2026-09-22锛氫笉鍐嶉潤榛樼粨杞笂鏈堬紝鏈鐩栫殑琛屽繀椤荤暀鐥曪紙琛屽彿+甯傚窞+椤圭洰鍚�+鍘熷洜锛�
+                    skipped.add("绗�" + (r + 1) + "琛� " + curCity + " " + b
+                            + (p == null ? " 鈫� 搴撳唴鏃犲尮閰嶉」鐩�" : " 鈫� 搴撳唴鏃� " + period + " 鏈堝害鏁版嵁"));
+                    skippedByCity.merge(curCity, 1, Integer::sum);
                     continue;
                 }
                 used.add(p.getId());
@@ -5668,6 +6041,7 @@
             }
             log.info("瀹㈣繍绔欐姇璧勬槑缁嗚〃 {}锛氬啓鍏� {} 琛岋紝缁撹浆姣嶇増鍘熷�� {} 琛岋紙鍏朵腑搴撳唴鏈夐」鐩絾褰撴湀鏃犳暟鎹� {} 琛岋級",
                     period, filled, kept, dup);
+            logSkippedRows("瀹㈣繍绔欐姇璧勬槑缁嗚〃", period, skipped, skippedByCity);
             return toBytes(wb);
         }
     }
@@ -5708,6 +6082,8 @@
         Map<String, double[]> cityStats = new LinkedHashMap<>();  // 鍏ㄥ悕 -> {鎬绘暟, 绱, 瑙勫垝鍐呮暟, 瑙勫垝鍐呯疮璁�, 瑙勫垝澶栨暟, 瑙勫垝澶栫疮璁
         Set<Long> used = new HashSet<>();
         int filled = 0, kept = 0;
+        List<String> skipped = new ArrayList<>();
+        Map<String, Integer> skippedByCity = new LinkedHashMap<>();
         for (int i = 0; i < cityRows.size(); i++) {
             int cr = cityRows.get(i);
             String cityLabel = nvl(cellText(sheet.getRow(cr).getCell(0)));
@@ -5730,8 +6106,16 @@
                 if (!a.matches("\\d+(\\.\\d+)?")) continue;
                 if (b.isEmpty()) continue;
                 InvestmentProject p = matchInvest(projects, b, city, "鐗╂祦鍥尯", used);
+                p = preferMonthlyCandidate(projects, p, mByProject, "鐗╂祦鍥尯", used);
                 InvestmentMonthly m = p == null ? null : mByProject.get(p.getId());
-                if (p == null || m == null) { kept++; continue; }
+                if (p == null || m == null) {
+                    kept++;
+                    // 2026-09-22锛氫笉鍐嶉潤榛樼粨杞笂鏈堬紝鏈鐩栫殑琛屽繀椤荤暀鐥�
+                    skipped.add("绗�" + (r + 1) + "琛� " + city + " " + b
+                            + (p == null ? " 鈫� 搴撳唴鏃犲尮閰嶉」鐩�" : " 鈫� 搴撳唴鏃� " + period + " 鏈堝害鏁版嵁"));
+                    skippedByCity.merge(city, 1, Integer::sum);
+                    continue;
+                }
                 used.add(p.getId());
                 // 鍙e緞鍚屽杩愮珯鏄庣粏琛細鍙鐩� H 鑷紑濮嬪缓璁剧疮璁� / J 鑷勾鍒濈疮璁� / K 鏈湀瀹屾垚 / M 褰㈣薄杩涘害
                 if (m.getStartCum() != null) putNum(row, 7, m.getStartCum());
@@ -5862,6 +6246,7 @@
             log.warn("鐗╂祦绔欏満鎶曡祫鏄庣粏鍏紡姹傚�煎け璐�: {}", e.getMessage());
         }
         log.info("鐗╂祦绔欏満鎶曡祫鏄庣粏 {}锛氬啓鍏� {} 琛岋紝缁撹浆姣嶇増鍘熷�� {} 琛�", period, filled, kept);
+        logSkippedRows("鐗╂祦绔欏満鎶曡祫鏄庣粏", period, skipped, skippedByCity);
         return toBytesHssf(wb);
     }
 
@@ -6156,6 +6541,40 @@
         return full != null && RegionUtil.cityList().contains(full);
     }
 
+    /**
+     * 鍚屽悕锛堝綊涓�鍖栧悗涓�鑷达級澶氭。鏃讹紝浼樺厛閫夈�屽綋鏈堟湁鏁版嵁銆嶇殑閭d竴妗c��
+     * 鑳屾櫙锛�2026-09-22锛夛細鍗佸牥銆屽揩閫掔墿娴佷骇涓氬洯浜屾湡銆嶅湪搴撳唴鏈変袱妗� id=154锛堢宸� 2026锛屾湀搴﹀彧鍒� 7 鏈堬級涓�
+     * id=181锛堢宸� 2029锛屾湁 8 鏈堬級锛屾ā鏉胯鍚嶅墠缂�涓� 154 涓�鑷� 鈫� 瀵煎嚭鍙栧埌 154 鐨� 7 鏈堝�笺��
+     */
+    private InvestmentProject preferMonthlyCandidate(List<InvestmentProject> pool, InvestmentProject p,
+                                                    Map<Long, InvestmentMonthly> mByProject,
+                                                    String category, Set<Long> excludeIds) {
+        if (p == null || mByProject.containsKey(p.getId())) return p;
+        String norm = normInvestName(p.getProjectName());
+        if (norm.isEmpty()) return p;
+        InvestmentProject better = null;
+        for (InvestmentProject c : pool) {
+            if (c == null || c.getId().equals(p.getId())) continue;
+            if (excludeIds != null && excludeIds.contains(c.getId())) continue;
+            if (category != null && !category.equals(c.getCategory())) continue;
+            if (!mByProject.containsKey(c.getId())) continue;
+            if (!norm.equals(normInvestName(c.getProjectName()))) continue;
+            if (better == null || c.getId() > better.getId()) better = c;
+        }
+        return better != null ? better : p;
+    }
+
+    /** 鏈寜褰撴湀鏁版嵁瑕嗙洊鐨勮蹇呴』鐣欑棔锛氶�愯鍒楀嚭琛屽彿+甯傚窞+椤圭洰鍚嶏紝閬垮厤銆岄潤榛樼粨杞笂鏈熷�笺�嶅啀娆″彂鐢� */
+    private void logSkippedRows(String reportName, String period, List<String> skipped, Map<String, Integer> byCity) {
+        if (skipped == null || skipped.isEmpty()) return;
+        log.warn("{} {}锛歿} 琛屾湭鎸夊綋鏈堟暟鎹鐩栵紙缁撹浆姣嶇増鍘熷�硷級锛岄渶浜哄伐鏍稿婧愭枃浠�/搴撳唴鏁版嵁锛�",
+                reportName, period, skipped.size());
+        int limit = Math.min(skipped.size(), 50);
+        for (int i = 0; i < limit; i++) log.warn("  [鏈鐩朷 {}", skipped.get(i));
+        if (skipped.size() > limit) log.warn("  鈥﹀彟鏈� {} 琛屾湭閫愯鍒楀嚭", skipped.size() - limit);
+        if (byCity != null && !byCity.isEmpty()) log.warn("  鏈鐩栬鎸夊競宸炵粺璁★細{}", byCity);
+    }
+
     /** 鍦ㄥ�欓�夐」鐩睜涓寜鍚嶇О鍖归厤 DB 椤圭洰锛堝彲閫� 甯傚窞/绫诲埆 杩囨护 + 鍘垮尯鍓嶇紑娑堟锛夛紝鏈懡涓繑鍥� null */
     private InvestmentProject matchInvest(List<InvestmentProject> pool, String name, String cityFull, String category) {
         return matchInvest(pool, name, cityFull, category, null);
@@ -6204,7 +6623,8 @@
         if (name == null) return "";
         String s = name.trim();
         s = s.replaceAll("[锛�(]", "").replaceAll("[锛�)]", "");
-        s = s.replaceAll("[路鈥⑩�旓紞_\\-]", "");
+        // 2026-09-22锛氳ˉ鍏呭崐/鍏ㄨ鍙ョ偣銆侀】鍙枫�佸垎鍙风瓑锛堝簱鍐呫�屼腑鍥戒緵閿�.鍏畨鍟嗚锤鐗╂祦鍥�嶅洜鍗婅鍙ョ偣鍖归厤涓嶄笂妯℃澘琛岋級
+        s = s.replaceAll("[路鈥⑩�旓紞_\\-锛�.銆傘�侊紝,;锛�:锛歖", "");
         s = s.replaceAll("[鈪犫叀鈪⑩叄鈪も叆鈪︹収鈪ㄢ叐鈪扳叡鈪测叧鈪粹叺鈪垛叿鈪糕吂]", "I");
         s = s.replaceAll("^(婀栧寳鐪亅姝︽眽甯倈榛勭煶甯倈鍗佸牥甯倈瀹滄槍甯倈瑗勯槼甯倈閯傚窞甯倈鑽嗛棬甯倈瀛濇劅甯倈鑽嗗窞甯倈榛勫唸甯倈鍜稿畞甯倈闅忓窞甯倈鎭╂柦宸瀨浠欐甯倈娼滄睙甯倈澶╅棬甯倈绁炲啘鏋舵灄鍖�)", "");
         s = s.replaceAll("^(?:[\\u4e00-\\u9fa5]{2,4}(?:鍘縷甯倈鍖�))", "");

--
Gitblit v1.9.1