跳转到内容

案例 3:保险理赔核赔计算

本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 3:保险理赔核赔计算(生产级样板),属于保险领域,解决医疗险、重疾险、意外险、车险四类产品理赔案件的自动化核赔:沿「等待期资格校验 → 赔付比例 → 免赔额 → 理算 → 产品封顶 → 风险评分 → 通道路由」的核赔实务链路,输出核赔结论、赔付金额与处置通道。

理赔核赔是保险公司理赔流程中决定「赔不赔、赔多少、谁来赔」的关键环节。客户报案后,理赔系统需要采集产品类型(医疗险/重疾险/意外险/车险)、就诊医院等级(三甲/二甲/社区医院)、申请赔付金额、发票与费用清单金额、保单约定免赔额、已投保天数与产品等待期、近 1 年历史理赔次数、风控标记次数(黑名单、重复报案、影像异常等)、是否意外出险、材料是否齐全等报案要素。

核赔首先做资格校验:已投保天数小于等待期天数的案件不承担赔付责任,直接拒赔。通过资格校验后进入理算:赔付比例由产品类型、医院等级与是否意外三者交叉确定(医疗险三甲 85%、二甲 75%、社区 60%;重疾险 100%;意外险 90%;车险意外 90%、非意外 70%);实际免赔额按产品调整(重疾 0 免赔、意外减半、且不超过申请金额);赔付金额以申请金额与发票金额的较小者为费用基数,扣除免赔额后乘以赔付比例;最后执行各产品封顶线——医疗 20 万、重疾 50 万、车险 30 万、意外 10 万。

与理算并行的是反欺诈风控:风险分由历史理赔次数(每次 10 分)、风控标记次数(每次 25 分)与大额报案加分(10 万以上加 20、5 万以上加 10)构成,映射 LOW/MEDIUM/HIGH 三级;风控标记达到 2 次、或 1 次标记且金额达到 5 万元的案件必须转调查岗。

最终输出物包括核赔结论(APPROVE 正常赔付 / MANUAL_AUDIT 转人工审核 / REJECT 拒赔)、赔付金额、实际免赔额、赔付比例、风险分与风险等级、是否转调查、是否走小额快赔通道,以及逐条核赔说明(等待期拒赔、触达封顶线、材料不齐、快赔准入、理赔频次偏高等),供理赔员执行与向客户解释。其中材料齐全、赔款不超过 5000 元且无历史理赔的案件进入小额快赔通道,实现秒级赔付。

  • 赔付比例矩阵复杂且调整频繁:4 类产品 × 3 档医院等级 × 是否意外的交叉比例硬编码在核心理赔系统中,新增合作医院网络、报备新产品或调整比例时都要修改核心代码并重新发版。
  • 等待期、免赔、封顶线随条款变化:重疾 0 免赔、意外免赔减半、医疗 20 万/重疾 50 万/车险 30 万/意外 10 万的封顶线均来自产品报备条款,产品线增多后口径分散、极易错用旧条款造成错赔超赔。
  • 理算链路长、环节耦合:等待期校验、比例、免赔、取小、乘法、封顶、防负任一环节写错都会直接导致赔付金额错误,混写在一个大方法里时难以逐段测试与定位。
  • 风控路由与理算混写,骗赔漏拦:风控标记转调查、大额案件复核、小额快赔准入等条件与理算逻辑纠缠,出现「2 次风控标记 + 8 万大额报案」仍被自动赔付的漏洞风险。
  • 结论不可解释:只有赔款数字、没有核赔说明,等待期拒赔、封顶减赔、材料不齐转人工等结论面对客户投诉与监管检查时难以举证。
  • 保险核心老旧、信创推进难:大量寿险/财险核心仍运行在 JDK 8 与国产化配套 JDK 上,无法承受重型规则平台的独立中间件与数据库运维成本。

本案例采用「领域模型 + 规则接口与 XML 规则脚本 + 编排服务 + 测试」的六件套结构:等待期、比例、免赔、封顶、风险、路由全部由 XML 规则外置管理,Service 负责串联核赔链路、封顶复核与核赔说明组装。

