traffic-audit-server/src/main/java/com/trafficaudit/auditengine/service/AuditEngineService.java
@@ -631,6 +631,8 @@
                    .eq(InvestmentSystem::getReportPeriod, reportPeriod))) {
            sysMap.computeIfAbsent(normInvestName(s.getProjectName()), k -> new ArrayList<>()).add(s);
        }
        // 同报表期投资系统基准整期缺失时,不做"项目缺失核实"(缺失=基准未导入,不等于项目不存在),避免整表误报
        boolean benchmarkMissing = sysMap.isEmpty();
        // 幂等:只清除 INVEST 规则的旧审核结果
        List<Long> ruleIds = new ArrayList<>();
@@ -647,6 +649,7 @@
            String projectName = project == null ? "未知项目" : project.getProjectName();
            InvestmentSystem sys = findInvestSystem(sysMap, projectName);
            for (AuditRule rule : rules) {
                if (benchmarkMissing && "EXISTENCE_SYS".equals(rule.getCompareType())) continue;
                AuditResult result = checkInvestRule(rule, monthly, projectName, sys);
                if (result != null) {
                    result.setRuleName(rule.getRuleName());
@@ -1256,19 +1259,17 @@
    public List<AuditResult> queryResults(String reportPeriod, String reportType) {
        String type = reportType == null || reportType.trim().isEmpty() ? "H2032" : reportType.trim();
        List<AuditRule> rules = ruleMapper.selectList(new LambdaQueryWrapper<AuditRule>()
            .eq(AuditRule::getReportType, type));
        if (rules.isEmpty()) return new java.util.ArrayList<>();
        List<Long> ruleIds = new ArrayList<>();
        for (AuditRule rule : rules) ruleIds.add(rule.getId());
        List<AuditResult> results = resultMapper.selectList(new LambdaQueryWrapper<AuditResult>()
            .eq(AuditResult::getReportPeriod, reportPeriod)
            .in(AuditResult::getRuleId, ruleIds)
            .orderByAsc(AuditResult::getId));
        List<Long> ruleIds = new ArrayList<>();
        for (AuditRule rule : ruleMapper.selectList(null)) {
            if (type.equals(rule.getReportType())) ruleIds.add(rule.getId());
        }
        List<AuditResult> filtered = new ArrayList<>();
        for (AuditResult result : results) {
            if (ruleIds.contains(result.getRuleId())) filtered.add(result);
        }
        attachInfo(filtered);
        return filtered;
        attachInfo(results, type, reportPeriod);
        return results;
    }
    public void reviewResult(Long id, String status, String comment) {
@@ -1280,46 +1281,56 @@
        resultMapper.updateById(result);
    }
    private void attachInfo(List<AuditResult> results) {
    private void attachInfo(List<AuditResult> results, String type, String period) {
        if (results.isEmpty()) return;
        Map<Long, AuditRule> ruleMap = new HashMap<>();
        for (AuditRule rule : ruleMapper.selectList(null)) ruleMap.put(rule.getId(), rule);
        Map<Long, String> h2032Name = new HashMap<>();
        Map<Long, String> h2032Explain = new HashMap<>();
        Map<Long, String> passengerName = new HashMap<>();
        Map<Long, String> passengerExplain = new HashMap<>();
        Map<Long, String> energyName = new HashMap<>();
        Map<Long, String> investName = new HashMap<>();
        Map<Long, String> busName = new HashMap<>();
        Map<Long, String> busExplain = new HashMap<>();
        Map<Long, String> taxiName = new HashMap<>();
        Map<Long, String> taxiExplain = new HashMap<>();
        for (H2032EnterpriseMonthly r : h2032Mapper.selectList(null)) {
            h2032Name.putIfAbsent(r.getId(), r.getEnterpriseName());
            h2032Explain.putIfAbsent(r.getId(), r.getVerifyExplanation());
        for (AuditRule rule : ruleMapper.selectList(new LambdaQueryWrapper<AuditRule>()
                .eq(AuditRule::getReportType, type))) {
            ruleMap.put(rule.getId(), rule);
        }
        for (PassengerEnterpriseMonthly r : passengerMapper.selectList(null)) {
            passengerName.putIfAbsent(r.getId(), r.getEnterpriseName());
            passengerExplain.putIfAbsent(r.getId(), r.getVerifyExplanation());
        }
        for (EnergyVehicleQuarterly r : energyMapper.selectList(null)) {
            energyName.putIfAbsent(r.getId(), r.getEnterpriseName());
        }
        for (CityBusMonthly r : cityBusMapper.selectList(null)) {
            busName.putIfAbsent(r.getId(), r.getEnterpriseName());
            busExplain.putIfAbsent(r.getId(), r.getVerifyExplanation());
        }
        for (CityTaxiMonthly r : cityTaxiMapper.selectList(null)) {
            taxiName.putIfAbsent(r.getId(), r.getCity());
            taxiExplain.putIfAbsent(r.getId(), r.getVerifyExplanation());
        }
        Map<Long, Long> investProjectId = new HashMap<>();
        for (InvestmentMonthly r : investMonthlyMapper.selectList(null)) {
            investProjectId.putIfAbsent(r.getId(), r.getProjectId());
        }
        for (InvestmentProject p : investProjectMapper.selectList(null)) {
            for (Map.Entry<Long, Long> e : investProjectId.entrySet()) {
                if (e.getValue().equals(p.getId())) investName.putIfAbsent(e.getKey(), p.getProjectName());
        Map<Long, String> nameMap = new HashMap<>();
        Map<Long, String> explainMap = new HashMap<>();
        if ("INVEST".equals(type)) {
            Map<Long, Long> monthlyProject = new HashMap<>();
            for (InvestmentMonthly m : investMonthlyMapper.selectList(new LambdaQueryWrapper<InvestmentMonthly>()
                    .eq(InvestmentMonthly::getReportPeriod, period))) {
                monthlyProject.put(m.getId(), m.getProjectId());
            }
            for (InvestmentProject p : investProjectMapper.selectList(null)) {
                for (Map.Entry<Long, Long> e : monthlyProject.entrySet()) {
                    if (e.getValue() != null && e.getValue().equals(p.getId())) {
                        nameMap.putIfAbsent(e.getKey(), p.getProjectName());
                    }
                }
            }
        } else if ("H204".equals(type)) {
            for (EnergyVehicleQuarterly m : energyMapper.selectList(new LambdaQueryWrapper<EnergyVehicleQuarterly>()
                    .eq(EnergyVehicleQuarterly::getReportPeriod, period))) {
                nameMap.putIfAbsent(m.getId(), m.getEnterpriseName());
            }
        } else if ("H2031".equals(type)) {
            for (PassengerEnterpriseMonthly m : passengerMapper.selectList(new LambdaQueryWrapper<PassengerEnterpriseMonthly>()
                    .eq(PassengerEnterpriseMonthly::getReportPeriod, period))) {
                nameMap.putIfAbsent(m.getId(), m.getEnterpriseName());
                explainMap.putIfAbsent(m.getId(), m.getVerifyExplanation());
            }
        } else if ("CITY_BUS".equals(type)) {
            for (CityBusMonthly m : cityBusMapper.selectList(new LambdaQueryWrapper<CityBusMonthly>()
                    .eq(CityBusMonthly::getReportPeriod, period))) {
                nameMap.putIfAbsent(m.getId(), m.getEnterpriseName());
                explainMap.putIfAbsent(m.getId(), m.getVerifyExplanation());
            }
        } else if ("CITY_TAXI".equals(type)) {
            for (CityTaxiMonthly m : cityTaxiMapper.selectList(new LambdaQueryWrapper<CityTaxiMonthly>()
                    .eq(CityTaxiMonthly::getReportPeriod, period))) {
                nameMap.putIfAbsent(m.getId(), m.getCity());
                explainMap.putIfAbsent(m.getId(), m.getVerifyExplanation());
            }
        } else {
            for (H2032EnterpriseMonthly m : h2032Mapper.selectList(new LambdaQueryWrapper<H2032EnterpriseMonthly>()
                    .eq(H2032EnterpriseMonthly::getReportPeriod, period))) {
                nameMap.putIfAbsent(m.getId(), m.getEnterpriseName());
                explainMap.putIfAbsent(m.getId(), m.getVerifyExplanation());
            }
        }
        for (AuditResult result : results) {
@@ -1329,69 +1340,49 @@
                result.setRuleCode(rule.getRuleCode());
                result.setAlertLevel(rule.getAlertLevel());
            }
            // 各报表表 id 各自独立自增,需按规则所属报表类型分表回填企业名称
            String type = rule == null ? null : rule.getReportType();
            Map<Long, String> nameMap = "INVEST".equals(type) ? investName
                : "H204".equals(type) ? energyName
                : "H2031".equals(type) ? passengerName
                : "CITY_BUS".equals(type) ? busName
                : "CITY_TAXI".equals(type) ? taxiName : h2032Name;
            Map<Long, String> explainMap = "H2031".equals(type) ? passengerExplain
                : "CITY_BUS".equals(type) ? busExplain
                : "CITY_TAXI".equals(type) ? taxiExplain : h2032Explain;
            String enterpriseName = nameMap.get(result.getReportId());
            if (enterpriseName == null) {
                // 上报数据被重新导入后旧审核结果已失效,给出提示而非空白
                enterpriseName = "\uFF08\u539F\u6570\u636E\u5DF2\u91CD\u65B0\u5BFC\u5165\uFF0C\u8BF7\u91CD\u65B0\u6267\u884C\u5BA1\u6838\uFF09";
                enterpriseName = "(原数据已重新导入,请重新执行审核)";
            }
            result.setEnterpriseName(enterpriseName);
            result.setVerifyExplanation(explainMap.get(result.getReportId()));
        }
    }
    /** 最近有数据的报表期(用于前端默认选中) */
    public String latestPeriod(String reportType) {
        String type = reportType == null || reportType.trim().isEmpty() ? "H2032" : reportType.trim();
        String max = null;
        switch (type) {
            case "H2031":
                for (PassengerEnterpriseMonthly m : passengerMapper.selectList(
                        new LambdaQueryWrapper<PassengerEnterpriseMonthly>().select(PassengerEnterpriseMonthly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(passengerMapper.selectObjs(new LambdaQueryWrapper<PassengerEnterpriseMonthly>()
                    .select(PassengerEnterpriseMonthly::getReportPeriod)
                    .orderByDesc(PassengerEnterpriseMonthly::getReportPeriod).last("LIMIT 1")));
            case "H204":
                for (EnergyVehicleQuarterly m : energyMapper.selectList(
                        new LambdaQueryWrapper<EnergyVehicleQuarterly>().select(EnergyVehicleQuarterly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(energyMapper.selectObjs(new LambdaQueryWrapper<EnergyVehicleQuarterly>()
                    .select(EnergyVehicleQuarterly::getReportPeriod)
                    .orderByDesc(EnergyVehicleQuarterly::getReportPeriod).last("LIMIT 1")));
            case "INVEST":
                for (InvestmentMonthly m : investMonthlyMapper.selectList(
                        new LambdaQueryWrapper<InvestmentMonthly>().select(InvestmentMonthly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(investMonthlyMapper.selectObjs(new LambdaQueryWrapper<InvestmentMonthly>()
                    .select(InvestmentMonthly::getReportPeriod)
                    .orderByDesc(InvestmentMonthly::getReportPeriod).last("LIMIT 1")));
            case "CITY_BUS":
                for (CityBusMonthly m : cityBusMapper.selectList(
                        new LambdaQueryWrapper<CityBusMonthly>().select(CityBusMonthly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(cityBusMapper.selectObjs(new LambdaQueryWrapper<CityBusMonthly>()
                    .select(CityBusMonthly::getReportPeriod)
                    .orderByDesc(CityBusMonthly::getReportPeriod).last("LIMIT 1")));
            case "CITY_TAXI":
                for (CityTaxiMonthly m : cityTaxiMapper.selectList(
                        new LambdaQueryWrapper<CityTaxiMonthly>().select(CityTaxiMonthly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(cityTaxiMapper.selectObjs(new LambdaQueryWrapper<CityTaxiMonthly>()
                    .select(CityTaxiMonthly::getReportPeriod)
                    .orderByDesc(CityTaxiMonthly::getReportPeriod).last("LIMIT 1")));
            default:
                for (H2032EnterpriseMonthly m : h2032Mapper.selectList(
                        new LambdaQueryWrapper<H2032EnterpriseMonthly>().select(H2032EnterpriseMonthly::getReportPeriod))) {
                    max = maxPeriod(max, m.getReportPeriod());
                }
                break;
                return firstOf(h2032Mapper.selectObjs(new LambdaQueryWrapper<H2032EnterpriseMonthly>()
                    .select(H2032EnterpriseMonthly::getReportPeriod)
                    .orderByDesc(H2032EnterpriseMonthly::getReportPeriod).last("LIMIT 1")));
        }
        return max;
    }
    private String firstOf(List<Object> objs) {
        if (objs == null || objs.isEmpty() || objs.get(0) == null) return null;
        String v = String.valueOf(objs.get(0));
        return v.trim().isEmpty() ? null : v.trim();
    }
    private String maxPeriod(String a, String b) {
@@ -1400,24 +1391,29 @@
        return a;
    }
    /** 审核结果导出 Excel(含企业名/规则名/状态/审核意见) */
    /** 审核结果导出 Excel(两 Sheet,按业务模板):Sheet1 审核结果(逐条明细);Sheet2 企业合并(按企业汇总 + 问题列) */
    public byte[] exportResults(String reportPeriod, String reportType) throws Exception {
        return exportResults(reportPeriod, reportType, true, "all");
    }
    /** onlyVerify=true 时剔除“提醒型”规则(如 RULE_004 环比提醒),只保留核实型问题;status=all/pending/handled 与界面“状态”筛选一致 */
    public byte[] exportResults(String reportPeriod, String reportType, boolean onlyVerify, String status) throws Exception {
        List<AuditResult> results = queryResults(reportPeriod, reportType);
        java.util.List<AuditResult> filtered = new java.util.ArrayList<>();
        String st = status == null ? "all" : status.trim();
        for (AuditResult res : results) {
            if (onlyVerify && isReminderRule(res.getRuleCode())) continue;
            if ("pending".equals(st) && !"PENDING".equals(res.getStatus())) continue;
            if ("handled".equals(st) && (res.getStatus() == null || "PENDING".equals(res.getStatus()))) continue;
            filtered.add(res);
        }
        try (XSSFWorkbook wb = new XSSFWorkbook()) {
            // ---------- Sheet1 审核结果(逐条,列与业务模板一致) ----------
            Sheet sheet = wb.createSheet("审核结果");
            String[] headers = {"报表期", "企业/市州", "规则编码", "审核规则", "等级", "实际值", "阈值/基准", "偏差说明", "状态", "审核意见"};
            Row head = sheet.createRow(0);
            for (int i = 0; i < headers.length; i++) {
                Cell cell = head.createCell(i);
                cell.setCellValue(headers[i]);
                org.apache.poi.ss.usermodel.CellStyle style = wb.createCellStyle();
                org.apache.poi.ss.usermodel.Font font = wb.createFont();
                font.setBold(true);
                style.setFont(font);
                cell.setCellStyle(style);
            }
            writeAuditHeader(sheet, headers);
            int r = 1;
            for (AuditResult res : results) {
            for (AuditResult res : filtered) {
                Row row = sheet.createRow(r++);
                row.createCell(0).setCellValue(valueOf(res.getReportPeriod()));
                row.createCell(1).setCellValue(valueOf(res.getEnterpriseName()));
@@ -1434,10 +1430,154 @@
            for (int i = 0; i < widths.length; i++) {
                sheet.setColumnWidth(i, widths[i] * 256);
            }
            // ---------- Sheet2 企业合并(同一企业多条规则并排列出 + 问题汇总列) ----------
            Sheet ms = wb.createSheet("企业合并");
            String[] h2 = {"报表期", "企业/市州", "审核规则", "实际值", "阈值/基准", "偏差说明", "问题"};
            writeAuditHeader(ms, h2);
            java.util.LinkedHashMap<String, java.util.List<AuditResult>> groups = new java.util.LinkedHashMap<>();
            for (AuditResult res : filtered) {
                String key = valueOf(res.getEnterpriseName());
                if (key.isEmpty()) key = valueOf(res.getReportPeriod()) + "#" + valueOf(res.getActualValue());
                groups.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(res);
            }
            int rowCursor = 1;
            for (java.util.Map.Entry<String, java.util.List<AuditResult>> e : groups.entrySet()) {
                java.util.List<AuditResult> items = e.getValue();
                int groupStart = rowCursor;
                StringBuilder problems = new StringBuilder();
                int seq = 0;
                for (AuditResult it : items) {
                    Row row = ms.createRow(rowCursor++);
                    if (seq == 0) {
                        row.createCell(0).setCellValue(valueOf(it.getReportPeriod()));
                        row.createCell(1).setCellValue(valueOf(it.getEnterpriseName()).isEmpty() ? e.getKey() : valueOf(it.getEnterpriseName()));
                    }
                    row.createCell(2).setCellValue(valueOf(it.getRuleName()));
                    row.createCell(3).setCellValue(valueOf(it.getActualValue()));
                    row.createCell(4).setCellValue(valueOf(it.getThresholdValue()));
                    row.createCell(5).setCellValue(valueOf(it.getDeviation()));
                    seq++;
                    if (problems.length() > 0) problems.append("\n");
                    problems.append(seq).append("、").append(issueSentence(it));
                }
                if (rowCursor - groupStart > 1) {
                    ms.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(groupStart, rowCursor - 1, 1, 1));
                    ms.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(groupStart, rowCursor - 1, 6, 6));
                }
                Row first = ms.getRow(groupStart);
                if (first.getCell(6) == null) first.createCell(6);
                first.getCell(6).setCellValue(problems.toString());
            }
            int[] widths2 = {12, 30, 22, 34, 22, 34, 60};
            for (int i = 0; i < widths2.length; i++) {
                ms.setColumnWidth(i, widths2[i] * 256);
            }
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            wb.write(out);
            return out.toByteArray();
        }
    }
    private void writeAuditHeader(Sheet sheet, String[] headers) {
        Row head = sheet.createRow(0);
        for (int i = 0; i < headers.length; i++) {
            Cell cell = head.createCell(i);
            cell.setCellValue(headers[i]);
            org.apache.poi.ss.usermodel.CellStyle style = sheet.getWorkbook().createCellStyle();
            org.apache.poi.ss.usermodel.Font font = sheet.getWorkbook().createFont();
            font.setBold(true);
            style.setFont(font);
            cell.setCellStyle(style);
        }
    }
    /** 提醒型规则:仅提示变化、不需人工核实(如 RULE_004 货运类型环比提醒) */
    private boolean isReminderRule(String ruleCode) {
        return "RULE_004".equals(ruleCode);
    }
    /** 企业合并“问题”列话术:按规则类型生成可读的核实意见 */
    private String issueSentence(AuditResult res) {
        String code = res.getRuleCode();
        String name = valueOf(res.getRuleName());
        String act = valueOf(res.getActualValue());
        if ("RULE_001".equals(code) || "RULE_002".equals(code)) {
            return "企业上报车辆/吨位与运政数据对比存在差异(" + act + "),请核实";
        }
        if ("RULE_003".equals(code)) {
            return "上报周转量与轨迹测算周转量差异明显(" + act + "),请参考轨迹核实企业周转量";
        }
        if ("RULE_004".equals(code)) {
            return "货运类型环比发生变化" + (act.isEmpty() ? "" : "(" + act + ")") + ",请关注是否填报正常";
        }
        if ("RULE_005".equals(code)) {
            return "无集装箱车但存在集装箱货运量(" + act + "),请核实车辆类型与货运量填报";
        }
        if ("RULE_006".equals(code)) {
            return "整车平均吨位超出合理范围(" + act + "),请核实平均吨位填报";
        }
        if (code != null && code.startsWith("RULE_P")) {
            String special = passengerIssueSentence(res);
            if (special != null) return special;
        }
        return name + "异常(" + act + "),请核实";
    }
    /** 客运(H2031)规则问题话术:贴合模板示例“核实异常(上期指标=,本期指标=)请核实” */
    private String passengerIssueSentence(AuditResult res) {
        String code = res.getRuleCode();
        String act = valueOf(res.getActualValue());
        if ("RULE_P003".equals(code) || "RULE_P004".equals(code)) {
            boolean turnover = "RULE_P004".equals(code);
            String inner = act;
            if (inner.startsWith("本月与上月相同: ")) {
                inner = inner.substring("本月与上月相同: ".length());
            }
            java.util.List<String> clauses = new java.util.ArrayList<>();
            for (String item : inner.split("、")) {
                int eq = item.indexOf('=');
                if (eq <= 0) { clauses.add(item); continue; }
                String label = item.substring(0, eq).trim();
                String v = item.substring(eq + 1).trim();
                clauses.add("上月" + (turnover ? "周转量" : "客运量") + "=" + v + ",本月" + label + "=" + v);
            }
            return valueOf(res.getRuleName()) + "异常(" + String.join(";", clauses) + ")请核实";
        }
        if ("RULE_P005".equals(code)) {
            String pct = extractPct(valueOf(res.getThresholdValue()));
            String prefix = pct.isEmpty() ? "班线平均运距环比超限" : "班线平均运距环比超±" + pct + "%";
            return prefix + ",正常班线运距在线路未发生重大变化前较为稳定(" + act + "),请核实";
        }
        if ("RULE_P006".equals(code)) {
            String inner = act;
            if (inner.startsWith("等于上月: ")) {
                inner = inner.substring("等于上月: ".length());
            }
            java.util.List<String> clauses = new java.util.ArrayList<>();
            for (String item : inner.split("、")) {
                int eq = item.indexOf('=');
                if (eq <= 0) { clauses.add(item); continue; }
                String v = item.substring(eq + 1).trim();
                clauses.add("上月运距=" + v + ",本月运距=" + v);
            }
            return "包车平均运距等于上月(" + String.join(";", clauses)
                + ")请核实企业是否是固定线路旅游或者通勤业务";
        }
        return null;
    }
    private String extractPct(String thresholdText) {
        if (thresholdText == null || thresholdText.isEmpty()) return "";
        int idx = thresholdText.indexOf('\u00b1');
        if (idx < 0) return "";
        StringBuilder num = new StringBuilder();
        for (int i = idx + 1; i < thresholdText.length(); i++) {
            char c = thresholdText.charAt(i);
            if (Character.isDigit(c) || c == '.') num.append(c);
            else if (!Character.isWhitespace(c)) break;
        }
        return num.toString();
    }
    private String auditStatusCn(String status) {
@@ -1538,6 +1678,7 @@
            case "RULE_I001":
            case "RULE_I002":
            case "RULE_I003":
            case "RULE_I004":
                return investSystemMapper.selectCount(new LambdaQueryWrapper<InvestmentSystem>()
                    .eq(InvestmentSystem::getReportPeriod, period)) == 0;
            default:
@@ -1565,6 +1706,8 @@
            switch (rule.getCompareType()) {
                case "DIFF":
                    return checkDiff(rule, report, authMap);
                case "DIFF_FIELDS":
                    return checkDiffFields(rule, report, authMap);
                case "RATIO":
                    return checkRatio(rule, report, authMap, trackMap);
                case "MOM":
@@ -1607,6 +1750,86 @@
        return null;
    }
    /** RULE_001 分项运政对比:牵引车/挂车/整车车辆数与挂车吨位/整车吨位/总吨位分别与运政对比,任一分项超限即提醒 */
    private AuditResult checkDiffFields(AuditRule rule, H2032EnterpriseMonthly report,
                                        Map<String, TransportAuthVehicle> authMap) {
        TransportAuthVehicle auth = findAuth(authMap, report.getEnterpriseName());
        if (auth == null) return null;
        String[] fields = rule.getCheckField() == null ? new String[0] : rule.getCheckField().split(",");
        String[] limits = rule.getThreshold() == null ? new String[0] : rule.getThreshold().split("[,;|]");
        if (fields.length == 0 || limits.length != fields.length) return null;
        java.util.List<String> hits = new java.util.ArrayList<>();
        double maxAbs = 0;
        for (int i = 0; i < fields.length; i++) {
            String field = fields[i].trim();
            double limit;
            try {
                limit = Double.parseDouble(limits[i].trim());
            } catch (Exception e) {
                return null;
            }
            double actual;
            double reference;
            String unit;
            switch (field) {
                case "vehicleTractor":
                    actual = num(report.getVehicleTractor());
                    reference = num(auth.getTractorCount());
                    unit = "台";
                    break;
                case "vehicleTrailer":
                    actual = num(report.getVehicleTrailer());
                    reference = num(auth.getTrailerCount());
                    unit = "台";
                    break;
                case "tonsTrailer":
                    actual = nvl(report.getTonsTrailer());
                    reference = nvl(auth.getTrailerTons());
                    unit = "吨";
                    break;
                case "vehicleWhole":
                    actual = num(report.getVehicleWhole());
                    reference = num(auth.getOtherCount());
                    unit = "台";
                    break;
                case "tonsWhole":
                    actual = nvl(report.getTonsWhole());
                    reference = nvl(auth.getOtherTons());
                    unit = "吨";
                    break;
                case "tonsTotal":
                    actual = nvl(report.getTonsTotal());
                    reference = nvl(auth.getTrailerTons()) + nvl(auth.getOtherTons());
                    unit = "吨";
                    break;
                default:
                    return null;
            }
            double diff = Math.abs(actual - reference);
            maxAbs = Math.max(maxAbs, diff);
            if (diff > limit) {
                hits.add(h2032FieldLabel(field) + ":上报=" + fmt(actual) + " " + unit + ",运政=" + fmt(reference) + " " + unit
                    + ",差" + fmt(diff) + " " + unit + "(限" + fmt(limit) + " " + unit + ")");
            }
        }
        if (hits.isEmpty()) return null;
        StringBuilder detail = new StringBuilder();
        for (int i = 0; i < hits.size(); i++) {
            String one = hits.get(i);
            if (detail.length() == 0) {
                detail.append(one);
            } else if (detail.length() + one.length() + 1 <= 180) {
                detail.append(";").append(one);
            } else {
                detail.append(";等").append(hits.size() - i).append("项超限");
                break;
            }
        }
        return buildResult(rule, report, detail.toString(),
            "牵引车/挂车/整车车辆差≤5台;挂车吨位/整车吨位差≤50吨;总吨位差≤50吨",
            "超出项数=" + hits.size() + ",最大差=" + fmt(maxAbs));
    }
    /** 上报周转量与轨迹测算周转量对比,高出阈值比例核实 */
    private AuditResult checkRatio(AuditRule rule, H2032EnterpriseMonthly report,
                                   Map<String, TransportAuthVehicle> authMap,
@@ -1641,26 +1864,34 @@
        return null;
    }
    /** 货运类型环比:本月新增(上月为0、本月>0)需核实,少的不管 */
    /** 货运类型环比(RULE_004):上月无→本月新增 或 上月有→本月无,均提醒核实 */
    private AuditResult checkMom(AuditRule rule, H2032EnterpriseMonthly report,
                                 Map<String, H2032EnterpriseMonthly> lastMonthMap) {
        H2032EnterpriseMonthly lastMonth = lastMonthMap.get(key(report.getEnterpriseCode()));
        if (lastMonth == null) return null;
        List<String> hits = new ArrayList<>();
        List<String> addHits = new ArrayList<>();
        List<String> delHits = new ArrayList<>();
        for (String field : rule.getCheckField().split(",")) {
            double current = getH2032FieldValue(report, field);
            double last = getH2032FieldValue(lastMonth, field);
            if (current > 0 && last == 0) {
                hits.add(h2032FieldLabel(field) + "=" + fmt(current));
                addHits.add(h2032FieldLabel(field) + "=" + fmt(current));
            } else if (last > 0 && current == 0) {
                delHits.add(h2032FieldLabel(field) + "(上月=" + fmt(last) + ",本月无)");
            }
        }
        if (!hits.isEmpty()) {
            return buildResult(rule, report, "本月新增: " + String.join("、", hits),
                "上月该类型为0", "本月新增货运类型(上月无)");
        if (!addHits.isEmpty() || !delHits.isEmpty()) {
            StringBuilder msg = new StringBuilder();
            if (!addHits.isEmpty()) msg.append("本月新增: ").append(String.join("、", addHits));
            if (!delHits.isEmpty()) {
                if (msg.length() > 0) msg.append(";");
                msg.append("本月消失: ").append(String.join("、", delHits));
            }
            return buildResult(rule, report, msg.toString(),
                "上月无→本月新增 或 上月有→本月无", "货运类型环比新增/消失需核实");
        }
        return null;
    }
    /** 有货运量但对应车辆为0 需核实(如 有集装箱货运量无集装箱车辆) */
    private AuditResult checkNull(AuditRule rule, H2032EnterpriseMonthly report) {
        String[] fields = rule.getCheckField().split(",");
@@ -1675,15 +1906,29 @@
        return null;
    }
    /** 数值范围检查(如 整车吨位 4≤且≤40) */
    /** 数值范围检查;整车吨位按“平均吨位=整车吨位/整车数”口径判定 4~40 */
    private AuditResult checkOutRange(AuditRule rule, H2032EnterpriseMonthly report) {
        String[] bounds = rule.getThreshold().split(",");
        if (bounds.length < 2) return null;
        double value = getH2032FieldValue(report, rule.getCheckField());
        String field = rule.getCheckField();
        String label = h2032FieldLabel(field);
        double value;
        if ("tonsWhole".equals(field)) {
            double vehicles = report.getVehicleWhole() == null ? 0.0 : report.getVehicleWhole().doubleValue();
            double tons = nvl(report.getTonsWhole());
            if (vehicles > 0) {
                value = tons / vehicles;
                label = "整车平均吨位(整车吨位/整车数)";
            } else {
                value = tons; // 无整车数但有吨位时仍按原吨位判定
            }
        } else {
            value = getH2032FieldValue(report, field);
        }
        double low = Double.parseDouble(bounds[0].trim());
        double high = Double.parseDouble(bounds[1].trim());
        if (value > 0 && (value < low || value > high)) {
            return buildResult(rule, report, h2032FieldLabel(rule.getCheckField()) + "=" + fmt(value),
            return buildResult(rule, report, label + "=" + fmt(value),
                "范围 " + fmt(low) + "~" + fmt(high), "超出范围");
        }
        return null;
@@ -1778,6 +2023,10 @@
        labels.put("tonsWhole", "整车吨位");
        labels.put("turnoverTotal", "周转量");
        labels.put("vehicleWhole", "整车车辆数");
        labels.put("vehicleTractor", "牵引车数");
        labels.put("vehicleTrailer", "挂车数");
        labels.put("tonsTrailer", "挂车吨位");
        labels.put("tonsWhole", "整车吨位");
        String v = labels.get(field);
        return v == null ? field : v;
    }
@@ -1801,18 +2050,20 @@
    }
    private String fmt(double v) {
        if (v == Math.floor(v) && !Double.isInfinite(v)) {
        if (v == Math.floor(v) && !Double.isInfinite(v) && Math.abs(v) < 1e15) {
            return String.valueOf((long) v);
        }
        return String.valueOf(Math.round(v * 100.0) / 100.0);
        return java.math.BigDecimal.valueOf(v).setScale(2, java.math.RoundingMode.HALF_UP)
            .stripTrailingZeros().toPlainString();
    }
    /** 占比/小数值:保留 4 位小数 */
    private String fmt4(double v) {
        if (v == Math.floor(v) && !Double.isInfinite(v)) {
        if (v == Math.floor(v) && !Double.isInfinite(v) && Math.abs(v) < 1e15) {
            return String.valueOf((long) v);
        }
        return String.valueOf(Math.round(v * 10000.0) / 10000.0);
        return java.math.BigDecimal.valueOf(v).setScale(4, java.math.RoundingMode.HALF_UP)
            .stripTrailingZeros().toPlainString();
    }
    // ========== H2031 旅客审核 ==========
@@ -2021,4 +2272,4 @@
        result.setStatus("PENDING");
        return result;
    }
}
}