package com.ruoyi.project.service.impl; import com.ruoyi.project.domain.BaseFinancesubject; import com.ruoyi.project.domain.BaseItemexpense; import com.ruoyi.project.domain.ServiceFund; import com.ruoyi.project.domain.ServiceFunddetail; import com.ruoyi.project.domain.ServiceFunddetailVO; import com.ruoyi.project.domain.vo.ServiceFundVO; import com.ruoyi.project.mapper.BaseFinancesubjectMapper; import com.ruoyi.project.mapper.BaseItemexpenseMapper; import com.ruoyi.project.mapper.ServiceFundMapper; import com.ruoyi.project.mapper.ServiceFunddetailMapper; import com.ruoyi.project.service.ExpertFeeCheckResult; import com.ruoyi.project.service.ExpertFeeCheckResult.DetailRow; import com.ruoyi.system.service.ISysConfigService; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** * 专家劳务费校验服务(9 条规则) * *

业务背景:同一捐献案例(service_fund.infoid)可能有多条专家劳务费报销单 * (service_fund.applytype = '1'),所有费用控制都是针对同一案例进行的累计控制。 * *

校验时机(两处都调用): *

* *

规则明细: *

    *
  1. 捐献医院器官功能维护费(itemid=5) 合计 ≤ 60000(税前)
  2. *
  3. 上级捐献医院医疗团队器官功能维护费(itemid=7) 合计 ≤ 11000(税后)
  4. *
  5. 上级捐献医院护理团队器官功能维护费(itemid=8) 合计 ≤ 1000(税后)
  6. *
  7. ECOM团队劳务费(itemid=2):院内 ≤ 5000 / 市内 ≤ 10000 / 市外 ≤ 12000(按费用说明分档)
  8. *
  9. 同一专家不得同时领取「捐献医院初步评估费用(1)」「捐献病情评估专家费(18)」 * 「脑死亡临床判定专家费(19)」,只能就高取其一 —— 仅提示,不阻断
  10. *
  11. 基本规则(2026-10-08 新增),applytype=1 的全部明细: * 同一案例 × 同一专家 × 同一项目 × 同一费用说明的合计不得超过 * base_itemexpense(del_flag=0 且 UseState=1)中该项目的费用标准; * 金额为 0、费用说明为空、匹配不到标准,一律阻断;同组出现多条只提示不阻断
  12. *
  13. 器官获取加成(2026-10-08 重写,itemid 32~39):同一案例同一专家在该类项目上 * 获取多个器官时,按金额降序保高,第二个及以后的每条金额上限 = * 费用说明含「双侧」取 1000、其他取 500(阻断,参数见 sys_config)
  14. *
  15. 助手费用:按助手类型(itemid 41、71、72、73、74)分别合计,市内 ≤ 1000 / 市外 ≤ 2000
  16. *
  17. 手术室人工费-巡回(itemid=43 且费用说明含“巡回”) ≤ 500
  18. *
  19. 手术室人工费-麻醉(itemid=43 且费用说明含“麻醉”) ≤ 500
  20. *
* *

