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;
|
}
|
}
|