跳转到内容

案例 17:保单核保风险判定

本案例对应 USE-CASES 场景 17「保单核保风险判定」,属于保险行业,面向寿险公司人身险(寿险、重疾险、医疗险、意外险)投保单的自动核保,对投保人健康风险打分并输出承保结论、保费调整、保额核定与除外责任清单。

人身险投保要素取自投保书、健康告知问卷与职业分类表:年龄、1-6 类职业类别(数字越大风险越高,6 类为高危拒保职业)、投保险种、投保保额与标准保费、BMI、是否吸烟、慢性病史(无/高血压/糖尿病/恶性肿瘤史)以及健康告知是否如实填写。核保系统需要在投保单录入后即时给出标准体、加费、除外、延期或拒保的结论,支撑线上自动核保与人工核保员复核。

决策采用硬门槛前置两段式。Service 先做四条硬否决:健康告知未如实填写(依据保险法第十六条解除承保并拒赔)、恶性肿瘤既往史投保重疾/医疗/寿险、年龄超过 65 周岁自动核保上限、职业类别达到 6 类高危职业;命中任一即 DECLINE 且不进入评分。通过硬门槛后,规则脚本完成五维健康评分:年龄(0-25,越年轻越高)、职业类别(0-25)、BMI(0-20)、吸烟(0-10)、慢性病史(0-20),合计满分 100。

核保结论由脚本 underwritingResult 统一映射:≥85 优选体 PREFERRED、≥70 标准体 STANDARD、≥55 加费承保 LOADED、其余延期观察 POSTPONE;特定慢病与险种的匹配优先于评分——糖尿病投保医疗险或重疾险、高血压投保医疗险,无论分数高低均判为 EXCLUSION 除外责任承保。对加费件统一加费 25%,优选体享 10% 保费折扣;保额还要经过「职业类别 × 年龄」双轴的免体检/自动核保上限(最高 200 万、最低 30 万)校验,超限时降额承保并在核保说明中注明;除外件会输出糖尿病、高血压相关的具体除外责任条款清单。

  • 医学核保口径分散在核保手册与代码中:年龄分档、BMI 区间、职业类别表、加费 25%、优选体折扣等阈值散落各处,医务管理部调整口径后研发排期长。
  • 慢病与险种交叉规则复杂:糖尿病在医疗险/重疾险下要责任除外,高血压只对医疗险除外,这类「病种 × 险种」矩阵与评分结论存在覆盖关系,硬编码极易写错优先级。
  • 硬性拒保与健康评分混杂:未如实告知、恶性肿瘤史、超龄、高危职业属于法理或渠道层面的硬拒保,若混在评分流程后,可能出现打出高分又被退回的尴尬链路,且不合监管留痕要求。
  • 保额上限是职业与年龄的双轴矩阵:不同职业类别、年龄段对应 200/100/60/30 万不同上限,超额时要降额承保并给出解释,人工核保容易核错额度。
  • 决策结果要同时支撑保费、除外清单与核保说明:加费金额、折扣金额、最终保费、除外责任条款、延期/加费提示需要一体化组装,多系统拼装容易口径不一致。
  • 核心寿险系统老旧、信创环境难上重型规则平台:存量保单系统多运行在 JDK 8 上,独立规则中间件改造代价大,需要可嵌入的轻量规则能力。

本案例沿用六件套骨架:入参模型 + 决策结果模型 + 规则接口 + XML 规则脚本 + Service 编排 + 6 组真实业务形态测试。