层次 职责 对应文件
领域模型 报案要素、核赔结果 ClaimRequest.java、ClaimDecision.java
规则集与 XML 等待期、比例、免赔、理算、封顶、风险、通道路由,口径全部外置 ClaimAuditRules.java、claim-audit.xml
编排服务 核赔链路串联、封顶复核、调查改判、核赔说明与金额精度处理 ClaimAuditService.java
测试 6 组核赔业务形态 ClaimAuditServiceTest.java

规则接口共 10 个方法,严格按照核赔实务链路排列,每个方法只做一件事:

规则方法 口径 分值/输出
withinWaitingPeriod 已投保天数小于等待期天数即处于等待期 boolean
reimburseRatio 产品类型 × 医院等级 × 是否意外:医疗 0.85/0.75/0.6、重疾 1.0、意外 0.9、车险 0.9/0.7 0.6 ~ 1.0
actualDeductible 重疾免赔为 0、意外免赔减半、且不超过申请金额 实际免赔额(元)
payableAmount (min(申请金额, 发票金额) - 免赔额)× 比例,结果不为负 赔付金额(元)
applyCap 医疗 20 万 / 重疾 50 万 / 车险 30 万 / 意外 10 万封顶 封顶后金额(元)
riskScore 历史理赔次数 × 10 + 风控标记 × 25 + 大额报案加分(≥10 万 +20、≥5 万 +10) 风险分
riskLevel ≥60 HIGH / ≥30 MEDIUM / 否则 LOW LOW / MEDIUM / HIGH
needInvestigation 风控标记 ≥2 次,或标记 ≥1 次且金额 ≥5 万 boolean
auditResult 等待期拒赔 → 标记转人工 → 未达免赔拒赔 → 材料齐通过 / 不齐转人工 APPROVE / MANUAL_AUDIT / REJECT
fastTrack 材料齐全 + 赔款 ≤5000 元 + 无历史理赔 boolean

