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'),所有费用控制都是针对同一案例进行的累计控制。
*
*
校验时机(两处都调用):
*
* - 报销单填报保存前 —— ServiceFundServiceImpl#addOrUpdateNew
* - 报销单审核通过前 —— ServiceFundServiceImpl#checkFund
*
*
* 规则明细:
*
* - 捐献医院器官功能维护费(itemid=5) 合计 ≤ 60000(税前)
* - 上级捐献医院医疗团队器官功能维护费(itemid=7) 合计 ≤ 11000(税后)
* - 上级捐献医院护理团队器官功能维护费(itemid=8) 合计 ≤ 1000(税后)
* - ECOM团队劳务费(itemid=2):院内 ≤ 5000 / 市内 ≤ 10000 / 市外 ≤ 12000(按费用说明分档)
* - 同一专家不得同时领取「捐献医院初步评估费用(1)」「捐献病情评估专家费(18)」
* 「脑死亡临床判定专家费(19)」,只能就高取其一 —— 仅提示,不阻断
* - 基本规则(2026-10-08 新增),applytype=1 的全部明细:
* 同一案例 × 同一专家 × 同一项目 × 同一费用说明的合计不得超过
* base_itemexpense(del_flag=0 且 UseState=1)中该项目的费用标准;
* 金额为 0、费用说明为空、匹配不到标准,一律阻断;同组出现多条只提示不阻断
* - 器官获取加成(2026-10-08 重写,itemid 32~39):同一案例同一专家在该类项目上
* 获取多个器官时,按金额降序保高,第二个及以后的每条金额上限 =
* 费用说明含「双侧」取 1000、其他取 500(阻断,参数见 sys_config)
* - 助手费用:按助手类型(itemid 41、71、72、73、74)分别合计,市内 ≤ 1000 / 市外 ≤ 2000
* - 手术室人工费-巡回(itemid=43 且费用说明含“巡回”) ≤ 500
* - 手术室人工费-麻醉(itemid=43 且费用说明含“麻醉”) ≤ 500
*
*
* 金额一律使用 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 业务方确认):
*
* - 同一案例 × 同一专家 × 同一项目 × 同一费用说明的合计 ≤ 该项目费用标准;
* - 金额为 0 的明细一律阻断(明细金额不能为 0);
* - 费用说明为空、或匹配不到标准(含 UseState≠1、已删除),一律阻断;
* - 同一组出现多条时只提示不阻断(是否超限由上面的合计判定)。
*
*
* 金额口径:费用说明含「税后」的取税后金额(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 集合。
*
* 优先级:
*
* - sys_config 中显式配置的项目 id(逗号分隔)——项目名称不规范时用这个兜底
* - 按项目名称关键词到基础表解析(自动适应项目年度重建)
* - 早期年度的历史项目 id
*
* 仅按项目 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();
}
}