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 条规则)。
|
*
|
* <p>背景:规下 = 全口径(②《货运量周转量表》)− 规上(①H203-2 企业月报÷10000)——是**倒减**,
|
* 因此①漏报/缺报会让规下虚高,极端情况还会算出负数。本类把这些异常在导出前挑出来,
|
* 供日志/弹窗/《数据缺失清单》页使用(同一套结果也供生成主链的 dbProblems 复用)。
|
*
|
* <p>规则:
|
* <ol>
|
* <li>某市州规下为负(货量或周转量);</li>
|
* <li>②该市州有全口径、①无规上记录 → 规下等于全口径,明显虚高;</li>
|
* <li>②全口径小于①规上(逻辑不可能);</li>
|
* <li>Σ17 市州 ≠ ②的"湖北省"行(超容差 0.01);</li>
|
* <li>某市州全口径「本月与上月完全相同」(疑重复导入,两侧 ≥ 50 万吨/万吨公里);</li>
|
* <li>某市州全口径同比异常(|去年同月→本月| > 50% 且两侧 ≥ 50 万吨/万吨公里);</li>
|
* <li>累计勾稽:①周转量 1..M 月求和 ÷10000 与③《拆分表》CUMULATIVE.above_scale_turnover 不一致(超容差 0.01)。</li>
|
* </ol>
|
*/
|
@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<String> check(String period) {
|
List<String> 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<String, Map<Integer, Double>> aboveFreight = new HashMap<>();
|
Map<String, Map<Integer, Double>> aboveTurnover = new HashMap<>();
|
for (H2032EnterpriseMonthly rec : h2032Mapper.selectList(
|
new LambdaQueryWrapper<H2032EnterpriseMonthly>()
|
.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<String, FreightTurnoverImport> ft = new HashMap<>();
|
FreightTurnoverImport province = null;
|
for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
|
new LambdaQueryWrapper<FreightTurnoverImport>()
|
.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<String, FreightTurnoverImport> ftPrev = new HashMap<>();
|
for (FreightTurnoverImport e : freightTurnoverMapper.selectList(
|
new LambdaQueryWrapper<FreightTurnoverImport>()
|
.eq(FreightTurnoverImport::getReportPeriod, prevPeriod))) {
|
String city = RegionUtil.normalizeCityName(e.getRegionName());
|
if (city != null && !FreightCalc.PROVINCE.equals(city)) ftPrev.put(city, e);
|
}
|
|
// ③ 拆分表:当月/累计 规上与合计周转量
|
Map<String, ScaleSplitTransport> splitCum = new HashMap<>();
|
for (ScaleSplitTransport rec : scaleSplitMapper.selectList(
|
new LambdaQueryWrapper<ScaleSplitTransport>()
|
.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));
|
}
|
double belowF = (totalF == null ? 0 : totalF) - (abF == null ? 0 : abF);
|
double belowT = (totalT == null ? 0 : totalT) - (abT == null ? 0 : abT);
|
if (belowF < -TOL) issues.add(String.format("货运体检:%s %s 规下货运量为负(全口径 %.4f − 规上 %.4f = %.4f)", period, city, totalF, abF, belowF));
|
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<Integer, Double> 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<String> issues = check(period);
|
return issues.isEmpty() ? "货运体检:未发现问题" : String.join("\n", issues);
|
}
|
}
|