层次 职责 对应文件
领域模型 投保单核保要素、核保决策结果(结论/降额/保费/除外清单/评分明细) UnderwritingApplication.java、UnderwritingDecision.java
规则集与 XML 五维健康评分、结论分档、加费与折扣比例、职业年龄保额上限 InsuranceUnderwritingRules.java、insurance-underwriting.xml
编排服务 四条硬拒保前置、评分汇总、降额承保、保费调整与除外责任组装 UnderwritingService.java
测试 6 组投保形态(优选/标准/加费/除外降额/未告知/肿瘤史) UnderwritingServiceTest.java
规则方法 口径 分值/输出
scoreAge 年龄分档:≤30 岁 25、≤40 岁 22、≤50 岁 17、≤60 岁 10、否则 4 0-25
scoreOccupation 职业 1-5 类依次 25/22/17/11/5 分(6 类由 Service 硬拒保) 0-25
scoreBmi BMI 18.5-24.0 得 20、24-28 得 14、28-32 得 7、其余 3 0-20
scoreSmoker 不吸烟 10、吸烟 4 0-10
scoreChronic 无慢病 20、高血压 10、糖尿病 6、其他 0 0-20
underwritingResult 分数 ≥85/70/55 映射 PREFERRED/STANDARD/LOADED,否则 POSTPONE;糖尿病+医疗/重疾、高血压+医疗覆盖为 EXCLUSION 六种核保结论
extraPremiumRate 仅 LOADED 加费件返回 25%,其余 0 0/25(%)
premiumDiscountRate 仅 PREFERRED 优选体返回 10% 折扣,其余 0 0/10(%)
maxSumInsuredWan 职业类别 × 年龄双轴:1-2 类且 ≤40 岁 200 万;≤3 类且 ≤50 岁 100 万;≤4 类 60 万;否则 30 万 万元保额上限