将六个文件按源码原有包结构放入 Maven 工程的 src/test 对应目录:模型类 ClaimRequest.java、ClaimDecision.java 位于 com.github.paohaijiao.demo.model;规则接口 ClaimAuditRules.java 与测试类 ClaimAuditServiceTest.java 位于 com.github.paohaijiao.demo;编排服务 ClaimAuditService.java 位于 com.github.paohaijiao.demo.service;规则脚本 claim-audit.xml 位于 src/test/resources/demo/。六个文件的包名保持源码原样,模型类依赖 Lombok 的 @Data 注解。在工程根目录执行 mvn test -Dtest=ClaimAuditServiceTest 即可运行全部 6 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 理赔申请单(报案要素)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ClaimRequest {
/**
* 理赔单号
*/
private String claimNo;
/**
* 保单号
*/
private String policyNo;
/**
* 被保险人
*/
private String insuredName;
/**
* 产品类型:MEDICAL-医疗险 / CRITICAL_ILLNESS-重疾险 / ACCIDENT-意外险 / AUTO-车险
*/
private String productType;
/**
* 就诊医院等级:THIRD_A-三甲 / SECOND_A-二甲 / COMMUNITY-社区医院
*/
private String hospitalLevel;
/**
* 申请赔付金额(元)
*/
private double claimAmount;
/**
* 发票/费用清单金额(元)
*/
private double invoiceAmount;
/**
* 保单约定免赔额(元)
*/
private double deductible;
/**
* 已投保天数
*/
private int insuredDays;
/**
* 产品等待期天数
*/
private int waitingPeriodDays;
/**
* 近 1 年历史理赔次数
*/
private int priorClaimCount;
/**
* 风控标记次数(黑名单、重复报案、影像异常等)
*/
private int fraudFlagCount;
/**
* 是否意外出险
*/
private boolean isAccident;
/**
* 理赔材料是否齐全
*/
private boolean documentComplete;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 理赔核赔结果
*/
@Data
public class ClaimDecision {
/**
* 理赔单号
*/
private String claimNo;
/**
* 核赔结论:APPROVE-正常赔付 / MANUAL_AUDIT-转人工审核 / REJECT-拒赔
*/
private String auditResult;
/**
* 赔付金额(元)
*/
private double payableAmount;
/**
* 实际扣减的免赔额(元)
*/
private double deductibleAmount;
/**
* 适用赔付比例
*/
private double reimburseRatio;
/**
* 理赔风险分
*/
private int riskScore;
/**
* 理赔风险等级:LOW / MEDIUM / HIGH
*/
private String riskLevel;
/**
* 是否需要转调查岗
*/
private boolean needInvestigation;
/**
* 是否走小额快赔通道
*/
private boolean fastTrack;
/**
* 核赔说明,供理赔员与客户解释使用
*/
private List<String> auditNotes = new ArrayList<String>();
public void addAuditNote(String note) {
this.auditNotes.add(note);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 3(生产级样板):保险理赔核赔规则集
* <p>
* 规则按核赔实务拆分为「资格校验 → 比例与免赔额 → 赔付计算 → 封顶 → 风险与通道路由」,
* 由 {@code ClaimAuditService} 串联执行,规则脚本见
* {@code src/test/resources/demo/claim-audit.xml}。
*/
public interface ClaimAuditRules {
/**
* 是否处于等待期(等待期内不承担赔付责任)
*/
boolean withinWaitingPeriod(@Param("insuredDays") int insuredDays,
@Param("waitingPeriodDays") int waitingPeriodDays);
/**
* 赔付比例:产品类型 × 医院等级 × 是否意外
*/
double reimburseRatio(@Param("productType") String productType,
@Param("hospitalLevel") String hospitalLevel,
@Param("isAccident") boolean isAccident);
/**
* 实际扣减的免赔额(重疾免赔、意外减半、不超过申请金额)
*/
double actualDeductible(@Param("productType") String productType,
@Param("deductible") double deductible,
@Param("claimAmount") double claimAmount);
/**
* 赔付金额 =(实际费用 - 免赔额)× 赔付比例
*/
double payableAmount(@Param("claimAmount") double claimAmount,
@Param("invoiceAmount") double invoiceAmount,
@Param("ratio") double ratio,
@Param("deductibleAmount") double deductibleAmount);
/**
* 产品封顶线控制
*/
double applyCap(@Param("payableAmount") double payableAmount,
@Param("productType") String productType);
/**
* 理赔风险分:历史理赔次数 + 风控标记 + 大额报案
*/
int riskScore(@Param("priorClaimCount") int priorClaimCount,
@Param("fraudFlagCount") int fraudFlagCount,
@Param("claimAmount") double claimAmount);
/**
* 理赔风险等级:LOW / MEDIUM / HIGH
*/
String riskLevel(@Param("riskScore") int riskScore);
/**
* 是否需要转调查岗
*/
boolean needInvestigation(@Param("fraudFlagCount") int fraudFlagCount,
@Param("claimAmount") double claimAmount);
/**
* 核赔结论:APPROVE / MANUAL_AUDIT / REJECT
*/
String auditResult(@Param("withinWaitingPeriod") boolean withinWaitingPeriod,
@Param("fraudFlagCount") int fraudFlagCount,
@Param("documentComplete") boolean documentComplete,
@Param("payableAmount") double payableAmount);
/**
* 是否走小额快赔通道
*/
boolean fastTrack(@Param("payableAmount") double payableAmount,
@Param("documentComplete") boolean documentComplete,
@Param("priorClaimCount") int priorClaimCount);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN"
"classpath:paohaijiao/dtd/Jquick-java.dtd">
<javas namespace="com.github.paohaijiao.demo.ClaimAuditRules">
<!-- 等待期校验:等待期内不承担赔付责任 -->
<java name="withinWaitingPeriod" returnClass="boolean">
<![CDATA[
boolean def withinWaitingPeriod(int:insuredDays, int:waitingPeriodDays) {
if (insuredDays < waitingPeriodDays) {
return true;
} else {
return false;
}
}
]]>
</java>
<!-- 赔付比例:产品 × 医院等级 × 意外与否 -->
<java name="reimburseRatio" returnClass="double">
<![CDATA[
double def reimburseRatio(str:productType, str:hospitalLevel, boolean:isAccident) {
double ratio = 0.6;
if (productType == "MEDICAL") {
if (hospitalLevel == "THIRD_A") {
ratio = 0.85;
} else if (hospitalLevel == "SECOND_A") {
ratio = 0.75;
} else {
ratio = 0.6;
}
} else if (productType == "CRITICAL_ILLNESS") {
ratio = 1.0;
} else if (productType == "ACCIDENT") {
ratio = 0.9;
} else if (productType == "AUTO") {
if (isAccident) {
ratio = 0.9;
} else {
ratio = 0.7;
}
}
return ratio;
}
]]>
</java>
<!-- 实际免赔额:重疾免赔额为 0,意外险减半,且不超过申请金额 -->
<java name="actualDeductible" returnClass="double">
<![CDATA[
double def actualDeductible(str:productType, double:deductible, double:claimAmount) {
double actual = deductible;
if (productType == "CRITICAL_ILLNESS") {
actual = 0.0;
} else if (productType == "ACCIDENT") {
actual = deductible * 0.5;
}
if (actual > claimAmount) {
actual = claimAmount;
}
return actual;
}
]]>
</java>
<!-- 赔付金额计算:以发票金额与申请金额的较小者为费用基数 -->
<java name="payableAmount" returnClass="double">
<![CDATA[
double def payableAmount(double:claimAmount, double:invoiceAmount, double:ratio, double:deductibleAmount) {
double base = claimAmount;
if (invoiceAmount < claimAmount) {
base = invoiceAmount;
}
double payable = (base - deductibleAmount) * ratio;
if (payable < 0.0) {
payable = 0.0;
}
return payable;
}
]]>
</java>
<!-- 产品封顶线 -->
<java name="applyCap" returnClass="double">
<![CDATA[
double def applyCap(double:payableAmount, str:productType) {
double capped = payableAmount;
if (productType == "MEDICAL" && payableAmount > 200000.0) {
capped = 200000.0;
} else if (productType == "CRITICAL_ILLNESS" && payableAmount > 500000.0) {
capped = 500000.0;
} else if (productType == "AUTO" && payableAmount > 300000.0) {
capped = 300000.0;
} else if (productType == "ACCIDENT" && payableAmount > 100000.0) {
capped = 100000.0;
}
return capped;
}
]]>
</java>
<!-- 理赔风险分 -->
<java name="riskScore" returnClass="int">
<![CDATA[
int def riskScore(int:priorClaimCount, int:fraudFlagCount, double:claimAmount) {
int score = 0;
score = score + priorClaimCount * 10;
score = score + fraudFlagCount * 25;
if (claimAmount >= 100000.0) {
score = score + 20;
} else if (claimAmount >= 50000.0) {
score = score + 10;
}
return score;
}
]]>
</java>
<!-- 理赔风险等级 -->
<java name="riskLevel" returnClass="java.lang.String">
<![CDATA[
str def riskLevel(int:riskScore) {
if (riskScore >= 60) {
return "HIGH";
} else if (riskScore >= 30) {
return "MEDIUM";
} else {
return "LOW";
}
}
]]>
</java>
<!-- 是否转调查岗 -->
<java name="needInvestigation" returnClass="boolean">
<![CDATA[
boolean def needInvestigation(int:fraudFlagCount, double:claimAmount) {
if (fraudFlagCount >= 2) {
return true;
} else if (fraudFlagCount >= 1 && claimAmount >= 50000.0) {
return true;
} else {
return false;
}
}
]]>
</java>
<!-- 核赔结论 -->
<java name="auditResult" returnClass="java.lang.String">
<![CDATA[
str def auditResult(boolean:withinWaitingPeriod, int:fraudFlagCount, boolean:documentComplete, double:payableAmount) {
if (withinWaitingPeriod) {
return "REJECT";
} else if (fraudFlagCount >= 2) {
return "MANUAL_AUDIT";
} else if (payableAmount <= 0.0) {
return "REJECT";
} else if (documentComplete) {
return "APPROVE";
} else {
return "MANUAL_AUDIT";
}
}
]]>
</java>
<!-- 小额快赔通道 -->
<java name="fastTrack" returnClass="boolean">
<![CDATA[
boolean def fastTrack(double:payableAmount, boolean:documentComplete, int:priorClaimCount) {
if (documentComplete && payableAmount <= 5000.0 && priorClaimCount == 0) {
return true;
} else {
return false;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.ClaimAuditRules;
import com.github.paohaijiao.demo.model.ClaimDecision;
import com.github.paohaijiao.demo.model.ClaimRequest;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 场景 3:保险理赔核赔服务
* <p>
* 核赔链路:等待期校验 → 赔付比例 → 免赔额扣减 → 赔付计算 → 封顶 → 风险评分与通道路由。
* 比例、免赔、封顶、路由阈值全部由 XML 规则外置管理,理算逻辑变化时无需发版。
*/
public class ClaimAuditService {
/**
* 小额快赔通道默认额度上限(元),仅用于说明展示
*/
private static final double FAST_TRACK_CAP = 5000.0;
private final ClaimAuditRules rules;
public ClaimAuditService(ClaimAuditRules rules) {
this.rules = rules;
}
/**
* 执行理赔核赔
*
* @param request 报案要素
* @return 核赔结果
*/
public ClaimDecision audit(ClaimRequest request) {
ClaimDecision decision = new ClaimDecision();
decision.setClaimNo(request.getClaimNo());
boolean withinWaiting = rules.withinWaitingPeriod(request.getInsuredDays(), request.getWaitingPeriodDays());
double ratio = rules.reimburseRatio(request.getProductType(), request.getHospitalLevel(), request.isAccident());
double deductibleAmount = rules.actualDeductible(request.getProductType(),
request.getDeductible(), request.getClaimAmount());
double payable = rules.payableAmount(request.getClaimAmount(), request.getInvoiceAmount(),
ratio, deductibleAmount);
double capped = rules.applyCap(payable, request.getProductType());
if (capped > request.getClaimAmount()) {
capped = request.getClaimAmount();
}
int score = rules.riskScore(request.getPriorClaimCount(), request.getFraudFlagCount(), request.getClaimAmount());
boolean investigation = rules.needInvestigation(request.getFraudFlagCount(), request.getClaimAmount());
String result = rules.auditResult(withinWaiting, request.getFraudFlagCount(),
request.isDocumentComplete(), capped);
decision.setReimburseRatio(ratio);
decision.setDeductibleAmount(scale(deductibleAmount));
decision.setRiskScore(score);
decision.setRiskLevel(rules.riskLevel(score));
decision.setNeedInvestigation(investigation);
if ("REJECT".equals(result)) {
decision.setAuditResult(result);
decision.setPayableAmount(0.0);
decision.setFastTrack(false);
if (withinWaiting) {
decision.addAuditNote("保单处于等待期(已投保 " + request.getInsuredDays()
+ " 天 < 等待期 " + request.getWaitingPeriodDays() + " 天),本次不承担赔付责任");
} else {
decision.addAuditNote("扣除免赔额后无可赔付金额,本次拒赔");
}
return decision;
}
if (investigation && "APPROVE".equals(result)) {
result = "MANUAL_AUDIT";
decision.addAuditNote("存在风控标记,转调查岗复核");
}
decision.setAuditResult(result);
decision.setPayableAmount(scale(capped));
decision.setFastTrack(rules.fastTrack(capped, request.isDocumentComplete(), request.getPriorClaimCount()));
if (payable > capped) {
decision.addAuditNote("赔付金额触达产品封顶线,按封顶金额赔付");
}
if ("MANUAL_AUDIT".equals(result) && !request.isDocumentComplete()) {
decision.addAuditNote("理赔材料不齐全,转人工审核");
}
if (decision.isFastTrack()) {
decision.addAuditNote("赔付金额不超过 " + (int) FAST_TRACK_CAP + " 元且材料齐全,进入小额快赔通道");
}
if (request.getPriorClaimCount() >= 3) {
decision.addAuditNote("近 1 年理赔 " + request.getPriorClaimCount() + " 次,理赔频次偏高");
}
if ("MEDICAL".equals(request.getProductType()) && "COMMUNITY".equals(request.getHospitalLevel())) {
decision.addAuditNote("社区医院就诊,赔付比例按 60% 执行");
}
return decision;
}
private double scale(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.ClaimDecision;
import com.github.paohaijiao.demo.model.ClaimRequest;
import com.github.paohaijiao.demo.service.ClaimAuditService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 3 生产级样板:保险理赔核赔
* <p>
* 覆盖 6 组核赔形态:门诊小额快赔、重疾大额封顶、等待期拒赔、风控标记转调查、
* 材料不全转人工、未达免赔额拒赔。
*/
public class ClaimAuditServiceTest {
private ClaimAuditService service;
@Before
public void setUp() {
ClaimAuditRules rules = JQuickJava.create()
.createApi(ClaimAuditRules.class, "demo/claim-audit.xml");
this.service = new ClaimAuditService(rules);
}
/**
* 门诊小额:三甲医院医疗险,赔款 2465 元,材料齐全且无历史理赔 → 正常赔付并走快赔通道
*/
@Test
public void shouldApproveOutpatientClaimWithFastTrack() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826001");
request.setPolicyNo("POL20250101001");
request.setInsuredName("刘洋");
request.setProductType("MEDICAL");
request.setHospitalLevel("THIRD_A");
request.setClaimAmount(3200.0);
request.setInvoiceAmount(3200.0);
request.setDeductible(300.0);
request.setInsuredDays(400);
request.setWaitingPeriodDays(30);
request.setPriorClaimCount(0);
request.setFraudFlagCount(0);
request.setAccident(false);
request.setDocumentComplete(true);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("APPROVE", decision.getAuditResult());
Assert.assertEquals(2465.0, decision.getPayableAmount(), 0.001);
Assert.assertEquals(300.0, decision.getDeductibleAmount(), 0.001);
Assert.assertEquals(0.85, decision.getReimburseRatio(), 0.0001);
Assert.assertEquals(0, decision.getRiskScore());
Assert.assertEquals("LOW", decision.getRiskLevel());
Assert.assertFalse(decision.isNeedInvestigation());
Assert.assertTrue(decision.isFastTrack());
Assert.assertTrue(decision.getAuditNotes().contains("赔付金额不超过 5000 元且材料齐全,进入小额快赔通道"));
}
/**
* 重疾大额:赔款触达 50 万封顶线,风险分为 30(MEDIUM)
*/
@Test
public void shouldApplyCapForCriticalIllnessClaim() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826002");
request.setPolicyNo("POL20240101002");
request.setInsuredName("周静");
request.setProductType("CRITICAL_ILLNESS");
request.setHospitalLevel("THIRD_A");
request.setClaimAmount(600000.0);
request.setInvoiceAmount(580000.0);
request.setDeductible(10000.0);
request.setInsuredDays(800);
request.setWaitingPeriodDays(90);
request.setPriorClaimCount(1);
request.setFraudFlagCount(0);
request.setAccident(false);
request.setDocumentComplete(true);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("APPROVE", decision.getAuditResult());
Assert.assertEquals(500000.0, decision.getPayableAmount(), 0.001);
Assert.assertEquals(0.0, decision.getDeductibleAmount(), 0.001);
Assert.assertEquals(1.0, decision.getReimburseRatio(), 0.0001);
Assert.assertEquals(30, decision.getRiskScore());
Assert.assertEquals("MEDIUM", decision.getRiskLevel());
Assert.assertFalse(decision.isFastTrack());
Assert.assertTrue(decision.getAuditNotes().contains("赔付金额触达产品封顶线,按封顶金额赔付"));
}
/**
* 等待期内出险:已投保 20 天 < 等待期 30 天 → 拒赔且赔款为 0
*/
@Test
public void shouldRejectClaimWithinWaitingPeriod() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826003");
request.setPolicyNo("POL20260801003");
request.setInsuredName("吴磊");
request.setProductType("MEDICAL");
request.setHospitalLevel("THIRD_A");
request.setClaimAmount(8000.0);
request.setInvoiceAmount(8000.0);
request.setDeductible(300.0);
request.setInsuredDays(20);
request.setWaitingPeriodDays(30);
request.setPriorClaimCount(0);
request.setFraudFlagCount(0);
request.setAccident(false);
request.setDocumentComplete(true);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("REJECT", decision.getAuditResult());
Assert.assertEquals(0.0, decision.getPayableAmount(), 0.001);
Assert.assertFalse(decision.isFastTrack());
Assert.assertTrue(decision.getAuditNotes().contains(
"保单处于等待期(已投保 20 天 < 等待期 30 天),本次不承担赔付责任"));
}
/**
* 风控标记:2 次标记 + 大额意外报案 → 转调查岗,风险分 80(HIGH)
*/
@Test
public void shouldRouteFlaggedClaimToInvestigation() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826004");
request.setPolicyNo("POL20250101004");
request.setInsuredName("郑凯");
request.setProductType("ACCIDENT");
request.setHospitalLevel("THIRD_A");
request.setClaimAmount(80000.0);
request.setInvoiceAmount(90000.0);
request.setDeductible(500.0);
request.setInsuredDays(300);
request.setWaitingPeriodDays(30);
request.setPriorClaimCount(2);
request.setFraudFlagCount(2);
request.setAccident(true);
request.setDocumentComplete(true);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("MANUAL_AUDIT", decision.getAuditResult());
Assert.assertEquals(71775.0, decision.getPayableAmount(), 0.001);
Assert.assertEquals(250.0, decision.getDeductibleAmount(), 0.001);
Assert.assertEquals(0.9, decision.getReimburseRatio(), 0.0001);
Assert.assertEquals(80, decision.getRiskScore());
Assert.assertEquals("HIGH", decision.getRiskLevel());
Assert.assertTrue(decision.isNeedInvestigation());
Assert.assertFalse(decision.isFastTrack());
}
/**
* 材料不全:车险意外小额,赔款 3600 元但材料缺失 → 转人工审核
*/
@Test
public void shouldRouteIncompleteDocumentClaimToManualAudit() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826005");
request.setPolicyNo("POL20250201005");
request.setInsuredName("何欣");
request.setProductType("AUTO");
request.setHospitalLevel("THIRD_A");
request.setClaimAmount(4000.0);
request.setInvoiceAmount(4000.0);
request.setDeductible(0.0);
request.setInsuredDays(500);
request.setWaitingPeriodDays(30);
request.setPriorClaimCount(0);
request.setFraudFlagCount(0);
request.setAccident(true);
request.setDocumentComplete(false);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("MANUAL_AUDIT", decision.getAuditResult());
Assert.assertEquals(3600.0, decision.getPayableAmount(), 0.001);
Assert.assertEquals(0.9, decision.getReimburseRatio(), 0.0001);
Assert.assertFalse(decision.isFastTrack());
Assert.assertTrue(decision.getAuditNotes().contains("理赔材料不齐全,转人工审核"));
}
/**
* 未达免赔额:费用 500 元、免赔额 800 元 → 拒赔
*/
@Test
public void shouldRejectClaimBelowDeductible() {
ClaimRequest request = new ClaimRequest();
request.setClaimNo("CLM20260826006");
request.setPolicyNo("POL20250401006");
request.setInsuredName("林小雨");
request.setProductType("MEDICAL");
request.setHospitalLevel("COMMUNITY");
request.setClaimAmount(500.0);
request.setInvoiceAmount(500.0);
request.setDeductible(800.0);
request.setInsuredDays(200);
request.setWaitingPeriodDays(30);
request.setPriorClaimCount(0);
request.setFraudFlagCount(0);
request.setAccident(false);
request.setDocumentComplete(true);
ClaimDecision decision = service.audit(request);
Assert.assertEquals("REJECT", decision.getAuditResult());
Assert.assertEquals(0.0, decision.getPayableAmount(), 0.001);
Assert.assertTrue(decision.getAuditNotes().contains("扣除免赔额后无可赔付金额,本次拒赔"));
}
}

在工程根目录执行以下命令运行本案例:

Terminal window
mvn test -Dtest=ClaimAuditServiceTest

测试类共 6 个 @Test 方法,覆盖小额快赔、大额封顶、等待期拒赔、风控转调查、材料不全转人工、未达免赔拒赔六类核赔形态:

测试方法 构造的业务形态 关键断言/期望决策
shouldApproveOutpatientClaimWithFastTrack 医疗险三甲、申请与发票均 3200 元、免赔 300、投保 400 天超等待期、材料齐、无历史理赔 APPROVE,赔付 2465.0((3200-300)×0.85),免赔 300.0,比例 0.85,风险分 0 LOW,不调查,快赔 true,说明含「进入小额快赔通道」
shouldApplyCapForCriticalIllnessClaim 重疾险、申请 60 万发票 58 万、约定免赔 1 万、等待期 90 天已过、历史理赔 1 次 APPROVE,赔付 500000.0(触达 50 万封顶线),实际免赔 0.0,比例 1.0,风险分 30 MEDIUM,非快赔,说明含「触达产品封顶线」
shouldRejectClaimWithinWaitingPeriod 医疗险、已投保 20 天小于等待期 30 天、申请 8000 元、材料齐 REJECT,赔付 0.0,非快赔,说明含「保单处于等待期(已投保 20 天 < 等待期 30 天)」
shouldRouteFlaggedClaimToInvestigation 意外险、申请 8 万发票 9 万、免赔 500、历史理赔 2 次、风控标记 2 次、大额意外 MANUAL_AUDIT,赔付 71775.0((80000-250)×0.9),免赔 250.0(意外减半),比例 0.9,风险分 80 HIGH,需调查,非快赔
shouldRouteIncompleteDocumentClaimToManualAudit 车险意外、申请与发票 4000 元、0 免赔、材料不齐全 MANUAL_AUDIT,赔付 3600.0(4000×0.9),比例 0.9,非快赔,说明含「理赔材料不齐全,转人工审核」
shouldRejectClaimBelowDeductible 医疗险社区医院、费用 500 元、免赔额 800 元、材料齐 REJECT,赔付 0.0,说明含「扣除免赔额后无可赔付金额,本次拒赔」

门诊小额医疗(三甲医院、费用 3200 元、免赔额 300 元、材料齐全):

核赔结论: APPROVE 赔付金额: 2465.0 实际免赔额: 300.0
赔付比例: 0.85 风险分: 0 (LOW) 快赔通道: true

重疾大额(申请 60 万、发票 58 万、重疾免赔 0、赔付比例 100%):

核赔结论: APPROVE 赔付金额: 500000.0(触达 50 万封顶线)
风险分: 30 (MEDIUM) 快赔通道: false
  • 核赔口径全部外置 XML,理算变更无需发版:产品 × 医院等级 × 意外的比例矩阵、重疾 0 免赔与意外减半、20/50/30/10 万封顶线、5000 元快赔线均在 claim-audit.xml 中,产品条款报备调整后改 XML、重建代理即生效。
  • 十环节链路逐段方法化,可单测可回溯:等待期、比例、免赔、理算、封顶、风险分、风险等级、调查、结论、快赔各是一个独立规则方法,任一环口径都能单独测试与审计,避免长链路混写导致的错赔。
  • 规则脚本内置金额防护:payableAmount 对小于 0 的理算结果归零,杜绝免赔额大于费用时赔出负数;actualDeductible 以申请金额为上限,防止免赔额反超费用基数。
  • Service 二次复核与改判:规则封顶后 Java 侧再与申请金额取小,防止超申请金额赔付;调查岗命中时即使规则给出 APPROVE 也改判 MANUAL_AUDIT;金额统一用 BigDecimal 保留两位,并按命中环节组装核赔说明。
  • 正向 return 适配脚本语法:withinWaitingPeriod 与 fastTrack 等布尔规则均以 if (...) { return true; } else { return false; } 的正向写法表达,不依赖取反运算符,贴合引擎脚本的语法约束。
  • @Param 按名绑定 + 代理复用 + ASM 调用链:规则方法只收标量,报案对象由 Service 拆解传入;代理一次创建、6 个用例复用;引擎 Java 8 起步、无重型依赖,可嵌入保险老旧核心与信创 JDK,支撑理赔高频在线理算。