feat(汇总大表): 缺数清单可见且缺数期不沿用上一期旧值(排名页按源侧留空)(待验收)
1个文件已添加
3个文件已修改
548 ■■■■■ 已修改文件
HANDOFF.md 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java 453 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,20 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
### 10-12(12):汇总大表「缺数不留上一期旧值」(排名页按源侧留空)+ 缺数清单代码入库【待验收】
- **背景**:用户问"生成表里很多数据都是 8 月的吧";同时库里的缺数清单代码一直未提交(本机沙箱故障期间写的,沙箱恢复后补提交)。
- **口径改动(3 处,同一件事:缺数不留上一期旧值)**:
  1. `loadCumulativeMap` / `getProvinceCumulative`:③规上规下拆分表**只认目标期**(原实现允许"目标期及之前最近一期"→ 会静默拿上一期的数当本期);
  2. `copyRankValues`:源侧(独立排名表)缺数时**目标格留空**,不再沿用母版上一报表期的旧值(源侧文字标签不清);
  3. 排名块**整块缺数**(源侧一个数都没有,如 9 月缺②③时的《货运周转量排名》)→ 整块只留标题与市州名,避免母版自带 RANK/占比公式在空块上算出"排名全 1 + 占比 #DIV/0!"。
- **效果(2026-09 实测,产物 `docs\生成汇总大表\输出\生成_道路运输量汇总表_2026-09.xlsx`)**:
  - 《货运周转量排名》整块留空 —— 与用户日常核对的《生成_周转量排名》空态一致 ✓;
  - 《货运量排名》9 月块:规上=①实数(全省 **3575.1211**)、规下/合计留空(缺②);占比列沿用源侧公式(其中 72 个 #DIV/0! 是独立排名表本身就有的);
  - 无回归:公交 9 月客运量 **13476.35**、城市内 12021.00、出租客运量 5626.81 与库内 Σ17 市州逐位一致 ✓(其余页与上一版产物差异仅来自母版脏格清理与缓存刷新)。
- **同时补提交**(沙箱故障期间遗留,已按 10-12(4)报告验证过):`reportexport/calc/SummaryMissingDataCheck.java`(只读缺数清单)、`GET /api/report/summaryWorkbook/missing`、导出日志 `dbProblems` 接入(本次导出日志已可见"缺数提示=[…]")。
- **遗留/待确认**:① 城市分市州、道路运输周转量的 34 个 #DIV/0! 按 10-12(7)用户决定不动;② **排名页在缺数期整体留空是否符合预期,请验收时确认**(若要"保留上一期值"的口径,可回退第 2/3 条)。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_汇总大表缺数不留上一期旧值_2026-10-12.md`。状态:**【待验收】**。
### 10-12(11):扩列表头行二次修正 + 9 月母版被扩列写成日期的 13 格清理【待验收】
- **现象(用户报告)**:生成表里"很多数据是 8 月的"。排查发现 **9 月母版里有 13 个数据格被写成了 9 月月份日期序列 `46266`(= 2026-09-01)**:` 货运!S11/T11`、`班线包车!S9/T9`、`公交!S8/T8`、`出租!S6/T6`、`中口径明细!S5/T5`;下游缓存被带偏(`班线包车!S5`、`中口径明细!S99/S100/S104/S105` 等也是 46266)。
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/SummaryMissingDataCheck.java
New file
@@ -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} 互补、必须分开:
 * 那个查「不可能的数」(倒减为负、Σ市州≠省行…),本类查「该有的数没有」。
 * 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 无《公路旅客企业月报》来源 → 该月客运量/周转量、中口径「公路班线」将为空", 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 无①《H203-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("货运②全口径", 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(order 表与 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)由母版承担(母版没有时按「同期备份母版」还原):
     *  只有母版与备份母版都没有数,才算真正的出表缺口(否则会误报 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年M月」与日期序列号 */
    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;
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java
@@ -2,6 +2,7 @@
import com.trafficaudit.common.Result;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.calc.SummaryMissingDataCheck;
import com.trafficaudit.reportexport.service.ReportExportService;
import com.trafficaudit.reportexport.service.ReportExportTaskService;
import com.trafficaudit.system.service.OperationLogService;
@@ -29,6 +30,8 @@
    private ReportExportService reportService;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private SummaryMissingDataCheck missingDataCheck;
    @Resource
    private ReportExportTaskService exportTaskService;
    @Resource
@@ -98,6 +101,12 @@
        return Result.ok(freightReadinessCheck.check(period));
    }
    /** 汇总大表缺数清单(只读):列出目标期出表会缺的月份/市州/来源(与 freightCheck 互补)。 */
    @GetMapping("/summaryWorkbook/missing")
    public Result<java.util.List<String>> missingCheck(@RequestParam("period") String period) {
        return Result.ok(missingDataCheck.check(period));
    }
    /** 汇总大表按月扩列(Step 1,只做版式、数据留空):返回下一月版式 xlsx。 */
    @GetMapping("/export/summaryWorkbook/expand")
    public ResponseEntity<byte[]> expandSummaryWorkbook(@RequestParam("period") String period,
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/service/ReportExportService.java
@@ -1439,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;
@@ -1475,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;
@@ -3988,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,POI 的 setCellValue 只写 <v>、
                            // 不更新 <is>,Excel 会继续显示旧月份,必须先 setBlank 清掉再写。
@@ -4031,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++;
@@ -4050,6 +4080,34 @@
        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(SUM/RANK 等函数名后接括号不会被匹配) */
    private String shiftFormula(String formula, int dCol, int dRow) {
        if (formula == null || (dCol == 0 && dRow == 0)) return formula;