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 新增,用户要求)。
*
*
与《货运基数体检》{@link FreightReadinessCheck} 互补、必须分开:
* 那个查「不可能的数」(倒减为负、Σ市州≠省行…),本类查「该有的数没有」。
* 2026-08 体检 0 命中、但 2026-09 出表时 8 月列仍为空 —— 就是只有前者、没有后者的后果。
*
*
检查项:
*
* - 程序逐月从库回填的页(班线包车、公交、出租、轨道轮渡):1..目标月 逐月查来源是否有数、17 市州是否齐全;
* - 程序只回填目标月的页(货运、网约车):目标月三项来源是否齐全;
* - 上述两页的 1..目标月-1 由母版承担:母版该月值列全空则出表即缺(库里有数、程序不回填)。
*
*
* 本类只读、不抛异常(异常转成清单文字),供导出日志 / 弹窗 / 生成件《数据缺失清单》页使用。
*/
@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 check(String period) {
List 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 issues) {
try {
List rows = h2031Mapper.selectList(
new LambdaQueryWrapper().eq(PassengerEnterpriseMonthly::getReportPeriod, p));
if (rows.isEmpty()) {
issues.add(String.format("缺数:班线包车 %s 无《公路旅客企业月报》来源 → 该月客运量/周转量、中口径「公路班线」将为空", p));
return;
}
Set 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 issues) {
try {
List rows = cityBusMapper.selectList(
new LambdaQueryWrapper().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 issues) {
try {
List rows = cityBusMapper.selectList(
new LambdaQueryWrapper().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 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 issues) {
try {
List rows = cityTaxiMapper.selectList(
new LambdaQueryWrapper().eq(CityTaxiMonthly::getReportPeriod, p));
if (rows.isEmpty()) {
issues.add(String.format("缺数:出租 %s 无《巡游出租汽车月报》来源 → 出租页为空,且网约车拆分取不到平均运距", p));
return;
}
Set 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 issues) {
try {
// ① H203-2 企业月报(规上货运量/周转量)
List h = h2032Mapper.selectList(
new LambdaQueryWrapper().eq(H2032EnterpriseMonthly::getReportPeriod, p));
if (h.isEmpty()) {
issues.add(String.format("缺数:货运 %s 无①《H203-2 道路货物运输月度生产情况》→ 规上货运量/周转量取不到", p));
} else {
Set got = new LinkedHashSet<>();
for (H2032EnterpriseMonthly r : h) {
String c = RegionUtil.cityByCode(r.getRegionCode());
if (c != null) got.add(c);
}
addMissingCities("货运①规上", p, got, issues);
}
// ② 货运量周转量表(合计 = 规上+规下)
List f = freightTurnoverMapper.selectList(
new LambdaQueryWrapper().eq(FreightTurnoverImport::getReportPeriod, p));
if (f.isEmpty()) {
issues.add(String.format("缺数:货运 %s 无②《货运量周转量表》→ 合计货运量/周转量、规下(倒减)全部取不到", p));
} else {
Set 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 s = scaleSplitMapper.selectList(
new LambdaQueryWrapper().eq(ScaleSplitTransport::getReportPeriod, p));
if (s.isEmpty()) {
issues.add(String.format("缺数:货运 %s 无③《规上规下拆分表》→ 规上/合计周转量取不到(该月周转量将为空/0)", p));
} else {
Set month = new LinkedHashSet<>();
Set 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 issues) {
try {
List orders = wycOrderMapper.selectList(
new LambdaQueryWrapper().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 tot = wycTotalMapper.selectList(
new LambdaQueryWrapper().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 taxi = cityTaxiMapper.selectList(
new LambdaQueryWrapper().eq(CityTaxiMonthly::getReportPeriod, p));
Set 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 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 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 citySetOf(List rows) {
Set 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 got, List issues) {
List 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 detectYearMonthCols(Sheet sheet, int year) {
Map 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;
}
}