package com.trafficaudit.reportexport.calc;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.trafficaudit.common.util.RegionUtil;
import com.trafficaudit.dataimport.entity.FreightTurnoverImport;
import com.trafficaudit.dataimport.entity.H2032EnterpriseMonthly;
import com.trafficaudit.dataimport.entity.ScaleSplitTransport;
import com.trafficaudit.dataimport.mapper.FreightTurnoverImportMapper;
import com.trafficaudit.dataimport.mapper.H2032EnterpriseMonthlyMapper;
import com.trafficaudit.dataimport.mapper.ScaleSplitTransportMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 货运口径「导出前基数体检」(B4,2026-10-12 新增 6 条规则)。
*
*
背景:规下 = 全口径(②《货运量周转量表》)− 规上(①H203-2 企业月报÷10000)——是**倒减**,
* 因此①漏报/缺报会让规下虚高,极端情况还会算出负数。本类把这些异常在导出前挑出来,
* 供日志/弹窗/《数据缺失清单》页使用(同一套结果也供生成主链的 dbProblems 复用)。
*
*
规则:
*
* - 某市州规下为负(货量或周转量);
* - ②该市州有全口径、①无规上记录 → 规下等于全口径,明显虚高;
* - ②全口径小于①规上(逻辑不可能);
* - Σ17 市州 ≠ ②的"湖北省"行(超容差 0.01);
* - 某市州全口径「本月与上月完全相同」(疑重复导入,两侧 ≥ 50 万吨/万吨公里);
* - 某市州全口径同比异常(|去年同月→本月| > 50% 且两侧 ≥ 50 万吨/万吨公里);
* - 累计勾稽:①周转量 1..M 月求和 ÷10000 与③《拆分表》CUMULATIVE.above_scale_turnover 不一致(超容差 0.01)。
*
*/
@Slf4j
@Service
public class FreightReadinessCheck {
private static final double TOL = 0.01;
private static final double YOY_RATIO = 0.50;
/** 环比检查的绝对门槛:两侧都要 ≥ 该值才判异常(避免神农架林区这类小基数噪声) */
private static final double CHAIN_FLOOR = 50.0;
@Resource
private H2032EnterpriseMonthlyMapper h2032Mapper;
@Resource
private FreightTurnoverImportMapper freightTurnoverMapper;
@Resource
private ScaleSplitTransportMapper scaleSplitMapper;
public List check(String period) {
List issues = new ArrayList<>();
if (period == null || period.length() < 7) return issues;
int year = Integer.parseInt(period.substring(0, 4));
int month = Integer.parseInt(period.substring(5, 7));
// ① 规上(吨)按市州+月份
Map> aboveFreight = new HashMap<>();
Map> aboveTurnover = new HashMap<>();
for (H2032EnterpriseMonthly rec : h2032Mapper.selectList(
new LambdaQueryWrapper()
.likeRight(H2032EnterpriseMonthly::getReportPeriod, year))) {
String rp = rec.getReportPeriod();
if (rp == null || rp.compareTo(period) > 0) continue;
String city = RegionUtil.cityByCode(rec.getRegionCode());
if (city == null) continue;
int m = Integer.parseInt(rp.substring(5, 7));
aboveFreight.computeIfAbsent(city, k -> new HashMap<>())
.merge(m, rec.getFreightTotal() == null ? 0.0 : rec.getFreightTotal(), Double::sum);
aboveTurnover.computeIfAbsent(city, k -> new HashMap<>())
.merge(m, rec.getTurnoverTotal() == null ? 0.0 : rec.getTurnoverTotal(), Double::sum);
}
// ② 全口径:市州行 + 湖北省行(当期)
Map ft = new HashMap<>();
FreightTurnoverImport province = null;
for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
new LambdaQueryWrapper()
.eq(FreightTurnoverImport::getReportPeriod, period))) {
String city = RegionUtil.normalizeCityName(e.getRegionName());
if (city == null) continue;
if (FreightCalc.PROVINCE.equals(city)) province = e;
else ft.put(city, e);
}
// 上月全口径(用于环比)
String prevPeriod = prevPeriod(period);
Map ftPrev = new HashMap<>();
for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
new LambdaQueryWrapper()
.eq(FreightTurnoverImport::getReportPeriod, prevPeriod))) {
String city = RegionUtil.normalizeCityName(e.getRegionName());
if (city != null && !FreightCalc.PROVINCE.equals(city)) ftPrev.put(city, e);
}
// ③ 拆分表:当月/累计 规上与合计周转量
Map splitCum = new HashMap<>();
for (ScaleSplitTransport rec : scaleSplitMapper.selectList(
new LambdaQueryWrapper()
.eq(ScaleSplitTransport::getReportPeriod, period))) {
String city = RegionUtil.normalizeCityName(rec.getRegionName());
if (city == null || FreightCalc.PROVINCE.equals(city)) continue;
if ("CUMULATIVE".equals(rec.getPeriodType())) splitCum.put(city, rec);
}
double sumFreight = 0.0, sumTurnover = 0.0;
for (String city : RegionUtil.cityList()) {
FreightTurnoverImport row = ft.get(city);
Double totalF = row == null ? null : monthField(row, month, true);
Double totalT = row == null ? null : monthField(row, month, false);
Double abF = aboveFreight.containsKey(city) ? aboveFreight.get(city).get(month) : null;
abF = abF == null ? null : abF / 10000.0;
Double abT = aboveTurnover.containsKey(city) && aboveTurnover.get(city).get(month) != null
? aboveTurnover.get(city).get(month) / 10000.0 : null;
if (totalF != null) sumFreight += totalF;
if (totalT != null) sumTurnover += totalT;
if (totalF == null && abF == null) continue; // 两侧都没数 → 交给缺数清单(另算)
if (totalF == null && abF != null) {
issues.add(String.format("货运体检:%s %s 缺②全口径(规上 %.4f 在、全口径缺)→ 规下无法计算"
+ "(需先导《货运量周转量表》当月档)", period, city, abF));
}
if (totalT == null && abT != null) {
issues.add(String.format("货运体检:%s %s 缺②全口径周转量(规上 %.4f 在、全口径缺)→ 规下无法计算",
period, city, abT));
}
// 2026-10-12:②全口径缺(未导)时不再报「规下为负」——上面已单独报「缺②全口径」,
// 若这里仍按 0 − 规上 算,会对每个市州多刷一条假阳性(值不是负、是缺),把真问题淹没。
if (totalF != null) {
double belowF = totalF - (abF == null ? 0 : abF);
if (belowF < -TOL) issues.add(String.format("货运体检:%s %s 规下货运量为负(全口径 %.4f − 规上 %.4f = %.4f)", period, city, totalF, abF, belowF));
}
if (totalT != null) {
double belowT = totalT - (abT == null ? 0 : abT);
if (belowT < -TOL) issues.add(String.format("货运体检:%s %s 规下周转量为负(全口径 %.4f − 规上 %.4f = %.4f)", period, city, totalT, abT, belowT));
}
if (totalF != null && abF == null) issues.add(String.format("货运体检:%s %s 有全口径但无规上记录 → 规下等于全口径(%.4f),疑①漏报", period, city, totalF));
if (totalF != null && abF != null && totalF < abF) issues.add(String.format("货运体检:%s %s 全口径 %.4f 小于规上 %.4f", period, city, totalF, abF));
FreightTurnoverImport prev = ftPrev.get(city);
if (prev != null) {
int prevMonth = month == 1 ? 12 : month - 1;
Double prevF = monthField(prev, prevMonth, true);
Double prevT = monthField(prev, prevMonth, false);
if (totalF != null && sameValue(prevF, totalF) && Math.abs(totalF) >= CHAIN_FLOOR) {
issues.add(String.format("货运体检:%s %s 全口径货运量本月与上月完全相同(%.4f),疑重复导入", period, city, totalF));
}
if (totalT != null && sameValue(prevT, totalT) && Math.abs(totalT) >= CHAIN_FLOOR) {
issues.add(String.format("货运体检:%s %s 全口径周转量本月与上月完全相同(%.4f),疑重复导入", period, city, totalT));
}
}
if (row != null) {
Double lastF = lastMonthField(row, month, true);
Double lastT = lastMonthField(row, month, false);
if (lastF != null && totalF != null && Math.abs(lastF) >= CHAIN_FLOOR && Math.abs(totalF) >= CHAIN_FLOOR) {
double ratio = totalF / lastF - 1.0;
if (Math.abs(ratio) > YOY_RATIO) {
issues.add(String.format("货运体检:%s %s 全口径货运量同比异常(去年同月 %.4f -> 本月 %.4f,%.1f%%)", period, city, lastF, totalF, ratio * 100));
}
}
if (lastT != null && totalT != null && Math.abs(lastT) >= CHAIN_FLOOR && Math.abs(totalT) >= CHAIN_FLOOR) {
double ratio = totalT / lastT - 1.0;
if (Math.abs(ratio) > YOY_RATIO) {
issues.add(String.format("货运体检:%s %s 全口径周转量同比异常(去年同月 %.4f -> 本月 %.4f,%.1f%%)", period, city, lastT, totalT, ratio * 100));
}
}
}
ScaleSplitTransport cum = splitCum.get(city);
if (cum != null && cum.getAboveScaleTurnover() != null) {
Map buckets = aboveTurnover.get(city);
if (buckets != null) {
double sum = 0.0;
for (int m = 1; m <= month; m++) sum += buckets.getOrDefault(m, 0.0);
double fromH2032 = sum / 10000.0;
double fromSplit = cum.getAboveScaleTurnover();
if (Math.abs(fromH2032 - fromSplit) > TOL) {
issues.add(String.format("货运体检:%s %s 规上周转量累计勾稽不一致(①逐月求和 %.4f vs ③累计 %.4f)", period, city, fromH2032, fromSplit));
}
}
}
}
if (province != null) {
Double pf = monthField(province, month, true);
Double pt = monthField(province, month, false);
if (pf != null && Math.abs(pf - sumFreight) > TOL) {
issues.add(String.format("货运体检:%s Σ17市州货运量 %.4f ≠ ②表湖北省行 %.4f(差 %.4f)", period, sumFreight, pf, sumFreight - pf));
}
if (pt != null && Math.abs(pt - sumTurnover) > TOL) {
issues.add(String.format("货运体检:%s Σ17市州周转量 %.4f ≠ ②表湖北省行 %.4f(差 %.4f)", period, sumTurnover, pt, sumTurnover - pt));
}
}
if (!issues.isEmpty()) {
log.warn("货运体检(B4):{} 命中 {} 条问题", period, issues.size());
}
return issues;
}
/** ②表按 月号 取当月货运量/周转量(与 FreightCalc 同一取法) */
private Double monthField(FreightTurnoverImport e, int m, boolean freight) {
switch (m) {
case 1: return freight ? e.getFreightM01() : e.getTurnoverM01();
case 2: return freight ? e.getFreightM02() : e.getTurnoverM02();
case 3: return freight ? e.getFreightM03() : e.getTurnoverM03();
case 4: return freight ? e.getFreightM04() : e.getTurnoverM04();
case 5: return freight ? e.getFreightM05() : e.getTurnoverM05();
case 6: return freight ? e.getFreightM06() : e.getTurnoverM06();
case 7: return freight ? e.getFreightM07() : e.getTurnoverM07();
case 8: return freight ? e.getFreightM08() : e.getTurnoverM08();
case 9: return freight ? e.getFreightM09() : e.getTurnoverM09();
case 10: return freight ? e.getFreightM10() : e.getTurnoverM10();
case 11: return freight ? e.getFreightM11() : e.getTurnoverM11();
case 12: return freight ? e.getFreightM12() : e.getTurnoverM12();
default: return null;
}
}
/** ②表去年同月值 */
private Double lastMonthField(FreightTurnoverImport e, int m, boolean freight) {
switch (m) {
case 1: return freight ? e.getLastFreightM01() : e.getLastTurnoverM01();
case 2: return freight ? e.getLastFreightM02() : e.getLastTurnoverM02();
case 3: return freight ? e.getLastFreightM03() : e.getLastTurnoverM03();
case 4: return freight ? e.getLastFreightM04() : e.getLastTurnoverM04();
case 5: return freight ? e.getLastFreightM05() : e.getLastTurnoverM05();
case 6: return freight ? e.getLastFreightM06() : e.getLastTurnoverM06();
case 7: return freight ? e.getLastFreightM07() : e.getLastTurnoverM07();
case 8: return freight ? e.getLastFreightM08() : e.getLastTurnoverM08();
case 9: return freight ? e.getLastFreightM09() : e.getLastTurnoverM09();
case 10: return freight ? e.getLastFreightM10() : e.getLastTurnoverM10();
case 11: return freight ? e.getLastFreightM11() : e.getLastTurnoverM11();
case 12: return freight ? e.getLastFreightM12() : e.getLastTurnoverM12();
default: return null;
}
}
private boolean sameValue(Double a, Double b) {
if (a == null || b == null) return false;
return Math.abs(a - b) < 1e-9;
}
private String prevPeriod(String period) {
int y = Integer.parseInt(period.substring(0, 4));
int m = Integer.parseInt(period.substring(5, 7));
if (m == 1) return (y - 1) + "-12";
return String.format("%04d-%02d", y, m - 1);
}
public String describe(String period) {
List issues = check(period);
return issues.isEmpty() ? "货运体检:未发现问题" : String.join("\n", issues);
}
}