yxh
21 小时以前 8c215c8be0f5076e8c95daf1b2203440ca7dfcad
ruoyi-project/src/main/java/com/ruoyi/project/service/impl/ExpertFeeValidateService.java
@@ -305,6 +305,65 @@
        }
        log.info("[专家费校验] 参与校验明细 {} 条:{}", rows.size(), describeRows(rows));
        // 2026-10-15:按"费用项目"的校验模式(base_itemexpense.validate_mode)拆分明细后分别校验
        //   1=强制校验:金额类问题 → 阻断(error)
        //   2=校验提醒:金额类问题 → 仅提醒(warning),允许保存
        //   3=不校验  :跳过该项目全部金额类规则
        // 说明:金额类规则全部按项目 id 判定,同一项目的明细才会互相产生合计类问题,
        //       因此按项目拆分不会影响各规则内部逻辑;含跨项目比较的规则(如评估冲突)
        //       在双方都是强制模式时仍然一起参与,行为不变。
        List<DetailRow> forcedRows = new ArrayList<>();
        List<DetailRow> remindRows = new ArrayList<>();
        List<DetailRow> skippedRows = new ArrayList<>();
        for (DetailRow row : rows) {
            int mode = loadItemMode(row.getItemid());
            if (mode == MODE_SKIP) {
                skippedRows.add(row);
            } else if (mode == MODE_REMIND) {
                remindRows.add(row);
            } else {
                forcedRows.add(row);
            }
        }
        log.info("[专家费校验] 校验模式统计:强制={} 提醒={} 不校验={}",
                forcedRows.size(), remindRows.size(), skippedRows.size());
        // 1) 强制校验:正常阻断
        runAmountRules(forcedRows, result);
        // 2) 校验提醒:同样跑一遍,但把结果降级为提醒(不阻断)
        if (!remindRows.isEmpty()) {
            ExpertFeeCheckResult remindResult = new ExpertFeeCheckResult();
            runAmountRules(remindRows, remindResult);
            for (String error : remindResult.getErrors()) {
                // 业务方确认的提醒文案:金额类问题在「校验提醒」模式下不阻断,仅提示后可继续保存
                result.addWarning("该费用项存在超标准情况,仅作提醒,是否继续保存?【" + error + "】");
            }
            if (!remindResult.getErrors().isEmpty()) {
                log.info("[专家费校验] 以下明细所属项目为「校验提醒」模式,仅提醒不阻断:{}", describeRows(remindRows));
            }
        }
        // 3) 不校验:跳过金额类规则,仅记录日志,便于事后回溯
        if (!skippedRows.isEmpty()) {
            log.info("[专家费校验] 以下明细所属项目为「不校验」模式,已跳过金额类规则:{}", describeRows(skippedRows));
        }
        // 4) 数据质量类规则(金额为 0、未填写费用说明)在三种模式下都强制:
        //    对未参与强制校验的明细单独跑一次"只查数据质量"的检查,避免出现金额为空的脏数据
        checkItemStandard(remindRows, result, false);
        checkItemStandard(skippedRows, result, false);
        log.info("[专家费校验] 校验完成 阻断={} 提示={} 阻断信息={} 提示信息={}",
                result.getErrors().size(), result.getWarnings().size(),
                result.getErrors(), result.getWarnings());
        return result;
    }
    /**
     * 金额类规则(受"校验模式"影响):合计限额、ECOM、助手、手术室、评估冲突、费用标准、器官获取加成。
     */
    private void runAmountRules(List<DetailRow> rows, ExpertFeeCheckResult result) {
        // 规则 1~4、7~9:合计限额类(阻断),全部按项目 id 判定
        checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_ORGAN_MAINTAIN, "捐献医院器官功能维护费", FALLBACK_ORGAN_MAINTAIN),
                false,
@@ -321,13 +380,8 @@
        // 规则 5:仅提示;基本规则与器官获取加成:硬拦截
        checkAssessConflict(rows, result);
        checkItemStandard(rows, result);
        checkItemStandard(rows, result, true);
        checkDoctorOrganSecond(rows, result);
        log.info("[专家费校验] 校验完成 阻断={} 提示={} 阻断信息={} 提示信息={}",
                result.getErrors().size(), result.getWarnings().size(),
                result.getErrors(), result.getWarnings());
        return result;
    }
    /** 把参与校验的明细拼成一行,便于排查「为什么没拦住」(金额、项目、场景一目了然) */
