From 259db33b22095891f436b9ed5a81a21496b11c4c Mon Sep 17 00:00:00 2001
From: zhizhijie <zhizhijie@users.noreply.gitee.com>
Date: 星期一, 12 十月 2026 23:41:55 +0800
Subject: [PATCH] feat(汇总大表): 缺数清单加导出前弹窗并在生成件附《数据缺失清单》页(待验收)

---
 traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java |  453 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 453 insertions(+), 0 deletions(-)

diff --git a/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java b/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java
new file mode 100644
index 0000000..2d5e89e
--- /dev/null
+++ b/traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java
@@ -0,0 +1,453 @@
+package com.trafficaudit.reportexport.calc;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.trafficaudit.common.util.RegionUtil;
+import com.trafficaudit.dataimport.entity.CityBusMonthly;
+import com.trafficaudit.dataimport.entity.CityTaxiMonthly;
+import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
+import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
+import com.trafficaudit.dataimport.entity.PassengerEnterpriseMonthly;
+import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
+import com.trafficaudit.dataimport.entity.WycOrderMonthly;
+import com.trafficaudit.dataimport.entity.WycTotalMonthly;
+import com.trafficaudit.dataimport.mapper.CityBusMonthlyMapper;
+import com.trafficaudit.dataimport.mapper.CityTaxiMonthlyMapper;
+import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
+import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
+import com.trafficaudit.dataimport.mapper.PassengerEnterpriseMonthlyMapper;
+import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
+import com.trafficaudit.dataimport.mapper.WycOrderMonthlyMapper;
+import com.trafficaudit.dataimport.mapper.WycTotalMonthlyMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.openxml4j.util.ZipSecureFile;
+import org.apache.poi.ss.usermodel.Cell;
+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.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 姹囨�诲ぇ琛ㄣ�岀己鏁版竻鍗曘�嶏紙鍙锛�2026-10-12 鏂板锛岀敤鎴疯姹傦級銆�
+ *
+ * <p>涓庛�婅揣杩愬熀鏁颁綋妫�銆媨@link FreightReadinessCheck} 浜掕ˉ銆佸繀椤诲垎寮�锛�
+ * 閭d釜鏌ャ�屼笉鍙兘鐨勬暟銆嶏紙鍊掑噺涓鸿礋銆佄e競宸炩墵鐪佽鈥︼級锛屾湰绫绘煡銆岃鏈夌殑鏁版病鏈夈�嶃��
+ * 2026-08 浣撴 0 鍛戒腑銆佷絾 2026-09 鍑鸿〃鏃� 8 鏈堝垪浠嶄负绌� 鈥斺�� 灏辨槸鍙湁鍓嶈�呫�佹病鏈夊悗鑰呯殑鍚庢灉銆�
+ *
+ * <p>妫�鏌ラ」锛�
+ * <ol>
+ *   <li>绋嬪簭閫愭湀浠庡簱鍥炲~鐨勯〉锛堢彮绾垮寘杞︺�佸叕浜ゃ�佸嚭绉熴�佽建閬撹疆娓★級锛�1..鐩爣鏈� 閫愭湀鏌ユ潵婧愭槸鍚︽湁鏁般��17 甯傚窞鏄惁榻愬叏锛�</li>
+ *   <li>绋嬪簭鍙洖濉洰鏍囨湀鐨勯〉锛堣揣杩愩�佺綉绾﹁溅锛夛細鐩爣鏈堜笁椤规潵婧愭槸鍚﹂綈鍏紱</li>
+ *   <li>涓婅堪涓ら〉鐨� 1..鐩爣鏈�-1 鐢辨瘝鐗堟壙鎷咃細姣嶇増璇ユ湀鍊煎垪鍏ㄧ┖鍒欏嚭琛ㄥ嵆缂猴紙搴撻噷鏈夋暟銆佺▼搴忎笉鍥炲~锛夈��</li>
+ * </ol>
+ *
+ * <p>鏈被鍙銆佷笉鎶涘紓甯革紙寮傚父杞垚娓呭崟鏂囧瓧锛夛紝渚涘鍑烘棩蹇� / 寮圭獥 / 鐢熸垚浠躲�婃暟鎹己澶辨竻鍗曘�嬮〉浣跨敤銆�
+ */
+@Slf4j
+@Service
+public class SummaryMissingDataCheck {
+
+    @Resource
+    private PassengerEnterpriseMonthlyMapper h2031Mapper;      // 鐝嚎鍖呰溅锛堝叕璺梾瀹紒涓氭湀鎶ワ級
+    @Resource
+    private CityBusMonthlyMapper cityBusMapper;                // 鍏氦 + 杞ㄩ亾/杞浮
+    @Resource
+    private CityTaxiMonthlyMapper cityTaxiMapper;              // 鍑虹锛堢綉绾﹁溅鎷嗗垎涔熺敤瀹冪殑骞冲潎杩愯窛锛�
+    @Resource
+    private H2032EnterpriseMonthlyMapper h2032Mapper;          // 璐ц繍瑙勪笂锛堚憼锛�
+    @Resource
+    private FreightTurnoverImportMapper freightTurnoverMapper; // 璐ц繍鍏ㄥ彛寰勶紙鈶★級
+    @Resource
+    private ScaleSplitTransportMapper scaleSplitMapper;        // 瑙勪笂瑙勪笅鎷嗗垎锛堚憿锛�
+    @Resource
+    private WycOrderMonthlyMapper wycOrderMapper;              // 缃戠害杞﹁鍗� + 鍏ㄧ渷 pin
+    @Resource
+    private WycTotalMonthlyMapper wycTotalMapper;              // 缃戠害杞� pin 涓撹〃锛堝厹搴曪級
+
+    @Value("${summary.template-dir:docs/鐢熸垚姹囨�诲ぇ琛▆")
+    private String summaryTemplateDir;
+
+    /** 绋嬪簭鍙洖濉�岀洰鏍囨湀銆嶇殑椤� 鈥斺�� 鏇存棭鏈堜唤鐢辨瘝鐗堟壙鎷� */
+    private static final String[] TARGET_ONLY_PAGES = {" 璐ц繍", "缃戠害杞�"};
+
+    public List<String> check(String period) {
+        List<String> issues = new ArrayList<>();
+        if (period == null || period.length() < 7) return issues;
+        int year;
+        int month;
+        try {
+            year = Integer.parseInt(period.substring(0, 4));
+            month = Integer.parseInt(period.substring(5, 7));
+        } catch (Exception e) {
+            return issues;
+        }
+        // 1) 閫愭湀鍥炲~椤碉細1..鐩爣鏈�
+        for (int m = 1; m <= month; m++) {
+            String p = String.format("%04d-%02d", year, m);
+            checkH2031Month(p, issues);
+            checkCityBusMonth(p, issues);
+            checkRailFerryMonth(p, issues);
+            checkCityTaxiMonth(p, issues);
+        }
+        // 2) 鍙洖濉洰鏍囨湀鐨勪袱椤碉細鐩爣鏈堟潵婧�
+        String target = String.format("%04d-%02d", year, month);
+        checkFreightTarget(target, issues);
+        checkWycTarget(target, issues);
+        // 3) 杩欎袱椤电殑鍘嗗彶鏈堬細姣嶇増鏄惁涓虹┖
+        checkMotherHistory(target, year, month, issues);
+        if (!issues.isEmpty()) log.warn("缂烘暟娓呭崟锛坽}锛夛細鍛戒腑 {} 鏉�", period, issues.size());
+        return issues;
+    }
+
+    // ---------- 鍚勯〉妫�鏌� ----------
+
+    private void checkH2031Month(String p, List<String> issues) {
+        try {
+            List<PassengerEnterpriseMonthly> rows = h2031Mapper.selectList(
+                new LambdaQueryWrapper<PassengerEnterpriseMonthly>().eq(PassengerEnterpriseMonthly::getReportPeriod, p));
+            if (rows.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氱彮绾垮寘杞� %s 鏃犮�婂叕璺梾瀹紒涓氭湀鎶ャ�嬫潵婧� 鈫� 璇ユ湀瀹㈣繍閲�/鍛ㄨ浆閲忋�佷腑鍙e緞銆屽叕璺彮绾裤�嶅皢涓虹┖", p));
+                return;
+            }
+            Set<String> got = new LinkedHashSet<>();
+            for (PassengerEnterpriseMonthly r : rows) {
+                String c = RegionUtil.cityByCode(r.getRegionCode());
+                if (c != null) got.add(c);
+            }
+            addMissingCities("鐝嚎鍖呰溅", p, got, issues);
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細鐝嚎鍖呰溅 " + p + " :: " + e.getMessage());
+        }
+    }
+
+    private void checkCityBusMonth(String p, List<String> issues) {
+        try {
+            List<CityBusMonthly> rows = cityBusMapper.selectList(
+                new LambdaQueryWrapper<CityBusMonthly>().eq(CityBusMonthly::getReportPeriod, p));
+            if (rows.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氬叕浜� %s 鏃犮�婂煄甯傚叕浜ゆ湀鎶ャ�嬫潵婧� 鈫� 鍏氦椤典负绌恒�佸煄甯傚杩愰〉灏嗙己鍏氦閮ㄥ垎", p));
+                return;
+            }
+            addMissingCities("鍏氦", p, citySetOf(rows), issues);
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細鍏氦 " + p + " :: " + e.getMessage());
+        }
+    }
+
+    private void checkRailFerryMonth(String p, List<String> issues) {
+        try {
+            List<CityBusMonthly> rows = cityBusMapper.selectList(
+                new LambdaQueryWrapper<CityBusMonthly>().eq(CityBusMonthly::getReportPeriod, p));
+            if (rows.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氳建閬撱�佽疆娓� %s 鏃犮�婂煄甯傚叕浜ゆ湀鎶ャ�嬫潵婧愶紙杞ㄩ亾/杞浮鍚岃〃锛夆啋 璇ラ〉涓虹┖", p));
+                return;
+            }
+            boolean rail = false;
+            boolean ferry = false;
+            for (CityBusMonthly r : rows) {
+                if (r.getRailPassengerVolume() != null) rail = true;
+                if (r.getFerryPassengerVolume() != null) ferry = true;
+            }
+            List<String> miss = new ArrayList<>();
+            if (!rail) miss.add("杞ㄩ亾");
+            if (!ferry) miss.add("杞浮");
+            if (!miss.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氳建閬撱�佽疆娓� %s 鐨� %s 瀛楁鍏ㄧ┖ 鈫� 璇ュ潡灏嗕负绌�",
+                    p, String.join("銆�", miss)));
+            }
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細杞ㄩ亾銆佽疆娓� " + p + " :: " + e.getMessage());
+        }
+    }
+
+    private void checkCityTaxiMonth(String p, List<String> issues) {
+        try {
+            List<CityTaxiMonthly> rows = cityTaxiMapper.selectList(
+                new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, p));
+            if (rows.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氬嚭绉� %s 鏃犮�婂贰娓稿嚭绉熸苯杞︽湀鎶ャ�嬫潵婧� 鈫� 鍑虹椤典负绌猴紝涓旂綉绾﹁溅鎷嗗垎鍙栦笉鍒板钩鍧囪繍璺�", p));
+                return;
+            }
+            Set<String> got = new LinkedHashSet<>();
+            for (CityTaxiMonthly r : rows) {
+                String c = RegionUtil.normalizeCityName(r.getCity());
+                if (c != null && !c.isEmpty()) got.add(c);
+            }
+            addMissingCities("鍑虹", p, got, issues);
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細鍑虹 " + p + " :: " + e.getMessage());
+        }
+    }
+
+    private void checkFreightTarget(String p, List<String> issues) {
+        try {
+            // 鈶� H203-2 浼佷笟鏈堟姤锛堣涓婅揣杩愰噺/鍛ㄨ浆閲忥級
+            List<H2032EnterpriseMonthly> h = h2032Mapper.selectList(
+                new LambdaQueryWrapper<H2032EnterpriseMonthly>().eq(H2032EnterpriseMonthly::getReportPeriod, p));
+            if (h.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氳揣杩� %s 鏃犫憼銆奌203-2 閬撹矾璐х墿杩愯緭鏈堝害鐢熶骇鎯呭喌銆嬧啋 瑙勪笂璐ц繍閲�/鍛ㄨ浆閲忓彇涓嶅埌", p));
+            } else {
+                Set<String> got = new LinkedHashSet<>();
+                for (H2032EnterpriseMonthly r : h) {
+                    String c = RegionUtil.cityByCode(r.getRegionCode());
+                    if (c != null) got.add(c);
+                }
+                addMissingCities("璐ц繍鈶犺涓�", p, got, issues);
+            }
+            // 鈶� 璐ц繍閲忓懆杞噺琛紙鍚堣 = 瑙勪笂+瑙勪笅锛�
+            List<FreightTurnoverImport> f = freightTurnoverMapper.selectList(
+                new LambdaQueryWrapper<FreightTurnoverImport>().eq(FreightTurnoverImport::getReportPeriod, p));
+            if (f.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氳揣杩� %s 鏃犫憽銆婅揣杩愰噺鍛ㄨ浆閲忚〃銆嬧啋 鍚堣璐ц繍閲�/鍛ㄨ浆閲忋�佽涓嬶紙鍊掑噺锛夊叏閮ㄥ彇涓嶅埌", p));
+            } else {
+                Set<String> got = new LinkedHashSet<>();
+                for (FreightTurnoverImport r : f) {
+                    String c = RegionUtil.normalizeCityName(r.getRegionName());
+                    if (c != null && !RegionUtil.cityList().contains(c) && !"婀栧寳鐪�".equals(c)) continue;
+                    if (c != null && RegionUtil.cityList().contains(c)) got.add(c);
+                }
+                addMissingCities("璐ц繍鈶″叏鍙e緞", p, got, issues);
+            }
+            // 鈶� 鎷嗗垎琛紙瑙勪笂/鍚堣鍛ㄨ浆閲忥級锛屽綋鏈� + 绱
+            List<ScaleSplitTransport> s = scaleSplitMapper.selectList(
+                new LambdaQueryWrapper<ScaleSplitTransport>().eq(ScaleSplitTransport::getReportPeriod, p));
+            if (s.isEmpty()) {
+                issues.add(String.format("缂烘暟锛氳揣杩� %s 鏃犫憿銆婅涓婅涓嬫媶鍒嗚〃銆嬧啋 瑙勪笂/鍚堣鍛ㄨ浆閲忓彇涓嶅埌锛堣鏈堝懆杞噺灏嗕负绌�/0锛�", p));
+            } else {
+                Set<String> month = new LinkedHashSet<>();
+                Set<String> cum = new LinkedHashSet<>();
+                for (ScaleSplitTransport r : s) {
+                    String c = RegionUtil.normalizeCityName(r.getRegionName());
+                    if (c == null || !RegionUtil.cityList().contains(c)) continue;
+                    if ("MONTH".equals(r.getPeriodType())) month.add(c);
+                    else if ("CUMULATIVE".equals(r.getPeriodType())) cum.add(c);
+                }
+                addMissingCities("璐ц繍鈶㈡媶鍒�(褰撴湀)", p, month, issues);
+                addMissingCities("璐ц繍鈶㈡媶鍒�(绱)", p, cum, issues);
+            }
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細璐ц繍 " + p + " :: " + e.getMessage());
+        }
+    }
+
+    private void checkWycTarget(String p, List<String> issues) {
+        try {
+            List<WycOrderMonthly> orders = wycOrderMapper.selectList(
+                new LambdaQueryWrapper<WycOrderMonthly>().eq(WycOrderMonthly::getReportPeriod, p));
+            boolean okOrders = false;
+            boolean okPin = false;
+            if (!orders.isEmpty()) {
+                WycOrderMonthly o = orders.get(0);
+                double sum = nz(o.getOrderWuhan()) + nz(o.getOrderHuangshi()) + nz(o.getOrderShiyan()) + nz(o.getOrderYichang())
+                    + nz(o.getOrderXiangyang()) + nz(o.getOrderEzhou()) + nz(o.getOrderJingmen()) + nz(o.getOrderXiaogan())
+                    + nz(o.getOrderJingzhou()) + nz(o.getOrderHuanggang()) + nz(o.getOrderXianning()) + nz(o.getOrderSuizhou())
+                    + nz(o.getOrderEnshi()) + nz(o.getOrderXiantao()) + nz(o.getOrderQianjiang()) + nz(o.getOrderTianmen())
+                    + nz(o.getOrderShennong());
+                okOrders = sum > 0;
+                okPin = nz(o.getPinK()) > 0 && nz(o.getPinCK()) > 0 && nz(o.getPinZ()) > 0 && nz(o.getPinCZ()) > 0;
+            }
+            if (!okOrders) {
+                issues.add(String.format("缂烘暟锛氱綉绾﹁溅 %s 鏃犺鍗曟暟鎹紙wyc_order_monthly 17 甯傚窞璁㈠崟锛夆啋 鎷嗗垎鏃犳硶璁$畻锛岃椤靛皢涓虹┖", p));
+            }
+            if (!okPin) {
+                List<WycTotalMonthly> tot = wycTotalMapper.selectList(
+                    new LambdaQueryWrapper<WycTotalMonthly>().eq(WycTotalMonthly::getReportPeriod, p));
+                boolean okTot = !tot.isEmpty()
+                    && nz(tot.get(0).getPinK()) > 0 && nz(tot.get(0).getPinCK()) > 0
+                    && nz(tot.get(0).getPinZ()) > 0 && nz(tot.get(0).getPinCZ()) > 0;
+                if (!okTot) {
+                    issues.add(String.format("缂烘暟锛氱綉绾﹁溅 %s 缂哄叏鐪� pin锛坥rder 琛ㄤ笌 wyc_total_monthly 閮芥病鏈夛級鈫� 鎷嗗垎鏃犳硶璁$畻", p));
+                }
+            }
+            List<CityTaxiMonthly> taxi = cityTaxiMapper.selectList(
+                new LambdaQueryWrapper<CityTaxiMonthly>().eq(CityTaxiMonthly::getReportPeriod, p));
+            Set<String> got = new LinkedHashSet<>();
+            for (CityTaxiMonthly r : taxi) {
+                String c = RegionUtil.normalizeCityName(r.getCity());
+                if (c != null && !c.isEmpty()) got.add(c);
+            }
+            addMissingCities("缃戠害杞�(渚濊禆鍑虹骞冲潎杩愯窛)", p, got, issues);
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細缃戠害杞� " + p + " :: " + e.getMessage());
+        }
+    }
+
+    /** 璐ц繍/缃戠害杞︾殑鍘嗗彶鏈堬紙1..M-1锛夌敱姣嶇増鎵挎媴锛堟瘝鐗堟病鏈夋椂鎸夈�屽悓鏈熷浠芥瘝鐗堛�嶈繕鍘燂級锛�
+     *  鍙湁姣嶇増涓庡浠芥瘝鐗堥兘娌℃湁鏁帮紝鎵嶇畻鐪熸鐨勫嚭琛ㄧ己鍙o紙鍚﹀垯浼氳鎶� 7 鏈堥偅绉嶃�岄鏋舵瘝鐗� + donor 杩樺師銆嶇殑鎯呭喌锛夈�� */
+    private void checkMotherHistory(String period, int year, int month, List<String> issues) {
+        File mother = resolveMother(year, month);
+        if (mother == null) {
+            issues.add(String.format("缂烘暟锛氭湭鎵惧埌姣嶇増銆�%d骞�%d鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx銆嬶紙%s锛�", year, month, summaryTemplateDir));
+            return;
+        }
+        File donor = resolveDonor(year, month);
+        double saved = ZipSecureFile.getMinInflateRatio();
+        ZipSecureFile.setMinInflateRatio(0.0001);
+        try (InputStream in = new FileInputStream(mother);
+             Workbook wb = WorkbookFactory.create(in);
+             InputStream in2 = (donor != null) ? new FileInputStream(donor) : null;
+             Workbook dwb = (in2 != null) ? WorkbookFactory.create(in2) : null) {
+            for (String pageName : TARGET_ONLY_PAGES) {
+                List<String> blankMonths = new ArrayList<>();
+                for (int m = 1; m < month; m++) {
+                    if (monthHasData(wb, pageName, year, m)) continue;
+                    if (dwb != null && monthHasData(dwb, pageName, year, m)) continue;
+                    blankMonths.add(String.format("%d骞�%d鏈�", year, m));
+                }
+                if (!blankMonths.isEmpty()) {
+                    issues.add(String.format("缂烘暟锛�%s 姣嶇増锛�%s 鏈燂級鐨� %s 鍊煎垪鍏ㄧ┖%s锛岃�岀▼搴忓彧鍥炲~鐩爣鏈� 鈫� 鍑鸿〃璇ユ湀灏嗕负绌猴紙搴撻噷鍙兘鏈夋暟鎹級",
+                        pageName.trim(), period, String.join("銆�", blankMonths),
+                        donor == null ? "" : "锛堝悓鏈熷浠芥瘝鐗堜篃娌℃湁锛�"));
+                }
+            }
+        } catch (Exception e) {
+            issues.add("缂烘暟妫�鏌ュ紓甯革細璇绘瘝鐗� " + mother.getName() + " :: " + e.getMessage());
+        } finally {
+            ZipSecureFile.setMinInflateRatio(saved);
+        }
+    }
+
+    /** 璇ュ伐浣滅翱璇ラ〉銆佽骞磋鏈堢殑銆屽�煎垪銆嶆槸鍚﹀凡鏈夋暟鍊硷紙琛ㄥご琛屼箣澶栵級 */
+    private boolean monthHasData(Workbook wb, String pageName, int year, int m) {
+        Sheet sheet = findSheet(wb, pageName);
+        if (sheet == null) return false;
+        int[] ref = detectYearMonthCols(sheet, year).get(m);
+        if (ref == null) return false;
+        return countNumericInColumn(sheet, ref[1], ref[0]) > 0;
+    }
+
+    private Sheet findSheet(Workbook wb, String pageName) {
+        Sheet s = wb.getSheet(pageName);
+        if (s == null && " 璐ц繍".equals(pageName)) s = wb.getSheet("璐ц繍");
+        return s;
+    }
+
+    /** 鍚屾湡澶囦唤姣嶇増锛堟竻鐞嗗墠鐨勫畬鏁存湀姣嶇増锛夛紝鍛藉悕 _澶囦唤_{骞磢骞磠鏈坿鏈堚�﹂亾璺繍杈撻噺姹囨�昏〃 */
+    private File resolveDonor(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;
+    }
+
+    // ---------- 宸ュ叿 ----------
+
+    private Set<String> citySetOf(List<CityBusMonthly> rows) {
+        Set<String> got = new LinkedHashSet<>();
+        for (CityBusMonthly r : rows) {
+            String c = RegionUtil.normalizeCityName(r.getCity());
+            if (c != null && !c.isEmpty()) got.add(c);
+        }
+        return got;
+    }
+
+    private void addMissingCities(String page, String period, Set<String> got, List<String> issues) {
+        List<String> miss = new ArrayList<>();
+        for (String c : RegionUtil.cityList()) {
+            if (!got.contains(c)) miss.add(c);
+        }
+        if (!miss.isEmpty()) {
+            issues.add(String.format("缂烘暟锛�%s %s 缂哄競宸烇細%s", page, period, String.join("銆�", miss)));
+        }
+    }
+
+    /** 姣嶇増閲屽畾浣嶇洰鏍囧勾鍚勬湀銆屽�煎垪銆嶏細杩斿洖 鏈� -> [琛ㄥご琛�(0鍩�), 鍒�(0鍩�)]锛涘吋瀹规枃鏈��2026骞碝鏈堛�嶄笌鏃ユ湡搴忓垪鍙� */
+    private Map<Integer, int[]> detectYearMonthCols(Sheet sheet, int year) {
+        Map<Integer, int[]> out = new LinkedHashMap<>();
+        int lastRow = Math.min(5, sheet.getLastRowNum());
+        for (int r = 0; r <= lastRow; r++) {
+            Row row = sheet.getRow(r);
+            if (row == null) continue;
+            for (Cell c : row) {
+                Integer m = monthOfCell(c, year);
+                if (m != null && !out.containsKey(m)) {
+                    out.put(m, new int[]{r, c.getColumnIndex()});
+                }
+            }
+        }
+        return out;
+    }
+
+    private Integer monthOfCell(Cell c, int year) {
+        try {
+            if (c == null) return null;
+            if (c.getCellType() == CellType.STRING) {
+                String s = c.getStringCellValue();
+                if (s == null) return null;
+                java.util.regex.Matcher mt = java.util.regex.Pattern
+                    .compile("(20\\d\\d)\\s*骞碶\s*(\\d{1,2})\\s*鏈�").matcher(s);
+                if (mt.find()) {
+                    if (Integer.parseInt(mt.group(1)) != year) return null;
+                    int m = Integer.parseInt(mt.group(2));
+                    return (m >= 1 && m <= 12) ? m : null;
+                }
+                return null;
+            }
+            if (c.getCellType() == CellType.NUMERIC) {
+                double v = c.getNumericCellValue();
+                if (v < 20000 || v > 60000 || v != Math.floor(v)) return null;
+                java.util.Date d = org.apache.poi.ss.usermodel.DateUtil.getJavaDate(v);
+                java.util.Calendar cal = java.util.Calendar.getInstance();
+                cal.setTime(d);
+                if (cal.get(java.util.Calendar.YEAR) != year) return null;
+                return cal.get(java.util.Calendar.MONTH) + 1;
+            }
+        } catch (Exception ignore) {
+            // 闈炴湀浠借〃澶�
+        }
+        return null;
+    }
+
+    /** 缁熻鏌愬垪锛堣烦杩囪〃澶磋锛夐潪绌烘暟鍊兼牸鏁� */
+    private int countNumericInColumn(Sheet sheet, int col, int headerRow) {
+        int n = 0;
+        for (int r = 0; r <= sheet.getLastRowNum(); r++) {
+            if (r == headerRow) continue;
+            Row row = sheet.getRow(r);
+            if (row == null) continue;
+            Cell c = row.getCell(col);
+            if (c == null) continue;
+            if (c.getCellType() == CellType.NUMERIC || c.getCellType() == CellType.FORMULA) n++;
+        }
+        return n;
+    }
+
+    private File resolveMother(int year, int month) {
+        String rel = summaryTemplateDir;
+        while (rel.startsWith("./")) rel = rel.substring(2);
+        String name = year + "骞�" + month + "鏈堥亾璺繍杈撻噺姹囨�昏〃.xlsx";
+        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, name);
+            if (f.exists() && f.isFile()) return f;
+        }
+        return null;
+    }
+
+    private double nz(Double v) {
+        return v == null ? 0.0 : v;
+    }
+}
\ No newline at end of file

--
Gitblit v1.9.1