金额一律使用 BigDecimal 累加,禁止 double 运算(本项目此前出现过金额浮点精度问题)。 * * @author ruoyi */ @Slf4j @Service public class ExpertFeeValidateService { /** 专家劳务费的 applytype 取值 */ private static final String APPLYTYPE_EXPERT = "1"; /** 费用说明中的场景关键字(客户环境实际下拉文本为「院内院区 / 市内 / 市外」,同时兼容「本市 / 外市」旧写法) */ private static final String[] SCOPE_IN_HOSPITAL = {"院内"}; private static final String[] SCOPE_IN_CITY = {"市内", "本市"}; private static final String[] SCOPE_OUT_CITY = {"市外", "外市"}; private static final String SCOPE_TAXED = "税后"; private static final String SCOPE_CIRCUIT = "巡回"; private static final String SCOPE_ANESTHESIA = "麻醉"; /** 费用说明中的「双侧」关键字(肾、肺的获取区分单侧/双侧) */ private static final String SCOPE_DOUBLE_SIDE = "双侧"; // ---- 费用项目:全部按项目 id 判定 ---- // 规则不再依赖项目名称(名称每年带年度前缀、各院也可能不同), // 统一由 sys_config 的 sys.expertfee.itemid.* 配置项目 id(逗号分隔,可多个)。 // ---- 兼容历史年度的项目 id(项目表改造前使用) ---- // 与配置项求并集,保证历史单据仍能被识别;配置项是主要依据。 private static final Set FALLBACK_ORGAN_MAINTAIN = setOf(5L); private static final Set FALLBACK_SUPERIOR_MEDICAL = setOf(7L); private static final Set FALLBACK_SUPERIOR_NURSING = setOf(8L); private static final Set FALLBACK_ECOM = setOf(2L); private static final Set FALLBACK_OPERATION_ROOM = setOf(43L); private static final Set FALLBACK_ASSESS = setOf(1L, 18L, 19L); private static final Set FALLBACK_DOCTOR_ORGAN = setOf(32L, 33L, 34L, 35L, 36L, 37L, 38L, 39L); private static final Set FALLBACK_ASSISTANT = setOf(41L, 71L, 72L, 73L, 74L); // ---- sys_config 配置键 ---- private static final String CFG_ORGAN_MAINTAIN = "sys.expertfee.organMaintain.limit"; private static final String CFG_SUPERIOR_MEDICAL = "sys.expertfee.superiorMedical.limit"; private static final String CFG_SUPERIOR_NURSING = "sys.expertfee.superiorNursing.limit"; private static final String CFG_ECOM_IN_HOSPITAL = "sys.expertfee.ecom.limit.inhospital"; private static final String CFG_ECOM_IN_CITY = "sys.expertfee.ecom.limit.incity"; private static final String CFG_ECOM_OUT_CITY = "sys.expertfee.ecom.limit.outcity"; private static final String CFG_ASSISTANT_IN_CITY = "sys.expertfee.assistant.limit.incity"; private static final String CFG_ASSISTANT_OUT_CITY = "sys.expertfee.assistant.limit.outcity"; /** 手术室人工费-巡回:合计限额(itemid=43 且费用说明含「巡回」) */ private static final String CFG_OPERATION_ROOM_CIRCUIT = "sys.expertfee.operationroom.circuit.limit"; /** 手术室人工费-麻醉:合计限额(itemid=43 且费用说明含「麻醉」) */ private static final String CFG_OPERATION_ROOM_ANESTHESIA = "sys.expertfee.operationroom.anesthesia.limit"; /** * 第二个及以后的器官获取:双侧上限(itemid 32~39,费用说明含「双侧」)。 * 该阈值无法用 base_itemexpense 表达(标准表里记的是首个器官的足额标准),所以放 sys_config。 */ private static final String CFG_ORGAN_SECOND_DOUBLE = "sys.expertfee.organ.second.double.limit"; /** 第二个及以后的器官获取:其他(单侧、以及无单双侧概念的项目)上限 */ private static final String CFG_ORGAN_SECOND_OTHER = "sys.expertfee.organ.second.other.limit"; // ---- 费用项目 id 配置(逗号分隔,可多个)。全部规则统一按项目 id 判定; // 未配置时仅靠下方历史 id 兼容,对应规则会记 warn 日志(便于发现漏配) ---- private static final String CFG_ITEM_ORGAN_MAINTAIN = "sys.expertfee.itemid.organMaintain"; private static final String CFG_ITEM_SUPERIOR_MEDICAL = "sys.expertfee.itemid.superiorMedical"; private static final String CFG_ITEM_SUPERIOR_NURSING = "sys.expertfee.itemid.superiorNursing"; private static final String CFG_ITEM_ECOM = "sys.expertfee.itemid.ecom"; private static final String CFG_ITEM_OPERATION_ROOM = "sys.expertfee.itemid.operationRoom"; private static final String CFG_ITEM_ASSESS = "sys.expertfee.itemid.assess"; private static final String CFG_ITEM_ASSISTANT = "sys.expertfee.itemid.assistant"; private static final String CFG_ITEM_DOCTOR = "sys.expertfee.itemid.doctor"; // ---- 9 条规则校验总开关 ---- /** * 专家劳务费 9 条规则校验总开关,取值:1/true/y/on = 开启,0/false/n/off = 关闭。 * *

规则仍在与业务方确认中,2026-09-28 起暂时关闭; * 规则确认后把该配置改为 true 即可重新开启(无需改代码、无需发版)。 */ private static final String CFG_VALIDATE_ENABLED = "sys.expertfee.validate.enabled"; /** * 总开关缺省值:关闭。 * *

缺省取关闭,可保证即使 sys_config 漏配也不会误拦业务; * 规则确认后需显式配置为 true 才开启校验。 */ private static final boolean DEFAULT_VALIDATE_ENABLED = false; // ---- 配置缺省值(sys_config 未配置时兜底,避免校验失效) ---- private static final BigDecimal DEFAULT_ORGAN_MAINTAIN = new BigDecimal("60000"); private static final BigDecimal DEFAULT_SUPERIOR_MEDICAL = new BigDecimal("11000"); private static final BigDecimal DEFAULT_SUPERIOR_NURSING = new BigDecimal("1000"); private static final BigDecimal DEFAULT_ECOM_IN_HOSPITAL = new BigDecimal("5000"); private static final BigDecimal DEFAULT_ECOM_IN_CITY = new BigDecimal("10000"); private static final BigDecimal DEFAULT_ECOM_OUT_CITY = new BigDecimal("12000"); private static final BigDecimal DEFAULT_ASSISTANT_IN_CITY = new BigDecimal("1000"); private static final BigDecimal DEFAULT_ASSISTANT_OUT_CITY = new BigDecimal("2000"); /** 手术室人工费-巡回合计限额 */ private static final BigDecimal DEFAULT_OPERATION_ROOM_CIRCUIT = new BigDecimal("500"); /** 手术室人工费-麻醉合计限额 */ private static final BigDecimal DEFAULT_OPERATION_ROOM_ANESTHESIA = new BigDecimal("500"); /** 第二个及以后的器官获取:双侧上限缺省值 */ private static final BigDecimal DEFAULT_ORGAN_SECOND_DOUBLE = new BigDecimal("1000"); /** 第二个及以后的器官获取:其他上限缺省值 */ private static final BigDecimal DEFAULT_ORGAN_SECOND_OTHER = new BigDecimal("500"); @Autowired private ServiceFundMapper serviceFundMapper; @Autowired private ServiceFunddetailMapper serviceFunddetailMapper; /** 费用项目主表(base_financesubject),用于按项目名称动态解析项目 id */ @Autowired private BaseFinancesubjectMapper baseFinancesubjectMapper; /** 费用标准表(base_itemexpense):基本规则按「项目 id + 费用说明」取标准金额 */ @Autowired private BaseItemexpenseMapper baseItemexpenseMapper; @Autowired private ISysConfigService sysConfigService; /** 项目 id -> 项目名称(带缓存),仅用于拼接可读的错误提示文案 */ private volatile Map expertFeeItemNameCache; /** * 费用标准缓存:itemid -> (费用说明 -> 标准金额)。 * *

带 60 秒过期时间:标准表是可维护的基础数据,改了不必重启即可生效, * 同时避免一次校验里按项目反复查库。 */ private final Map itemExpenseCache = new ConcurrentHashMap<>(); /** 费用标准缓存有效期(毫秒) */ private static final long ITEM_EXPENSE_CACHE_MILLIS = 60 * 1000L; /** 费用标准缓存项 */ private static final class ItemExpenseEntry { private final Map standards; private final long expireAt; private ItemExpenseEntry(Map standards, long expireAt) { this.standards = standards; this.expireAt = expireAt; } } // ================================================================== // 对外入口 // ================================================================== /** * 报销单填报保存前的校验。 * *

此时本次提交的明细尚未落库,需要把「本次提交的明细」与「案例下已落库的其他单据明细」 * 合并后判定;本次提交的明细会按 id 覆盖库中同一条记录,避免重复计算。 * * @param serviceFundVO 本次提交的表单 * @return 校验结果(errors 阻断保存,warnings 仅提示) */ public ExpertFeeCheckResult validateBeforeSave(ServiceFundVO serviceFundVO) { if (!isValidateEnabled()) { log.info("[专家费校验-保存] 9 条规则校验总开关已关闭({}),跳过校验", CFG_VALIDATE_ENABLED); return new ExpertFeeCheckResult(); } if (serviceFundVO == null) { log.info("[专家费校验-保存] 入参为空,跳过"); return new ExpertFeeCheckResult(); } log.info("[专家费校验-保存] 开始 applytype={} infoid={} fundId={} 本次明细数={}", serviceFundVO.getApplytype(), serviceFundVO.getInfoid(), serviceFundVO.getId(), CollectionUtils.isEmpty(serviceFundVO.getServiceFunddetails()) ? 0 : serviceFundVO.getServiceFunddetails().size()); if (!isExpertFee(serviceFundVO.getApplytype())) { log.info("[专家费校验-保存] applytype={} 不是专家劳务费(1),跳过", serviceFundVO.getApplytype()); return new ExpertFeeCheckResult(); } Long infoid = serviceFundVO.getInfoid(); if (infoid == null) { log.warn("[专家费校验-保存] infoid 为空,无法做案例内累计,跳过(fundId={})", serviceFundVO.getId()); return new ExpertFeeCheckResult(); } List fundIds = listExpertFundIds(infoid); List storedRows = loadDetailRows(fundIds); log.info("[专家费校验-保存] infoid={} 命中专家费单据 {} 张,库中明细 {} 条", infoid, fundIds.size(), storedRows.size()); // 用本次提交的明细覆盖库中同 id 的记录(新增的追加进来) Map merged = new LinkedHashMap<>(); for (DetailRow row : storedRows) { merged.put(rowKey(row), row); } List submitDetails = serviceFundVO.getServiceFunddetails(); if (!CollectionUtils.isEmpty(submitDetails)) { for (ServiceFunddetailVO vo : submitDetails) { if (vo == null) { continue; } if (vo.getInfoid() == null) { vo.setInfoid(infoid); } merged.put(rowKey(vo), DetailRow.of(vo)); } } return doValidate(new ArrayList<>(merged.values())); } /** * 报销单审核通过前的校验。数据以数据库为准。 * * @param fundid 待审核的报销单 id * @return 校验结果 */ public ExpertFeeCheckResult validateBeforeCheck(Long fundid) { if (!isValidateEnabled()) { log.info("[专家费校验-审核] 9 条规则校验总开关已关闭({}),跳过校验", CFG_VALIDATE_ENABLED); return new ExpertFeeCheckResult(); } ServiceFund fund = serviceFundMapper.selectById(fundid); if (fund == null || !isExpertFee(applytypeOf(fund.getApplytype()))) { return new ExpertFeeCheckResult(); } if (fund.getInfoid() == null) { log.warn("专家劳务费审核校验跳过:infoid 为空,fundId={}", fundid); return new ExpertFeeCheckResult(); } return doValidate(loadDetailRows(listExpertFundIds(fund.getInfoid()))); } // ================================================================== // 校验主体 // ================================================================== private ExpertFeeCheckResult doValidate(List rows) { ExpertFeeCheckResult result = new ExpertFeeCheckResult(); if (CollectionUtils.isEmpty(rows)) { log.info("[专家费校验] 参与校验的明细为空,直接通过"); return result; } log.info("[专家费校验] 参与校验明细 {} 条:{}", rows.size(), describeRows(rows)); // 规则 1~4、7~9:合计限额类(阻断),全部按项目 id 判定 checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_ORGAN_MAINTAIN, "捐献医院器官功能维护费", FALLBACK_ORGAN_MAINTAIN), false, cfg(CFG_ORGAN_MAINTAIN, DEFAULT_ORGAN_MAINTAIN), result, "捐献医院器官功能维护费用"); checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_SUPERIOR_MEDICAL, "上级医疗团队器官功能维护费", FALLBACK_SUPERIOR_MEDICAL), true, cfg(CFG_SUPERIOR_MEDICAL, DEFAULT_SUPERIOR_MEDICAL), result, "上级捐献医院医疗团队器官功能维护费用"); checkSingleItemLimit(rows, resolveItemIds(CFG_ITEM_SUPERIOR_NURSING, "上级护理团队器官功能维护费", FALLBACK_SUPERIOR_NURSING), true, cfg(CFG_SUPERIOR_NURSING, DEFAULT_SUPERIOR_NURSING), result, "上级捐献医院护理团队器官功能维护费用"); checkEcom(rows, result); checkAssistant(rows, result); checkOperationRoom(rows, result); // 规则 5:仅提示;基本规则与器官获取加成:硬拦截 checkAssessConflict(rows, result); checkItemStandard(rows, result); checkDoctorOrganSecond(rows, result); log.info("[专家费校验] 校验完成 阻断={} 提示={} 阻断信息={} 提示信息={}", result.getErrors().size(), result.getWarnings().size(), result.getErrors(), result.getWarnings()); return result; } /** 把参与校验的明细拼成一行,便于排查「为什么没拦住」(金额、项目、场景一目了然) */ private String describeRows(List rows) { StringBuilder sb = new StringBuilder(); for (DetailRow row : rows) { if (sb.length() > 0) { sb.append(" | "); } sb.append("[fundid=").append(row.getFundid()) .append(" itemid=").append(row.getItemid()) .append(" 金额=").append(row.getAmount()) .append(" 税后=").append(row.getTaxedamount()) .append(" 场景=").append(row.getServicesscopename()) .append("]"); } return sb.toString(); } /** * 规则 1、2、3:单一项目合计限额。 * * @param taxed 金额是否取税后(规则 2、3 取税后,其余取税前) */ private void checkSingleItemLimit(List rows, Set itemids, boolean taxed, BigDecimal limit, ExpertFeeCheckResult result, String itemLabel) { List matched = new ArrayList<>(); BigDecimal total = BigDecimal.ZERO; for (DetailRow row : rows) { if (isTargetItem(row, itemids)) { matched.add(row); total = total.add(taxed ? row.getTaxedamount() : row.getAmount()); } } if (matched.isEmpty()) { return; } log.info("[专家费校验] {} 命中{}条 合计={} 限额={} 是否超限={}", itemLabel, matched.size(), stripZero(total), stripZero(limit), total.compareTo(limit) > 0); if (total.compareTo(limit) > 0) { result.addError(String.format( "%s合计%s元,超过限额%s元(案例内累计,共%d条明细)", itemLabel, stripZero(total), stripZero(limit), matched.size())); } } /** 规则 4:ECOM团队劳务费,按费用说明分院内/市内/市外三档 */ private void checkEcom(List rows, ExpertFeeCheckResult result) { BigDecimal inHospital = BigDecimal.ZERO; BigDecimal inCity = BigDecimal.ZERO; BigDecimal outCity = BigDecimal.ZERO; int count = 0; int unknownScopeRows = 0; Set ecomItemIds = resolveItemIds(CFG_ITEM_ECOM, "ECOM团队劳务费", FALLBACK_ECOM); for (DetailRow row : rows) { if (!isTargetItem(row, ecomItemIds)) { continue; } count++; if (row.scopeContainsAny(SCOPE_IN_HOSPITAL)) { inHospital = inHospital.add(row.getAmount()); } else if (row.scopeContainsAny(SCOPE_OUT_CITY)) { outCity = outCity.add(row.getAmount()); } else if (row.scopeContainsAny(SCOPE_IN_CITY)) { inCity = inCity.add(row.getAmount()); } else if (row.getAmount().compareTo(BigDecimal.ZERO) > 0) { // 场景无法识别时不归入任何档(避免误判),但不能静默放过,提示经办人核实 unknownScopeRows++; log.warn("[专家费校验] ECOM团队劳务费存在无法识别场景的明细:fundid={} itemid={} 费用说明={} 金额={}", row.getFundid(), row.getItemid(), row.getServicesscopename(), row.getAmount()); } } if (count == 0) { return; } if (unknownScopeRows > 0) { result.addWarning(String.format( "ECOM团队劳务费有%d条明细的费用说明不在「院内/市内/市外」范围内,未纳入限额校验,请核实", unknownScopeRows)); } checkLimit(result, "ECOM团队劳务费(院内)", inHospital, cfg(CFG_ECOM_IN_HOSPITAL, DEFAULT_ECOM_IN_HOSPITAL)); checkLimit(result, "ECOM团队劳务费(市内)", inCity, cfg(CFG_ECOM_IN_CITY, DEFAULT_ECOM_IN_CITY)); checkLimit(result, "ECOM团队劳务费(市外)", outCity, cfg(CFG_ECOM_OUT_CITY, DEFAULT_ECOM_OUT_CITY)); } /** * 规则 7:助手费用——按【助手类型(项目 id)】分别合计, * 每个类型各自:市内 ≤ 1000、外市 ≤ 2000(itemid 41/71/72/73/74)。 * *

2026-09-30 需求变更:原实现"不区分具体是哪个器官/哪个助手项目,所有助手明细合并累计" * (一个市内合计 + 一个外市合计),与原始需求「同个器官多个助手合计限额」不符,会大面积误拦 * (本地库 1004 个含助手明细的案例中,旧口径拦 647 个,按类型分开后只拦 75 个)。 * 现改为:同一案例、同一助手类型内累计,不同类型之间不互相占用额度; * 市内外仍由 servicesscopename(费用说明)判定。 */ private void checkAssistant(List rows, ExpertFeeCheckResult result) { BigDecimal inCityLimit = cfg(CFG_ASSISTANT_IN_CITY, DEFAULT_ASSISTANT_IN_CITY); BigDecimal outCityLimit = cfg(CFG_ASSISTANT_OUT_CITY, DEFAULT_ASSISTANT_OUT_CITY); Set assistantItemIds = resolveItemIds(CFG_ITEM_ASSISTANT, "助手费用", FALLBACK_ASSISTANT); // key = itemid(助手类型),LinkedHashMap + 末尾按 itemid 排序,保证提示顺序稳定 Map groups = new LinkedHashMap<>(); int unknownScopeCount = 0; for (DetailRow row : rows) { if (!isAssistantItem(row, assistantItemIds)) { continue; } boolean outCity = row.scopeContainsAny(SCOPE_OUT_CITY); boolean inCity = !outCity && row.scopeContainsAny(SCOPE_IN_CITY); if (!outCity && !inCity) { // 未标注市内外的不归入任何档,避免误判(仅记日志,不打扰业务) unknownScopeCount++; continue; } AssistantTotal group = groups.computeIfAbsent(row.getItemid(), key -> new AssistantTotal(key, itemNameOf(row))); if (outCity) { group.outCity = group.outCity.add(row.getAmount()); group.outCityCount++; } else { group.inCity = group.inCity.add(row.getAmount()); group.inCityCount++; } } List ordered = new ArrayList<>(groups.values()); ordered.sort((a, b) -> { if (a.itemid == null) { return b.itemid == null ? 0 : 1; } return b.itemid == null ? -1 : a.itemid.compareTo(b.itemid); }); for (AssistantTotal group : ordered) { log.info("[专家费校验] 助手费用[{}] 市内{}条合计={} 限额={};外市{}条合计={} 限额={}", group.itemName, group.inCityCount, stripZero(group.inCity), stripZero(inCityLimit), group.outCityCount, stripZero(group.outCity), stripZero(outCityLimit)); if (group.inCityCount > 0 && group.inCity.compareTo(inCityLimit) > 0) { result.addError(String.format( "助手费用(%s,市内)合计%s元,超过限额%s元(同一案例下该类型助手合计,共%d条)", group.itemName, stripZero(group.inCity), stripZero(inCityLimit), group.inCityCount)); } if (group.outCityCount > 0 && group.outCity.compareTo(outCityLimit) > 0) { result.addError(String.format( "助手费用(%s,外市)合计%s元,超过限额%s元(同一案例下该类型助手合计,共%d条)", group.itemName, stripZero(group.outCity), stripZero(outCityLimit), group.outCityCount)); } } if (unknownScopeCount > 0) { log.warn("[专家费校验] 助手费用有{}条明细的费用说明既不含市内/本市也不含市外/外市,未纳入限额校验", unknownScopeCount); } } /** 规则 7 的累计单元:同一助手类型(项目 id)在同一案例下的市内/外市合计 */ private static final class AssistantTotal { private final Long itemid; private final String itemName; private BigDecimal inCity = BigDecimal.ZERO; private int inCityCount; private BigDecimal outCity = BigDecimal.ZERO; private int outCityCount; private AssistantTotal(Long itemid, String itemName) { this.itemid = itemid; this.itemName = itemName; } } /** * 规则 8、9:手术室人工费按费用说明分档,各自独立限额 —— * 「巡回」合计 ≤ 500、「麻醉」合计 ≤ 500(itemid=43)。 * *

2026-09-30 需求变更:原实现把巡回与麻醉合并成一个 500 的总限额, * 现按业务要求拆成两档各自校验(如巡回 300 + 麻醉 300 不再判超限)。 * *

费用说明既不含「巡回」也不含「麻醉」的明细不计入任何档,仅给提示; * 同时含两个词的明细分别计入两档(并记 warn 日志)。 */ private void checkOperationRoom(List rows, ExpertFeeCheckResult result) { Set roomItemIds = resolveItemIds(CFG_ITEM_OPERATION_ROOM, "手术室人工费", FALLBACK_OPERATION_ROOM); BigDecimal circuitTotal = BigDecimal.ZERO; int circuitCount = 0; BigDecimal anesthesiaTotal = BigDecimal.ZERO; int anesthesiaCount = 0; int unknownScopeCount = 0; int bothScopeCount = 0; for (DetailRow row : rows) { if (!isTargetItem(row, roomItemIds)) { continue; } boolean circuit = row.scopeContains(SCOPE_CIRCUIT); boolean anesthesia = row.scopeContains(SCOPE_ANESTHESIA); if (circuit && anesthesia) { bothScopeCount++; } if (circuit) { circuitTotal = circuitTotal.add(row.getAmount()); circuitCount++; } if (anesthesia) { anesthesiaTotal = anesthesiaTotal.add(row.getAmount()); anesthesiaCount++; } if (!circuit && !anesthesia) { unknownScopeCount++; } } if (bothScopeCount > 0) { log.warn("[专家费校验] 手术室人工费有{}条明细的费用说明同时含「巡回」与「麻醉」,已分别计入两档", bothScopeCount); } BigDecimal circuitLimit = cfg(CFG_OPERATION_ROOM_CIRCUIT, DEFAULT_OPERATION_ROOM_CIRCUIT); if (circuitCount > 0) { log.info("[专家费校验] 手术室人工费(巡回) 命中{}条 合计={} 限额={} 是否超限={}", circuitCount, stripZero(circuitTotal), stripZero(circuitLimit), circuitTotal.compareTo(circuitLimit) > 0); if (circuitTotal.compareTo(circuitLimit) > 0) { result.addError(String.format( "手术室人工费(巡回)合计%s元,超过限额%s元(同一案例下合计,共%d条)", stripZero(circuitTotal), stripZero(circuitLimit), circuitCount)); } } BigDecimal anesthesiaLimit = cfg(CFG_OPERATION_ROOM_ANESTHESIA, DEFAULT_OPERATION_ROOM_ANESTHESIA); if (anesthesiaCount > 0) { log.info("[专家费校验] 手术室人工费(麻醉) 命中{}条 合计={} 限额={} 是否超限={}", anesthesiaCount, stripZero(anesthesiaTotal), stripZero(anesthesiaLimit), anesthesiaTotal.compareTo(anesthesiaLimit) > 0); if (anesthesiaTotal.compareTo(anesthesiaLimit) > 0) { result.addError(String.format( "手术室人工费(麻醉)合计%s元,超过限额%s元(同一案例下合计,共%d条)", stripZero(anesthesiaTotal), stripZero(anesthesiaLimit), anesthesiaCount)); } } if (unknownScopeCount > 0) { result.addWarning(String.format( "手术室人工费有%d条明细的费用说明既不含「巡回」也不含「麻醉」,未纳入限额校验,请核实", unknownScopeCount)); } } /** * 规则 5:同一专家不得同时在「捐献医院初步评估费用」「捐献病情评估专家费」 * 「脑死亡临床判定专家费」中出现,只能就高取其一。 * *

硬拦截:违反时不允许保存/审核通过,不自动删除也不自动改数。 */ private void checkAssessConflict(List rows, ExpertFeeCheckResult result) { Set assessItemIds = resolveItemIds(CFG_ITEM_ASSESS, "评估类费用", FALLBACK_ASSESS); Map> expertItems = new LinkedHashMap<>(); for (DetailRow row : rows) { if (!isTargetItem(row, assessItemIds)) { continue; } String expertKey = expertKey(row); if (expertKey == null) { continue; } // 记录命中的项目及名称,便于提示时告知具体是哪几个项目 expertItems.computeIfAbsent(expertKey, k -> new LinkedHashMap<>()) .put(row.getItemid(), itemNameOf(row)); } for (Map.Entry> entry : expertItems.entrySet()) { if (entry.getValue().size() > 1) { result.addError(String.format( "专家[%s]同时在 %s 等多个评估类项目中领取费用,同一专家只能就高取其一,请删除其余条明细", entry.getKey(), entry.getValue().values())); } } } // ================================================================== // 基本规则(2026-10-08 新增):单项金额不得超过 base_itemexpense 的费用标准 // ================================================================== /** * 基本规则:applytype=1(专家劳务费)的全部明细逐条与费用标准比对。 * *

判据(2026-10-08 业务方确认): *

    *
  1. 同一案例 × 同一专家 × 同一项目 × 同一费用说明的合计 ≤ 该项目费用标准;
  2. *
  3. 金额为 0 的明细一律阻断(明细金额不能为 0);
  4. *
  5. 费用说明为空、或匹配不到标准(含 UseState≠1、已删除),一律阻断;
  6. *
  7. 同一组出现多条时只提示不阻断(是否超限由上面的合计判定)。
  8. *
* *

金额口径:费用说明含「税后」的取税后金额(taxedamount),其余取税前金额(amount)。 * 标准表里「税后」类标准的 Expense 本身就是税后口径, * 若拿税前金额(含税加价后的值)去比,会把大量正常单据误判为超标准(实测 3168 组 vs 162 组)。 */ private void checkItemStandard(List rows, ExpertFeeCheckResult result) { Map> groups = new LinkedHashMap<>(); Map groupStandards = new LinkedHashMap<>(); Map groupLabels = new LinkedHashMap<>(); for (DetailRow row : rows) { BigDecimal amount = effectiveAmount(row); if (amount.compareTo(BigDecimal.ZERO) <= 0) { result.addError(String.format("%s金额为%s元,明细金额不能为0,请填写金额或删除该条明细", itemLabel(row), stripZero(amount))); continue; } String scope = scopeOf(row); if (scope.isEmpty()) { result.addError(String.format("%s未填写费用说明,无法校验费用标准,请补充费用说明", itemLabel(row))); continue; } BigDecimal standard = loadItemStandard(row.getItemid(), scope); if (standard == null) { result.addError(String.format( "%s未找到对应的费用标准(项目id=%s,费用说明=%s),不允许报销,请联系管理员维护费用标准", itemLabel(row), row.getItemid(), scope)); continue; } String key = groupKey(row, scope); groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row); groupStandards.putIfAbsent(key, standard); groupLabels.putIfAbsent(key, groupLabel(row)); } for (Map.Entry> entry : groups.entrySet()) { String key = entry.getKey(); List list = entry.getValue(); BigDecimal total = BigDecimal.ZERO; for (DetailRow row : list) { total = total.add(effectiveAmount(row)); } BigDecimal standard = groupStandards.get(key); String label = groupLabels.get(key); log.info("[专家费校验] 基本规则 {} 命中{}条 合计={} 标准={} 是否超标={}", label, list.size(), stripZero(total), stripZero(standard), total.compareTo(standard) > 0); if (total.compareTo(standard) > 0) { result.addError(String.format( "%s合计%s元,超过费用标准%s元(同一案例同一专家同一项目的累计,共%d条明细)", label, stripZero(total), stripZero(standard), list.size())); } if (list.size() > 1) { result.addWarning(String.format("%s在同一案例下有%d条明细(合计%s元),请确认是否重复录入", label, list.size(), stripZero(total))); } } } /** * 器官获取加成(2026-10-08 重写,取代原「同一专家只允许一条超过 500」)。 * *

同一案例内,同一专家在医生获取/修整类项目(默认 itemid 32~39)上获取多个器官时: * 按金额降序保高,第二个及以后的每条金额上限 = 费用说明含「双侧」取 1000、其他取 500。 * 第一个(金额最高的)那条按费用标准校验,由上面的「基本规则」负责。 * *

该阈值无法用 base_itemexpense 表达(标准表里记的是首个器官的足额标准),因此放 sys_config。 */ private void checkDoctorOrganSecond(List rows, ExpertFeeCheckResult result) { BigDecimal doubleSideLimit = cfg(CFG_ORGAN_SECOND_DOUBLE, DEFAULT_ORGAN_SECOND_DOUBLE); BigDecimal otherLimit = cfg(CFG_ORGAN_SECOND_OTHER, DEFAULT_ORGAN_SECOND_OTHER); Set doctorItemIds = resolveItemIds(CFG_ITEM_DOCTOR, "医生获取/修整类费用", FALLBACK_DOCTOR_ORGAN); Map> byExpert = new LinkedHashMap<>(); for (DetailRow row : rows) { if (!isDoctorOrganItem(row, doctorItemIds)) { continue; } String expertKey = expertKey(row); if (expertKey == null) { continue; } byExpert.computeIfAbsent(expertKey, k -> new ArrayList<>()).add(row); } for (Map.Entry> entry : byExpert.entrySet()) { List list = new ArrayList<>(entry.getValue()); if (list.size() < 2) { continue; } // 金额降序保高:最高的那条按费用标准(基本规则已校验),其余按 500/1000 list.sort((a, b) -> effectiveAmount(b).compareTo(effectiveAmount(a))); log.info("[专家费校验] 器官获取加成 专家={} 器官获取明细{}条 上限(双侧/其他)={}/{}", entry.getKey(), list.size(), stripZero(doubleSideLimit), stripZero(otherLimit)); for (int i = 1; i < list.size(); i++) { DetailRow row = list.get(i); BigDecimal amount = effectiveAmount(row); BigDecimal limit = row.scopeContains(SCOPE_DOUBLE_SIDE) ? doubleSideLimit : otherLimit; if (amount.compareTo(limit) > 0) { result.addError(String.format( "专家[%s]的第%d个器官获取%s金额%s元,超过上限%s元" + "(同一案例中第二个及以后的器官获取:双侧上限%s元、其他上限%s元),请调整金额", entry.getKey(), i + 1, itemLabel(row), stripZero(amount), stripZero(limit), stripZero(doubleSideLimit), stripZero(otherLimit))); } } } } /** 明细的「有效金额」:费用说明含「税后」取税后金额,其余取税前金额 */ private BigDecimal effectiveAmount(DetailRow row) { return row.scopeContains(SCOPE_TAXED) ? row.getTaxedamount() : row.getAmount(); } /** 明细的费用说明(去空格) */ private String scopeOf(DetailRow row) { return row.getServicesscopename() == null ? "" : row.getServicesscopename().trim(); } /** 分组键:专家 + 项目 + 费用说明(案例范围已由 rows 限定) */ private String groupKey(DetailRow row, String scope) { String expert = row.getIdcardno() == null ? "" : row.getIdcardno().trim(); return expert + "|" + row.getItemid() + "|" + scope; } /** 提示文案:「项目·费用说明」 */ private String itemLabel(DetailRow row) { String scope = scopeOf(row); return "「" + itemNameOf(row) + (scope.isEmpty() ? "" : "·" + scope) + "」"; } /** 提示文案:专家[身份证]「项目·费用说明」 */ private String groupLabel(DetailRow row) { String expert = row.getIdcardno() == null ? "" : row.getIdcardno().trim(); return (expert.isEmpty() ? "" : "专家[" + expert + "]") + itemLabel(row); } /** * 取「项目 id + 费用说明」的费用标准。 * *

标准表是可维护的基础数据:只认 {@code del_flag = 0 且 UseState = 1} 的记录 * (实体没有 delFlag 字段,因此 del_flag 用 SQL 片段限定)。 * * @return 找不到时返回 {@code null},调用方按「匹配不到标准」阻断 */ private BigDecimal loadItemStandard(Long itemid, String scope) { return loadItemStandards(itemid).get(scope); } /** 按项目 id 加载费用标准(结果缓存 60 秒),key = 费用说明 */ private Map loadItemStandards(Long itemid) { if (itemid == null) { return Collections.emptyMap(); } long now = System.currentTimeMillis(); ItemExpenseEntry cached = itemExpenseCache.get(itemid); if (cached != null && cached.expireAt > now) { return cached.standards; } Map standards = new LinkedHashMap<>(); try { // 用字符串列名包装器:BaseItemexpense 实体没有 delFlag 字段(无法用 lambda 引用), // 且可避免依赖 MyBatis-Plus 的 lambda 缓存 QueryWrapper wrapper = new QueryWrapper<>(); wrapper.eq("ItemId", itemid); wrapper.eq("UseState", "1"); wrapper.apply("del_flag = 0"); wrapper.orderByAsc("Id"); List list = baseItemexpenseMapper.selectList(wrapper); if (list != null) { for (BaseItemexpense item : list) { if (item == null || item.getExpensedescribe() == null || item.getExpense() == null) { continue; } standards.putIfAbsent(item.getExpensedescribe().trim(), item.getExpense()); } } } catch (Exception e) { log.error("[专家费校验] 读取费用标准失败,itemid={}", itemid, e); } itemExpenseCache.put(itemid, new ItemExpenseEntry(standards, now + ITEM_EXPENSE_CACHE_MILLIS)); return standards; } // ================================================================== // 费用项目动态解析 // // 背景:费用项目表 base_financesubject 的项目按年度重建(例:“(2026)捐献医院器官功能维护费用” // 是 id=75,而早期年度为 5),因此不能把 itemid 写死在代码里。 // 做法:按项目名称关键词到基础表解析当前有效的项目 id,再与“早期年度 id”求并集。 // ================================================================== /** * 解析规则对应的费用项目 id 集合。 * *

优先级: *

    *
  1. sys_config 中显式配置的项目 id(逗号分隔)——项目名称不规范时用这个兜底
  2. *
  3. 按项目名称关键词到基础表解析(自动适应项目年度重建)
  4. *
  5. 早期年度的历史项目 id
  6. *
* 仅按项目 id 判定:取 sys_config 中配置的项目 id 列表,与历史年度 id 求并集。 * 配置为空且无历史 id 时返回空集,此时该规则不生效并会记 warn 日志。 * * @param itemIdConfigKey sys_config 中项目 id 列表的配置键 * @param ruleLabel 规则名称,仅用于日志可读性 * @param fallback 历史年度(项目表改造前)使用的项目 id */ private Set resolveItemIds(String itemIdConfigKey, String ruleLabel, Set fallback) { Set ids = new LinkedHashSet<>(); ids.addAll(parseItemIdConfig(itemIdConfigKey)); if (fallback != null) { ids.addAll(fallback); } if (ids.isEmpty()) { log.warn("[专家费校验] 规则[{}]未配置项目id(sys_config.{}),该规则本次不生效", ruleLabel, itemIdConfigKey); } return ids; } /** 读取 sys_config 中逗号分隔的项目 id 列表(未配置或格式错误时返回空集) */ private Set parseItemIdConfig(String configKey) { Set ids = new LinkedHashSet<>(); if (configKey == null) { return ids; } try { String value = sysConfigService.selectConfigByKey(configKey); if (StringUtils.isEmpty(value)) { return ids; } for (String part : value.split(",")) { String trimmed = part.trim(); if (StringUtils.isEmpty(trimmed)) { continue; } try { ids.add(Long.valueOf(trimmed)); } catch (NumberFormatException e) { log.warn("[专家费校验] 项目id配置[{}]中存在非法值“{}”,已忽略", configKey, trimmed); } } } catch (Exception e) { log.warn("[专家费校验] 读取项目id配置失败,key={}", configKey, e); } return ids; } /** 明细行是否属于给定项目 id 集合(全部规则统一按项目 id 判定) */ private boolean isTargetItem(DetailRow row, Set itemids) { return !CollectionUtils.isEmpty(itemids) && row.getItemid() != null && itemids.contains(row.getItemid()); } /** 规则 6:医生“XX获取/修整”类项目(仅按项目 id) */ private boolean isDoctorOrganItem(DetailRow row, Set itemids) { return isTargetItem(row, itemids); } /** 规则 7:助手类项目(仅按项目 id) */ private boolean isAssistantItem(DetailRow row, Set itemids) { return isTargetItem(row, itemids); } /** 取明细的项目名称(明细未携带时回查基础表),仅用于提示文案 */ private String itemNameOf(DetailRow row) { String name = row.getItemname(); if (StringUtils.hasText(name)) { return name; } if (row.getItemid() != null) { String cached = loadExpertFeeItemNames().get(row.getItemid()); if (StringUtils.hasText(cached)) { return cached; } } return "itemid=" + row.getItemid(); } /** 项目 id -> 项目名称(带缓存),仅用于明细未携带名称时回查、拼接提示文案 */ private Map loadExpertFeeItemNames() { Map cache = this.expertFeeItemNameCache; if (cache != null) { return cache; } synchronized (this) { if (this.expertFeeItemNameCache == null) { Map names = new LinkedHashMap<>(); for (BaseFinancesubject subject : loadExpertFeeItems()) { if (subject.getId() != null && subject.getItemname() != null) { names.put(subject.getId(), subject.getItemname()); } } this.expertFeeItemNameCache = names; cache = names; } else { cache = this.expertFeeItemNameCache; } } return cache; } /** 读取专家劳务费(费用大类=1)的全部在用项目 */ private List loadExpertFeeItems() { List items = baseFinancesubjectMapper.getAllInfoByExpenseType(APPLYTYPE_EXPERT); return CollectionUtils.isEmpty(items) ? new ArrayList<>() : items; } /** 字面量集合构造(供 static final 常量使用) */ private static Set setOf(Long... values) { Set set = new LinkedHashSet<>(); if (values != null) { for (Long value : values) { set.add(value); } } return set; } // ================================================================== // 工具方法 // ================================================================== private void checkLimit(ExpertFeeCheckResult result, String label, BigDecimal total, BigDecimal limit) { if (total.compareTo(BigDecimal.ZERO) > 0 && total.compareTo(limit) > 0) { result.addError(String.format("%s合计%s元,超过限额%s元(案例内累计)", label, stripZero(total), stripZero(limit))); } } /** 取该案例下全部专家劳务费报销单的 id */ private List listExpertFundIds(Long infoid) { List funds = serviceFundMapper.getInfoByInfoId(infoid); if (CollectionUtils.isEmpty(funds)) { return Collections.emptyList(); } return funds.stream() .filter(f -> f != null && isExpertFee(applytypeOf(f.getApplytype()))) .map(ServiceFund::getId) .filter(java.util.Objects::nonNull) .distinct() .collect(Collectors.toList()); } /** 按报销单 id 加载明细(XML 中已带 del_flag = '0') */ private List loadDetailRows(List fundIds) { if (CollectionUtils.isEmpty(fundIds)) { return Collections.emptyList(); } List rows = new ArrayList<>(); for (Long fundId : fundIds) { List details = serviceFunddetailMapper.selectFundDetailListById(fundId); if (CollectionUtils.isEmpty(details)) { continue; } for (ServiceFunddetail detail : details) { DetailRow row = new DetailRow(); row.setId(detail.getId()); row.setFundid(detail.getFundid()); row.setInfoid(detail.getInfoid()); row.setItemid(detail.getItemid()); row.setItemname(detail.getItemname()); row.setIdcardno(detail.getIdcardno()); row.setServicesscopename(detail.getServicesscopename()); row.setAmount(toDecimal(detail.getAmount())); row.setTaxedamount(toDecimal(detail.getTaxedamount())); rows.add(row); } } return rows; } /** * 明细行的唯一键。 * *

已落库的明细用主键 id;新增(尚无 id)的用 * 「报销单 + 项目 + 身份证 + 费用说明 + 金额」组合, * 确保同一项目下的多行明细不会被合并(否则会漏算或重复计算)。 */ private String rowKey(DetailRow row) { if (row.getId() != null) { return "id:" + row.getId(); } return "new:" + row.getFundid() + "|" + row.getItemid() + "|" + row.getIdcardno() + "|" + row.getServicesscopename() + "|" + row.getAmount(); } private String rowKey(ServiceFunddetailVO vo) { if (vo.getId() != null) { return "id:" + vo.getId(); } // 新增明细尚无 id:用「报销单 + 项目 + 身份证 + 费用说明 + 金额」近似定位,保证同一项目多行不会被合并 return "new:" + vo.getFundid() + "|" + vo.getItemid() + "|" + vo.getIdcardno() + "|" + vo.getServicesscopename() + "|" + vo.getAmount(); } /** 专家的识别键:优先身份证号,其次受益人编号,都没有时跳过该行 */ private String expertKey(DetailRow row) { if (StringUtils.hasText(row.getIdcardno())) { return row.getIdcardno().trim(); } return null; } private boolean isExpertFee(String applytype) { return APPLYTYPE_EXPERT.equals(applytype); } private String applytypeOf(String applytype) { return applytype; } /** * 9 条规则校验是否开启。 * *

关闭时所有入口直接返回"通过"(既不做库查询,也不产生 errors/warnings), * 前端预校验、保存、审核三条链路同时放行。 */ private boolean isValidateEnabled() { try { String value = sysConfigService.selectConfigByKey(CFG_VALIDATE_ENABLED); if (StringUtils.hasText(value)) { String v = value.trim(); return "1".equals(v) || "true".equalsIgnoreCase(v) || "y".equalsIgnoreCase(v) || "on".equalsIgnoreCase(v); } } catch (Exception e) { log.warn("读取专家劳务费校验总开关失败,key={},按默认值({})处理", CFG_VALIDATE_ENABLED, DEFAULT_VALIDATE_ENABLED, e); } return DEFAULT_VALIDATE_ENABLED; } private BigDecimal cfg(String key, BigDecimal defaultValue) { try { String value = sysConfigService.selectConfigByKey(key); if (StringUtils.hasText(value)) { return new BigDecimal(value.trim()); } } catch (Exception e) { log.warn("读取专家劳务费限额配置失败,key={},使用默认值{}", key, defaultValue, e); } return defaultValue; } private static BigDecimal toDecimal(Double value) { return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value); } /** 去掉金额末尾多余的 0,便于提示文案阅读(60000.00 -> 60000) */ private static String stripZero(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); } }