feat(汇总大表): 货运基数体检 B4 六条规则(倒减为负/缺报/重复导入/同比异常)待验收
1个文件已添加
3个文件已修改
266 ■■■■■ 已修改文件
HANDOFF.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java 243 ●●●●● 补丁 | 查看 | 原始文档 | 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 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -4,6 +4,16 @@
> **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。
> **协作约定(2026-08-31)**:说「提交」= 本地 commit + 推送到远端 git(origin main,Gitblit),不再只做本地 commit。
### 10-12(2):货运口径「导出前基数体检」(B4)6 条规则落地【待验收】
- **背景**:规下 = ②《货运量周转量表》全口径 − ①H203-2 规上÷10000,是**倒减**——①漏报会让规下虚高、极端还会算出负数。用户 2026-10-12 同意把这组规则加进 B4。
- **实现**:新增 `reportexport/calc/FreightReadinessCheck.java`(只读);接入 `exportSummaryWorkbook` 的缺数收集 `dbProblems`(随导出日志输出);新增只读接口 `GET /api/report/summaryWorkbook/freightCheck?period=YYYY-MM`。
- **规则**:① 某市州规下为负;② ②有全口径、①无规上记录(疑漏报→规下=全口径);③ ②全口径 < ①规上;④ Σ17 市州 ≠ ②"湖北省"行(容差 0.01);⑤ **本月与上月完全相同**(疑重复导入,两侧 ≥50 万吨/万吨公里);⑥ **同比异常**(|去年同月→本月| > 50%,两侧 ≥50);⑥' 累计勾稽(①周转量 1..M 求和 ÷10000 vs ③拆分表 CUMULATIVE)。
- **阈值修正(实测驱动,重要)**:首版用"规下环比 > 30%" → 2026-02 命中 22 条、2026-03 命中 32 条,复核确认**全是春节季节性**(各市州 2 月 −30~−40%、3 月 +30~+60%),非数据错误 → 改为"重复导入特征 + 同比>50%";并给判据加**绝对门槛 50 万吨/万吨公里**,避免神农架林区(20~46 万吨)这类小基数噪声长期报警。
- **实测**:2026-01~08 **全期 0 命中**(无误报);②表 `last_freight_m08`/`last_turnover_m08` **18/18 行有值** → 同比规则确实参与计算(非空跑)。
- **报告(本地、不入库)**:`docs/功能测试报告/功能测试报告_货运基数体检B4六条规则_2026-10-12.md`;日结同日两条。
- **待用户验收/确认**:① 阈值(绝对门槛 50、同比 50%)是否合适;② 是否现在做 B4 剩余部分——导出前弹窗 + 生成件《数据缺失清单》页(P0-3 剩余)。
- **状态**:**【待验收】**。只读功能,未改动业务数据。
## 最新(2026-10-12,zyj 线)
### 10-12:扩列器补「标题按母版期次重写」+ 修正 8 月母版标题【待验收】
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/calc/FreightReadinessCheck.java
New file
@@ -0,0 +1,243 @@
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>某市州全口径同比异常(|去年同月→本月| &gt; 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;      // 两侧都没数 → 交给缺数清单(另算)
            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);
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/reportexport/controller/ReportExportController.java
@@ -1,6 +1,7 @@
package com.trafficaudit.reportexport.controller;
import com.trafficaudit.common.Result;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.service.ReportExportService;
import com.trafficaudit.reportexport.service.ReportExportTaskService;
import com.trafficaudit.system.service.OperationLogService;
@@ -26,6 +27,8 @@
    @Resource
    private ReportExportService reportService;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private ReportExportTaskService exportTaskService;
    @Resource
@@ -89,6 +92,12 @@
    }
    /** 生成_道路运输量汇总表.xlsx(汇总工作簿 P0-P1 骨架:整本复制母版,页签/公式/2025 缓存保留) */
    /** 货运口径导出前体检(B4):只读返回命中的问题清单。 */
    @GetMapping("/summaryWorkbook/freightCheck")
    public Result<java.util.List<String>> freightCheck(@RequestParam("period") String period) {
        return Result.ok(freightReadinessCheck.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
@@ -34,6 +34,7 @@
import com.trafficaudit.reportexport.calc.MidCalc;
import com.trafficaudit.reportexport.calc.PaxCalc;
import com.trafficaudit.reportexport.calc.SummaryWorkbookFiller;
import com.trafficaudit.reportexport.calc.FreightReadinessCheck;
import com.trafficaudit.reportexport.layout.SummaryLayoutExpander;
import com.trafficaudit.reportexport.calc.WycSplitCalc;
import lombok.extern.slf4j.Slf4j;
@@ -104,6 +105,8 @@
    private SummaryWorkbookFiller summaryWorkbookFiller;
    @Resource
    private SummaryLayoutExpander summaryLayoutExpander;
    @Resource
    private FreightReadinessCheck freightReadinessCheck;
    @Resource
    private WycSplitCalc wycSplitCalc;
    @Resource
@@ -3707,6 +3710,7 @@
            int effMonth = (toMonth != null && toMonth > 0 && toMonth < month) ? toMonth : month;
            List<String> dbProblems = new ArrayList<>();
            String effPeriod = String.format("%04d-%02d", year, effMonth);
            dbProblems.addAll(freightReadinessCheck.check(effPeriod));   // B4:货运口径导出前体检(6 条规则)
            reportProgress(progress, 22, "回填当月数据");
            int dbFilled = summaryWorkbookFiller.fillMonthlyLedger(wb, effPeriod, year, effMonth, dbProblems);
            int yoyFilled = summaryWorkbookFiller.ensureMonthYoyFormulas(wb, year, effMonth);