将下列文件按源码包结构放入 JQuick-Java 工程的测试目录:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试类放入 src/test/java/com/github/paohaijiao/demo/,Service 放入 src/test/java/com/github/paohaijiao/demo/service/,XML 脚本放入 src/test/resources/demo/,包名保持源码原样(com.github.paohaijiao.demo / .model / .service)。放入后执行 mvn test -Dtest=UnderwritingServiceTest 即可运行全部 6 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 人身险投保单核保要素
* <p>
* 字段取自投保书、健康告知问卷与职业分类表,用于核保员风险评估与自动核保决策。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UnderwritingApplication {
/**
* 投保单号
*/
private String policyNo;
/**
* 投保人/被保人姓名
*/
private String applicantName;
/**
* 年龄
*/
private int age;
/**
* 职业类别(1-6 类,数字越大风险越高,6 类为高危拒保职业)
*/
private int occupationClass;
/**
* 投保险种:LIFE-寿险 / CRITICAL_ILLNESS-重疾险 / MEDICAL-医疗险 / ACCIDENT-意外险
*/
private String productType;
/**
* 投保保额(元)
*/
private double sumInsured;
/**
* 标准保费(元)
*/
private double premiumAmount;
/**
* 身体质量指数 BMI
*/
private double bmi;
/**
* 是否吸烟
*/
private boolean smoker;
/**
* 慢性病史:NONE-无 / HYPERTENSION-高血压 / DIABETES-糖尿病 / CANCER_HISTORY-恶性肿瘤史
*/
private String chronicDisease;
/**
* 健康告知是否如实填写(false 视为未如实告知,直接拒保)
*/
private boolean healthDeclarationPass;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 核保决策结果
*/
@Data
public class UnderwritingDecision {
/**
* 投保单号
*/
private String policyNo;
/**
* 核保结论:
* PREFERRED-优选体 / STANDARD-标准体 / LOADED-加费承保 /
* EXCLUSION-除外责任承保 / POSTPONE-延期观察 / DECLINE-拒保
*/
private String result;
/**
* 健康风险评分(0-100,分数越高越健康)
*/
private int totalScore;
/**
* 核定承保保额(元),超职业/年龄上限时自动降额
*/
private double approvedSumInsured;
/**
* 是否发生降额承保
*/
private boolean sumInsuredReduced;
/**
* 加费比例(百分比整数,如 25 表示加费 25%)
*/
private int extraPremiumRate;
/**
* 加费金额(元)
*/
private double extraPremium;
/**
* 优选体保费折扣比例(百分比整数)
*/
private int premiumDiscountRate;
/**
* 最终保费(元)
*/
private double finalPremium;
/**
* 除外责任清单
*/
private List<String> exclusionItems = new ArrayList<String>();
/**
* 拒保原因
*/
private List<String> declineReasons = new ArrayList<String>();
/**
* 核保说明与提示
*/
private List<String> notes = new ArrayList<String>();
/**
* 评分明细
*/
private List<String> scoreDetails = new ArrayList<String>();
public void addExclusionItem(String item) {
this.exclusionItems.add(item);
}
public void addDeclineReason(String reason) {
this.declineReasons.add(reason);
}
public void addNote(String note) {
this.notes.add(note);
}
public void addScoreDetail(String detail) {
this.scoreDetails.add(detail);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 17(生产级样板):人身险核保规则集
* <p>
* 五维健康评分(年龄 / 职业 / BMI / 吸烟 / 慢病),再映射核保结论、加费比例、
* 保额上限与优选体折扣;拒保硬否决(未如实告知、恶性肿瘤史、超龄、高危职业)由 Service 前置。
* 规则脚本见 {@code src/test/resources/demo/insurance-underwriting.xml}。
*/
public interface InsuranceUnderwritingRules {
/**
* 年龄风险评分(0-25,越年轻越高)
*/
int scoreAge(@Param("age") int age);
/**
* 职业类别评分(0-25)
*/
int scoreOccupation(@Param("occupationClass") int occupationClass);
/**
* BMI 评分(0-20)
*/
int scoreBmi(@Param("bmi") double bmi);
/**
* 吸烟评分(0-10)
*/
int scoreSmoker(@Param("smoker") boolean smoker);
/**
* 慢性病史评分(0-20)
*/
int scoreChronic(@Param("chronicDisease") String chronicDisease);
/**
* 核保结论:慢病与险种匹配优先于评分(糖尿病/高血压 → 除外责任)
*/
String underwritingResult(@Param("totalScore") int totalScore,
@Param("chronicDisease") String chronicDisease,
@Param("productType") String productType);
/**
* 加费比例(%):仅加费承保件有效
*/
int extraPremiumRate(@Param("result") String result);
/**
* 优选体保费折扣比例(%)
*/
int premiumDiscountRate(@Param("result") String result);
/**
* 职业与年龄对应的免体检/自动核保保额上限(万元)
*/
int maxSumInsuredWan(@Param("occupationClass") int occupationClass,
@Param("age") int age);
}
<?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.InsuranceUnderwritingRules">
<!-- 年龄评分:0-25 -->
<java name="scoreAge" returnClass="int">
<![CDATA[
int def scoreAge(int:age) {
if (age <= 30) {
return 25;
} else if (age <= 40) {
return 22;
} else if (age <= 50) {
return 17;
} else if (age <= 60) {
return 10;
} else {
return 4;
}
}
]]>
</java>
<!-- 职业类别评分:0-25 -->
<java name="scoreOccupation" returnClass="int">
<![CDATA[
int def scoreOccupation(int:occupationClass) {
if (occupationClass == 1) {
return 25;
} else if (occupationClass == 2) {
return 22;
} else if (occupationClass == 3) {
return 17;
} else if (occupationClass == 4) {
return 11;
} else {
return 5;
}
}
]]>
</java>
<!-- BMI 评分:0-20 -->
<java name="scoreBmi" returnClass="int">
<![CDATA[
int def scoreBmi(double:bmi) {
if (bmi >= 18.5 && bmi <= 24.0) {
return 20;
} else if (bmi > 24.0 && bmi <= 28.0) {
return 14;
} else if (bmi > 28.0 && bmi <= 32.0) {
return 7;
} else {
return 3;
}
}
]]>
</java>
<!-- 吸烟评分:0-10 -->
<java name="scoreSmoker" returnClass="int">
<![CDATA[
int def scoreSmoker(boolean:smoker) {
if (smoker) {
return 4;
} else {
return 10;
}
}
]]>
</java>
<!-- 慢性病史评分:0-20 -->
<java name="scoreChronic" returnClass="int">
<![CDATA[
int def scoreChronic(str:chronicDisease) {
if (chronicDisease == "NONE") {
return 20;
} else if (chronicDisease == "HYPERTENSION") {
return 10;
} else if (chronicDisease == "DIABETES") {
return 6;
} else {
return 0;
}
}
]]>
</java>
<!-- 核保结论:特定慢病+险种先判除外责任,其余按评分分档 -->
<java name="underwritingResult" returnClass="java.lang.String">
<![CDATA[
str def underwritingResult(int:totalScore, str:chronicDisease, str:productType) {
str result = "POSTPONE";
if (totalScore >= 55) {
result = "LOADED";
}
if (totalScore >= 70) {
result = "STANDARD";
}
if (totalScore >= 85) {
result = "PREFERRED";
}
if (chronicDisease == "DIABETES") {
if (productType == "MEDICAL") {
result = "EXCLUSION";
}
if (productType == "CRITICAL_ILLNESS") {
result = "EXCLUSION";
}
}
if (chronicDisease == "HYPERTENSION") {
if (productType == "MEDICAL") {
result = "EXCLUSION";
}
}
return result;
}
]]>
</java>
<!-- 加费比例:加费件统一加费 25% -->
<java name="extraPremiumRate" returnClass="int">
<![CDATA[
int def extraPremiumRate(str:result) {
if (result == "LOADED") {
return 25;
} else {
return 0;
}
}
]]>
</java>
<!-- 优选体折扣:10% -->
<java name="premiumDiscountRate" returnClass="int">
<![CDATA[
int def premiumDiscountRate(str:result) {
if (result == "PREFERRED") {
return 10;
} else {
return 0;
}
}
]]>
</java>
<!-- 自动核保保额上限(万元):职业类别与年龄双轴 -->
<java name="maxSumInsuredWan" returnClass="int">
<![CDATA[
int def maxSumInsuredWan(int:occupationClass, int:age) {
if (occupationClass <= 2 && age <= 40) {
return 200;
} else if (occupationClass <= 3 && age <= 50) {
return 100;
} else if (occupationClass <= 4) {
return 60;
} else {
return 30;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.InsuranceUnderwritingRules;
import com.github.paohaijiao.demo.model.UnderwritingApplication;
import com.github.paohaijiao.demo.model.UnderwritingDecision;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 场景 17:保单核保服务
* <p>
* 规则侧(XML):五维健康评分、核保结论分档、加费/折扣比例、职业年龄保额上限。
* Java 侧:未如实告知/恶性肿瘤史/超龄/高危职业硬拒保、降额承保、除外责任与最终保费组装。
*/
public class UnderwritingService {
/**
* 自动核保年龄上限
*/
private static final int MAX_AGE = 65;
/**
* 高危职业类别
*/
private static final int HIGH_RISK_OCCUPATION = 6;
private final InsuranceUnderwritingRules rules;
public UnderwritingService(InsuranceUnderwritingRules rules) {
this.rules = rules;
}
public UnderwritingDecision underwrite(UnderwritingApplication app) {
UnderwritingDecision decision = new UnderwritingDecision();
decision.setPolicyNo(app.getPolicyNo());
if (!passHardRules(app, decision)) {
return decision;
}
int ageScore = rules.scoreAge(app.getAge());
int occupationScore = rules.scoreOccupation(app.getOccupationClass());
int bmiScore = rules.scoreBmi(app.getBmi());
int smokerScore = rules.scoreSmoker(app.isSmoker());
int chronicScore = rules.scoreChronic(app.getChronicDisease());
int totalScore = ageScore + occupationScore + bmiScore + smokerScore + chronicScore;
decision.addScoreDetail("年龄风险评分: " + ageScore + " / 25");
decision.addScoreDetail("职业类别评分: " + occupationScore + " / 25");
decision.addScoreDetail("BMI 评分: " + bmiScore + " / 20");
decision.addScoreDetail("吸烟评分: " + smokerScore + " / 10");
decision.addScoreDetail("慢性病史评分: " + chronicScore + " / 20");
decision.addScoreDetail("汇总评分: " + totalScore);
decision.setTotalScore(totalScore);
String result = rules.underwritingResult(totalScore, app.getChronicDisease(), app.getProductType());
decision.setResult(result);
applySumInsuredCap(app, decision);
applyPremium(app, decision, result);
if ("EXCLUSION".equals(result)) {
buildExclusions(app, decision);
} else if ("POSTPONE".equals(result)) {
decision.addNote("健康评分低于 55,延期 6-12 个月观察并要求提供体检/复查报告后再申请");
} else if ("LOADED".equals(result)) {
decision.addNote("健康风险高于标准体,按加费 " + decision.getExtraPremiumRate() + "% 承保");
}
return decision;
}
/**
* 硬否决规则:未如实告知、恶性肿瘤史投保重疾/医疗、超龄、高危职业
*/
private boolean passHardRules(UnderwritingApplication app, UnderwritingDecision decision) {
if (!app.isHealthDeclarationPass()) {
decline(decision, "健康告知未如实填写,依据保险法第十六条解除承保并拒赔");
return false;
}
if ("CANCER_HISTORY".equals(app.getChronicDisease())
&& ("CRITICAL_ILLNESS".equals(app.getProductType())
|| "MEDICAL".equals(app.getProductType())
|| "LIFE".equals(app.getProductType()))) {
decline(decision, "存在恶性肿瘤既往史," + productName(app.getProductType()) + "不予承保");
return false;
}
if (app.getAge() > MAX_AGE) {
decline(decision, "年龄 " + app.getAge() + " 周岁超过自动核保上限 " + MAX_AGE + " 周岁");
return false;
}
if (app.getOccupationClass() >= HIGH_RISK_OCCUPATION) {
decline(decision, "职业类别为第 " + app.getOccupationClass()
+ " 类高危职业,普通险种不予承保,建议转投专属高危职业意外险");
return false;
}
return true;
}
private void decline(UnderwritingDecision decision, String reason) {
decision.setResult("DECLINE");
decision.addDeclineReason(reason);
}
/**
* 保额超过职业/年龄上限时降额承保
*/
private void applySumInsuredCap(UnderwritingApplication app, UnderwritingDecision decision) {
int maxWan = rules.maxSumInsuredWan(app.getOccupationClass(), app.getAge());
double maxAmount = maxWan * 10000.0;
if (app.getSumInsured() > maxAmount) {
decision.setApprovedSumInsured(round(maxAmount));
decision.setSumInsuredReduced(true);
decision.addNote("投保保额 " + round(app.getSumInsured()) + " 元超出第 "
+ app.getOccupationClass() + " 类职业/" + app.getAge()
+ " 周岁自动核保上限 " + maxWan + " 万元,降额至 " + maxWan + " 万元承保");
} else {
decision.setApprovedSumInsured(round(app.getSumInsured()));
}
}
/**
* 保费调整:加费件上浮、优选体打折、其余按标准保费
*/
private void applyPremium(UnderwritingApplication app, UnderwritingDecision decision, String result) {
int extraRate = rules.extraPremiumRate(result);
int discountRate = rules.premiumDiscountRate(result);
decision.setExtraPremiumRate(extraRate);
decision.setPremiumDiscountRate(discountRate);
double extra = app.getPremiumAmount() * extraRate / 100.0;
double discount = app.getPremiumAmount() * discountRate / 100.0;
decision.setExtraPremium(round(extra));
double finalPremium = app.getPremiumAmount() + extra - discount;
decision.setFinalPremium(round(finalPremium));
if (discountRate > 0) {
decision.addNote("优选体承保,标准保费享 " + discountRate + "% 折扣,省 " + round(discount) + " 元");
}
}
private void buildExclusions(UnderwritingApplication app, UnderwritingDecision decision) {
if ("DIABETES".equals(app.getChronicDisease())) {
decision.addExclusionItem("糖尿病及其并发症(含血糖异常相关治疗)");
decision.addExclusionItem("糖尿病继发心脑血管、眼底、肾脏病变");
}
if ("HYPERTENSION".equals(app.getChronicDisease())) {
decision.addExclusionItem("高血压及其并发症(含心脑血管、肾脏疾病)");
}
decision.addNote("已就既往症做责任除外,其余保险责任正常承保,保费不加费");
}
private String productName(String productType) {
if ("LIFE".equals(productType)) {
return "寿险";
}
if ("CRITICAL_ILLNESS".equals(productType)) {
return "重疾险";
}
if ("MEDICAL".equals(productType)) {
return "医疗险";
}
return "意外险";
}
private double round(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.UnderwritingApplication;
import com.github.paohaijiao.demo.model.UnderwritingDecision;
import com.github.paohaijiao.demo.service.UnderwritingService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 17 生产级样板:人身险保单核保风险判定
* <p>
* 覆盖 6 组投保形态:优选体折扣、标准体、吸烟慢病加费、糖尿病责任除外(含降额)、
* 未如实告知拒保、恶性肿瘤史拒保。
*/
public class UnderwritingServiceTest {
private UnderwritingService service;
@Before
public void setUp() {
InsuranceUnderwritingRules rules = JQuickJava.create()
.createApi(InsuranceUnderwritingRules.class, "demo/insurance-underwriting.xml");
this.service = new UnderwritingService(rules);
}
/**
* 优选体:28 岁 1 类职业不吸烟无慢病,100 分,寿险保费 9 折
*/
@Test
public void shouldIssuePreferredWithDiscount() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260001", "林悦", 28, 1, "LIFE",
1000000.0, 5000.0, 22.0, false, "NONE", true);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals(100, decision.getTotalScore());
Assert.assertEquals("PREFERRED", decision.getResult());
Assert.assertEquals(1000000.0, decision.getApprovedSumInsured(), 0.001);
Assert.assertFalse(decision.isSumInsuredReduced());
Assert.assertEquals(10, decision.getPremiumDiscountRate());
Assert.assertEquals(4500.0, decision.getFinalPremium(), 0.001);
Assert.assertTrue(decision.getNotes().contains("优选体承保,标准保费享 10% 折扣,省 500.0 元"));
}
/**
* 标准体:42 岁 3 类职业 BMI 偏高,78 分,重疾险标准保费承保
*/
@Test
public void shouldIssueStandardWithoutAdjustment() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260002", "赵磊", 42, 3, "CRITICAL_ILLNESS",
500000.0, 8000.0, 26.0, false, "NONE", true);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals(78, decision.getTotalScore());
Assert.assertEquals("STANDARD", decision.getResult());
Assert.assertEquals(0, decision.getExtraPremiumRate());
Assert.assertEquals(8000.0, decision.getFinalPremium(), 0.001);
Assert.assertTrue(decision.getExclusionItems().isEmpty());
}
/**
* 加费承保:48 岁吸烟高血压投保人买寿险,62 分加费 25%
*/
@Test
public void shouldIssueLoadedWithExtraPremium() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260003", "孙建国", 48, 3, "LIFE",
800000.0, 6000.0, 27.0, true, "HYPERTENSION", true);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals(62, decision.getTotalScore());
Assert.assertEquals("LOADED", decision.getResult());
Assert.assertEquals(25, decision.getExtraPremiumRate());
Assert.assertEquals(1500.0, decision.getExtraPremium(), 0.001);
Assert.assertEquals(7500.0, decision.getFinalPremium(), 0.001);
Assert.assertTrue(decision.getNotes().contains("健康风险高于标准体,按加费 25% 承保"));
}
/**
* 除外责任 + 降额:糖尿病投保医疗险,300 万保额降至 200 万,糖尿病相关责任除外
*/
@Test
public void shouldIssueWithExclusionAndReducedSumInsured() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260004", "周敏", 38, 2, "MEDICAL",
3000000.0, 4000.0, 25.0, false, "DIABETES", true);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals(74, decision.getTotalScore());
Assert.assertEquals("EXCLUSION", decision.getResult());
Assert.assertTrue(decision.isSumInsuredReduced());
Assert.assertEquals(2000000.0, decision.getApprovedSumInsured(), 0.001);
Assert.assertEquals(0, decision.getExtraPremiumRate());
Assert.assertEquals(4000.0, decision.getFinalPremium(), 0.001);
Assert.assertTrue(decision.getExclusionItems().contains("糖尿病及其并发症(含血糖异常相关治疗)"));
Assert.assertTrue(decision.getNotes().contains(
"投保保额 3000000.0 元超出第 2 类职业/38 周岁自动核保上限 200 万元,降额至 200 万元承保"));
}
/**
* 未如实健康告知:直接拒保,不进入评分
*/
@Test
public void shouldDeclineFalseHealthDeclaration() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260005", "吴海", 35, 2, "CRITICAL_ILLNESS",
800000.0, 7000.0, 23.0, false, "DIABETES", false);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals("DECLINE", decision.getResult());
Assert.assertEquals(0, decision.getTotalScore());
Assert.assertTrue(decision.getDeclineReasons().contains(
"健康告知未如实填写,依据保险法第十六条解除承保并拒赔"));
}
/**
* 恶性肿瘤既往史投保重疾险:直接拒保
*/
@Test
public void shouldDeclineCancerHistoryForCriticalIllness() {
UnderwritingApplication app = new UnderwritingApplication(
"UW20260006", "郑芳", 45, 2, "CRITICAL_ILLNESS",
600000.0, 9000.0, 24.5, false, "CANCER_HISTORY", true);
UnderwritingDecision decision = service.underwrite(app);
Assert.assertEquals("DECLINE", decision.getResult());
Assert.assertTrue(decision.getDeclineReasons().contains(
"存在恶性肿瘤既往史,重疾险不予承保"));
Assert.assertEquals(0.0, decision.getFinalPremium(), 0.001);
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=UnderwritingServiceTest
测试方法 构造的业务形态 关键断言/期望决策
shouldIssuePreferredWithDiscount 28 岁、1 类职业、BMI 22、不吸烟、无慢病,投保 100 万寿险、标准保费 5000 元 100 分 PREFERRED;保额不降额;折扣率 10%,最终保费 4500 元,含「省 500.0 元」说明
shouldIssueStandardWithoutAdjustment 42 岁、3 类职业、BMI 26、重疾险 50 万、标准保费 8000 元 78 分 STANDARD;加费率 0,最终保费 8000 元,除外责任清单为空
shouldIssueLoadedWithExtraPremium 48 岁、3 类职业、吸烟、高血压,投保寿险 80 万、标准保费 6000 元 62 分 LOADED;加费率 25%、加费 1500 元、最终保费 7500 元,含加费承保说明
shouldIssueWithExclusionAndReducedSumInsured 38 岁、2 类职业、糖尿病,投保医疗险 300 万、标准保费 4000 元 74 分但结论 EXCLUSION;保额由 300 万降至 200 万(职业/年龄上限);不加费、终费 4000 元;除外清单含「糖尿病及其并发症(含血糖异常相关治疗)」
shouldDeclineFalseHealthDeclaration 糖尿病投保人但 healthDeclarationPass=false DECLINE、总分 0,原因含「依据保险法第十六条解除承保并拒赔」
shouldDeclineCancerHistoryForCriticalIllness 45 岁、恶性肿瘤既往史投保重疾险 DECLINE,原因含「存在恶性肿瘤既往史,重疾险不予承保」,最终保费 0
  • 核保口径全量外置 XML:五维健康分档、85/70/55 结论线、25% 加费、10% 折扣、职业年龄保额矩阵全部在 XML 中,医务/精算调整阈值无需改动核心保单系统,重建代理即生效。
  • 慢病 × 险种矩阵在脚本中显式覆盖评分结论:underwritingResult 先按分数给结论,再用糖尿病+医疗/重疾、高血压+医疗的正向判断覆盖为 EXCLUSION,优先级一目了然,可独立审计。
  • Service 侧四条硬拒保前置:未如实告知、恶性肿瘤史、超 65 岁、6 类高危职业在评分前短路返回 DECLINE,法理类拒保不参与健康打分,链路清晰留痕完整。
  • 自动降额承保可解释:maxSumInsuredWan 以职业类别与年龄双轴给出免体检上限,超限时 Service 自动降额、置降额标记并生成含原保额、上限、核定保额的核保说明。
  • 保费一体化组装:加费件 标准保费 × 25% 上浮、优选体 -10% 折扣在同一方法内计算最终保费,除外件不加费,规则比例外置、金额计算留 Java 侧。
  • 脚本不支持 ! 取反,布尔条件一律正向书写:如吸烟评分 if (smoker) return 4 else return 10,规避取反语法限制。
  • @Param 按名绑定 + ASM 代理调用链:9 个规则方法共用一个动态代理,接口即契约;Java 8 无重型依赖,可嵌入 JDK 8 寿险核心系统与信创环境。