案例 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 |
规则方法拆分
Section titled “规则方法拆分”规则接口共 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 |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将六个文件按源码原有包结构放入 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 个用例。
ClaimRequest.java
Section titled “ClaimRequest.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 理赔申请单(报案要素) */@Data@NoArgsConstructor@AllArgsConstructorpublic 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;}ClaimDecision.java
Section titled “ClaimDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 理赔核赔结果 */@Datapublic 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); }}ClaimAuditRules.java
Section titled “ClaimAuditRules.java”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);}claim-audit.xml
Section titled “claim-audit.xml”<?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>ClaimAuditService.java
Section titled “ClaimAuditService.java”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(); }}ClaimAuditServiceTest.java
Section titled “ClaimAuditServiceTest.java”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("扣除免赔额后无可赔付金额,本次拒赔")); }}在工程根目录执行以下命令运行本案例:
mvn test -Dtest=ClaimAuditServiceTest测试覆盖形态
Section titled “测试覆盖形态”测试类共 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,支撑理赔高频在线理算。