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 条规则)
|
*
|
* <p>业务背景:同一捐献案例(service_fund.infoid)可能有多条专家劳务费报销单
|
* (service_fund.applytype = '1'),所有费用控制都是针对同一案例进行的累计控制。
|
*
|
* <p>校验时机(两处都调用):
|
* <ul>
|
* <li>报销单填报保存前 —— ServiceFundServiceImpl#addOrUpdateNew</li>
|
* <li>报销单审核通过前 —— ServiceFundServiceImpl#checkFund</li>
|
* </ul>
|
*
|
* <p>规则明细:
|
* <ol>
|
* <li>捐献医院器官功能维护费(itemid=5) 合计 ≤ 60000(税前)</li>
|
* <li>上级捐献医院医疗团队器官功能维护费(itemid=7) 合计 ≤ 11000(税后)</li>
|
* <li>上级捐献医院护理团队器官功能维护费(itemid=8) 合计 ≤ 1000(税后)</li>
|
* <li>ECOM团队劳务费(itemid=2):院内 ≤ 5000 / 市内 ≤ 10000 / 市外 ≤ 12000(按费用说明分档)</li>
|
* <li>同一专家不得同时领取「捐献医院初步评估费用(1)」「捐献病情评估专家费(18)」
|
* 「脑死亡临床判定专家费(19)」,只能就高取其一 —— 仅提示,不阻断</li>
|
* <li><b>基本规则(2026-10-08 新增)</b>,applytype=1 的<b>全部明细</b>:
|
* 同一案例 × 同一专家 × 同一项目 × 同一费用说明的<b>合计</b>不得超过
|
* base_itemexpense(del_flag=0 且 UseState=1)中该项目的费用标准;
|
* 金额为 0、费用说明为空、匹配不到标准,一律阻断;同组出现多条只提示不阻断</li>
|
* <li>器官获取加成(2026-10-08 重写,itemid 32~39):同一案例同一专家在该类项目上
|
* 获取多个器官时,按金额降序保高,第二个及以后的每条金额上限 =
|
* 费用说明含「双侧」取 1000、其他取 500(阻断,参数见 sys_config)</li>
|
* <li>助手费用:按助手类型(itemid 41、71、72、73、74)<b>分别</b>合计,市内 ≤ 1000 / 市外 ≤ 2000</li>
|
* <li>手术室人工费-巡回(itemid=43 且费用说明含“巡回”) ≤ 500</li>
|
* <li>手术室人工费-麻醉(itemid=43 且费用说明含“麻醉”) ≤ 500</li>
|
* </ol>
|
*
|
* <p>金额一律使用 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<Long> FALLBACK_ORGAN_MAINTAIN = setOf(5L);
|
private static final Set<Long> FALLBACK_SUPERIOR_MEDICAL = setOf(7L);
|
private static final Set<Long> FALLBACK_SUPERIOR_NURSING = setOf(8L);
|
private static final Set<Long> FALLBACK_ECOM = setOf(2L);
|
private static final Set<Long> FALLBACK_OPERATION_ROOM = setOf(43L);
|
private static final Set<Long> FALLBACK_ASSESS = setOf(1L, 18L, 19L);
|
private static final Set<Long> FALLBACK_DOCTOR_ORGAN = setOf(32L, 33L, 34L, 35L, 36L, 37L, 38L, 39L);
|
private static final Set<Long> 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 = 关闭。
|
*
|
* <p>规则仍在与业务方确认中,2026-09-28 起<b>暂时关闭</b>;
|
* 规则确认后把该配置改为 true 即可重新开启(无需改代码、无需发版)。
|
*/
|
private static final String CFG_VALIDATE_ENABLED = "sys.expertfee.validate.enabled";
|
/**
|
* 总开关缺省值:<b>关闭</b>。
|
*
|
* <p>缺省取关闭,可保证即使 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<Long, String> expertFeeItemNameCache;
|
|
/**
|
* 费用标准缓存:itemid -> (费用说明 -> 标准金额)。
|
*
|
* <p>带 60 秒过期时间:标准表是可维护的基础数据,改了不必重启即可生效,
|
* 同时避免一次校验里按项目反复查库。
|
*/
|
private final Map<Long, ItemExpenseEntry> itemExpenseCache = new ConcurrentHashMap<>();
|
|
/** 费用标准缓存有效期(毫秒) */
|
private static final long ITEM_EXPENSE_CACHE_MILLIS = 60 * 1000L;
|
|
/** 费用标准缓存项 */
|
private static final class ItemExpenseEntry {
|
private final Map<String, BigDecimal> standards;
|
private final long expireAt;
|
|
private ItemExpenseEntry(Map<String, BigDecimal> standards, long expireAt) {
|
this.standards = standards;
|
this.expireAt = expireAt;
|
}
|
}
|
|
// ==================================================================
|
// 对外入口
|
// ==================================================================
|
|
/**
|
* 报销单填报保存前的校验。
|
*
|
* <p>此时本次提交的明细尚未落库,需要把「本次提交的明细」与「案例下已落库的其他单据明细」
|
* 合并后判定;本次提交的明细会按 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<Long> fundIds = listExpertFundIds(infoid);
|
List<DetailRow> storedRows = loadDetailRows(fundIds);
|
log.info("[专家费校验-保存] infoid={} 命中专家费单据 {} 张,库中明细 {} 条",
|
infoid, fundIds.size(), storedRows.size());
|
|
// 用本次提交的明细覆盖库中同 id 的记录(新增的追加进来)
|
Map<String, DetailRow> merged = new LinkedHashMap<>();
|
for (DetailRow row : storedRows) {
|
merged.put(rowKey(row), row);
|
}
|
List<ServiceFunddetailVO> 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<DetailRow> 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<DetailRow> 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<DetailRow> rows, Set<Long> itemids, boolean taxed,
|
BigDecimal limit, ExpertFeeCheckResult result, String itemLabel) {
|
List<DetailRow> 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<DetailRow> rows, ExpertFeeCheckResult result) {
|
BigDecimal inHospital = BigDecimal.ZERO;
|
BigDecimal inCity = BigDecimal.ZERO;
|
BigDecimal outCity = BigDecimal.ZERO;
|
int count = 0;
|
int unknownScopeRows = 0;
|
Set<Long> 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)。
|
*
|
* <p>2026-09-30 需求变更:原实现"不区分具体是哪个器官/哪个助手项目,所有助手明细合并累计"
|
* (一个市内合计 + 一个外市合计),与原始需求「同个器官多个助手合计限额」不符,会大面积误拦
|
* (本地库 1004 个含助手明细的案例中,旧口径拦 647 个,按类型分开后只拦 75 个)。
|
* 现改为:同一案例、同一助手类型内累计,不同类型之间不互相占用额度;
|
* 市内外仍由 servicesscopename(费用说明)判定。
|
*/
|
private void checkAssistant(List<DetailRow> 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<Long> assistantItemIds = resolveItemIds(CFG_ITEM_ASSISTANT, "助手费用", FALLBACK_ASSISTANT);
|
|
// key = itemid(助手类型),LinkedHashMap + 末尾按 itemid 排序,保证提示顺序稳定
|
Map<Long, AssistantTotal> 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<AssistantTotal> 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)。
|
*
|
* <p>2026-09-30 需求变更:原实现把巡回与麻醉合并成一个 500 的总限额,
|
* 现按业务要求拆成两档各自校验(如巡回 300 + 麻醉 300 不再判超限)。
|
*
|
* <p>费用说明既不含「巡回」也不含「麻醉」的明细不计入任何档,仅给提示;
|
* 同时含两个词的明细分别计入两档(并记 warn 日志)。
|
*/
|
private void checkOperationRoom(List<DetailRow> rows, ExpertFeeCheckResult result) {
|
Set<Long> 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:同一专家不得同时在「捐献医院初步评估费用」「捐献病情评估专家费」
|
* 「脑死亡临床判定专家费」中出现,只能就高取其一。
|
*
|
* <p>硬拦截:违反时不允许保存/审核通过,不自动删除也不自动改数。
|
*/
|
private void checkAssessConflict(List<DetailRow> rows, ExpertFeeCheckResult result) {
|
Set<Long> assessItemIds = resolveItemIds(CFG_ITEM_ASSESS, "评估类费用", FALLBACK_ASSESS);
|
Map<String, Map<Long, String>> 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<String, Map<Long, String>> 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(专家劳务费)的<b>全部明细</b>逐条与费用标准比对。
|
*
|
* <p>判据(2026-10-08 业务方确认):
|
* <ol>
|
* <li>同一案例 × 同一专家 × 同一项目 × 同一费用说明的<b>合计</b> ≤ 该项目费用标准;</li>
|
* <li>金额为 0 的明细一律阻断(明细金额不能为 0);</li>
|
* <li>费用说明为空、或匹配不到标准(含 UseState≠1、已删除),一律阻断;</li>
|
* <li>同一组出现多条时<b>只提示不阻断</b>(是否超限由上面的合计判定)。</li>
|
* </ol>
|
*
|
* <p>金额口径:费用说明含「税后」的取<b>税后金额</b>(taxedamount),其余取税前金额(amount)。
|
* 标准表里「税后」类标准的 Expense 本身就是税后口径,
|
* 若拿税前金额(含税加价后的值)去比,会把大量正常单据误判为超标准(实测 3168 组 vs 162 组)。
|
*/
|
private void checkItemStandard(List<DetailRow> rows, ExpertFeeCheckResult result) {
|
Map<String, List<DetailRow>> groups = new LinkedHashMap<>();
|
Map<String, BigDecimal> groupStandards = new LinkedHashMap<>();
|
Map<String, String> 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<String, List<DetailRow>> entry : groups.entrySet()) {
|
String key = entry.getKey();
|
List<DetailRow> 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」)。
|
*
|
* <p>同一案例内,同一专家在医生获取/修整类项目(默认 itemid 32~39)上获取多个器官时:
|
* 按金额降序保高,<b>第二个及以后的每条</b>金额上限 = 费用说明含「双侧」取 1000、其他取 500。
|
* 第一个(金额最高的)那条按费用标准校验,由上面的「基本规则」负责。
|
*
|
* <p>该阈值无法用 base_itemexpense 表达(标准表里记的是首个器官的足额标准),因此放 sys_config。
|
*/
|
private void checkDoctorOrganSecond(List<DetailRow> 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<Long> doctorItemIds = resolveItemIds(CFG_ITEM_DOCTOR, "医生获取/修整类费用", FALLBACK_DOCTOR_ORGAN);
|
|
Map<String, List<DetailRow>> 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<String, List<DetailRow>> entry : byExpert.entrySet()) {
|
List<DetailRow> 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 + 费用说明」的费用标准。
|
*
|
* <p>标准表是可维护的基础数据:只认 {@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<String, BigDecimal> 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<String, BigDecimal> standards = new LinkedHashMap<>();
|
try {
|
// 用字符串列名包装器:BaseItemexpense 实体没有 delFlag 字段(无法用 lambda 引用),
|
// 且可避免依赖 MyBatis-Plus 的 lambda 缓存
|
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 (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 集合。
|
*
|
* <p>优先级:
|
* <ol>
|
* <li>sys_config 中显式配置的项目 id(逗号分隔)——项目名称不规范时用这个兜底</li>
|
* <li>按项目名称关键词到基础表解析(自动适应项目年度重建)</li>
|
* <li>早期年度的历史项目 id</li>
|
* </ol>
|
* 仅按项目 id 判定:取 sys_config 中配置的项目 id 列表,与历史年度 id 求并集。
|
* 配置为空且无历史 id 时返回空集,此时该规则不生效并会记 warn 日志。
|
*
|
* @param itemIdConfigKey sys_config 中项目 id 列表的配置键
|
* @param ruleLabel 规则名称,仅用于日志可读性
|
* @param fallback 历史年度(项目表改造前)使用的项目 id
|
*/
|
private Set<Long> resolveItemIds(String itemIdConfigKey, String ruleLabel, Set<Long> fallback) {
|
Set<Long> 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<Long> parseItemIdConfig(String configKey) {
|
Set<Long> 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<Long> itemids) {
|
return !CollectionUtils.isEmpty(itemids)
|
&& row.getItemid() != null
|
&& itemids.contains(row.getItemid());
|
}
|
|
/** 规则 6:医生“XX获取/修整”类项目(仅按项目 id) */
|
private boolean isDoctorOrganItem(DetailRow row, Set<Long> itemids) {
|
return isTargetItem(row, itemids);
|
}
|
|
/** 规则 7:助手类项目(仅按项目 id) */
|
private boolean isAssistantItem(DetailRow row, Set<Long> 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<Long, String> loadExpertFeeItemNames() {
|
Map<Long, String> cache = this.expertFeeItemNameCache;
|
if (cache != null) {
|
return cache;
|
}
|
synchronized (this) {
|
if (this.expertFeeItemNameCache == null) {
|
Map<Long, String> 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<BaseFinancesubject> loadExpertFeeItems() {
|
List<BaseFinancesubject> items = baseFinancesubjectMapper.getAllInfoByExpenseType(APPLYTYPE_EXPERT);
|
return CollectionUtils.isEmpty(items) ? new ArrayList<>() : items;
|
}
|
|
/** 字面量集合构造(供 static final 常量使用) */
|
private static Set<Long> setOf(Long... values) {
|
Set<Long> 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<Long> listExpertFundIds(Long infoid) {
|
List<ServiceFund> 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<DetailRow> loadDetailRows(List<Long> fundIds) {
|
if (CollectionUtils.isEmpty(fundIds)) {
|
return Collections.emptyList();
|
}
|
List<DetailRow> rows = new ArrayList<>();
|
for (Long fundId : fundIds) {
|
List<ServiceFunddetail> 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;
|
}
|
|
/**
|
* 明细行的唯一键。
|
*
|
* <p>已落库的明细用主键 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 条规则校验是否开启。
|
*
|
* <p>关闭时所有入口直接返回"通过"(既不做库查询,也不产生 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();
|
}
|
}
|