| | |
| | | } |
| | | 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, |
| | |
| | | |
| | | // 规则 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; |
| | | } |
| | | |
| | | /** 把参与校验的明细拼成一行,便于排查「为什么没拦住」(金额、项目、场景一目了然) */ |
| | |
| | | * 若拿税前金额(含税加价后的值)去比,会把大量正常单据误判为超标准(实测 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<>(); |
| | |
| | | } |
| | | 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); |
| | |
| | | * |
| | | * @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); |
| | | } |