@@ -626,6 +680,18 @@
     * 若拿税前金额(含税加价后的值)去比,会把大量正常单据误判为超标准(实测 3168 组 vs 162 组)。
     */
    private void checkItemStandard(List<DetailRow> rows, ExpertFeeCheckResult result) {
        checkItemStandard(rows, result, true);
    }
    /**
     * 费用标准校验。
     *
     * @param amountCheck {@code true}=完整校验(含金额类规则,受项目校验模式影响);
     *                    {@code false}=只查数据质量(金额为 0、未填写费用说明),
     *                    用于「校验提醒 / 不校验」模式下对未参与强制校验的明细做兜底强制,
     *                    避免出现金额为空的脏数据。
     */
    private void checkItemStandard(List<DetailRow> rows, ExpertFeeCheckResult result, boolean amountCheck) {
        Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
        Map<String, BigDecimal> groupStandards = new LinkedHashMap<>();
        Map<String, String> groupLabels = new LinkedHashMap<>();
@@ -644,9 +710,17 @@
            }
            BigDecimal standard = loadItemStandard(row.getItemid(), scope);
            if (standard == null) {
                if (!amountCheck) {
                    // 「未找到费用标准」属金额类问题,按项目校验模式处理
                    continue;
                }
                result.addError(String.format(
                        "%s未找到对应的费用标准(项目id=%s,费用说明=%s),不允许报销,请联系管理员维护费用标准",
                        itemLabel(row), row.getItemid(), scope));
                continue;
            }
            if (!amountCheck) {
                // 只查数据质量:不再做费用标准比较与重复录入提醒
                continue;
            }
            String key = groupKey(row, scope);
@@ -765,6 +839,64 @@
     *
     * @return 找不到时返回 {@code null},调用方按「匹配不到标准」阻断
     */
    /** 校验模式:强制校验(金额类问题不通过则不能保存) */
    private static final int MODE_FORCE = 1;
    /** 校验模式:校验提醒(金额类问题仅提醒,允许保存) */
    private static final int MODE_REMIND = 2;
    /** 校验模式:不校验(跳过该项目的金额类规则) */
    private static final int MODE_SKIP = 3;
    /** 项目校验模式缓存(60 秒),key = itemid */
    private final Map<Long, Integer> itemModeCache = new java.util.concurrent.ConcurrentHashMap<>();
    private final Map<Long, Long> itemModeExpireCache = new java.util.concurrent.ConcurrentHashMap<>();
    /**
     * 读取费用项目的校验模式({@code base_itemexpense.validate_mode}),缓存 60 秒。
     *
     * <p>读不到、取值非法或查询异常时**一律按「强制校验」处理**,
     * 保证不会因为开关读不到而放松校验(宁可多拦,不可漏拦)。
     */
    private int loadItemMode(Long itemid) {
        if (itemid == null) {
            return MODE_FORCE;
        }
        long now = System.currentTimeMillis();
        Integer cached = itemModeCache.get(itemid);
        Long expireAt = itemModeExpireCache.get(itemid);
        if (cached != null && expireAt != null && expireAt > now) {
            return cached;
        }
        int mode = MODE_FORCE;
        try {
            // 2026-10-15(修复):不能用 wrapper.select("ValidateMode") + selectMaps 取这一列,两处都会让模式取不到:
            //   ① 字符串列名会被 MyBatis-Plus 原样拼进 SELECT,而真实列名是 validate_mode,
            //      MySQL 直接报 Unknown column 'ValidateMode',异常被下面的 catch 吞掉后永远回落「强制校验」;
            //   ② 即使把列名改成 validate_mode,selectMaps 返回的 Map key 也是原始列标签(validate_mode,
            //      不会转成驼峰),与 "validatemode" 匹配不上,模式依然取不到。
            // 改为与同类 loadItemStandards 一致的实体映射写法(经实体字段 validateMode 映射到列 validate_mode)。
            QueryWrapper<BaseItemexpense> wrapper = new QueryWrapper<>();
            wrapper.eq("ItemId", itemid);
            wrapper.eq("UseState", "1");
            wrapper.apply("del_flag = 0");
            wrapper.orderByAsc("Id");
            List<BaseItemexpense> list = baseItemexpenseMapper.selectList(wrapper);
            if (!CollectionUtils.isEmpty(list) && list.get(0) != null
                    && list.get(0).getValidateMode() != null) {
                mode = list.get(0).getValidateMode();
            }
            if (mode != MODE_FORCE && mode != MODE_REMIND && mode != MODE_SKIP) {
                log.warn("[专家费校验] 项目校验模式取值非法,按强制校验处理,itemid={},validate_mode={}", itemid, mode);
                mode = MODE_FORCE;
            }
        } catch (Exception e) {
            log.error("[专家费校验] 读取项目校验模式失败,按强制校验处理,itemid={}", itemid, e);
            mode = MODE_FORCE;
        }
        itemModeCache.put(itemid, mode);
        itemModeExpireCache.put(itemid, now + ITEM_EXPENSE_CACHE_MILLIS);
        return mode;
    }
    private BigDecimal loadItemStandard(Long itemid, String scope) {
        return loadItemStandards(itemid).get(scope);
    }