Case 17: Insurance policy underwriting
This case corresponds to USE-CASES scenario 17, “Insurance policy underwriting risk determination,” in the insurance industry. It targets automated underwriting of life-insurance applications (life, critical illness, medical, accident) at life insurers, scoring the applicant’s health risk and outputting the underwriting decision, premium adjustment, approved sum insured, and exclusion list.
Industry scenario
Section titled “Industry scenario”Application data comes from the application form, the health disclosure questionnaire, and the occupation classification table: age, occupation class 1-6 (higher numbers mean higher risk; class 6 is a high-risk, non-insurable occupation), product type, requested sum insured and standard premium, BMI, smoking status, chronic disease history (none / hypertension / diabetes / malignant-tumor history), and whether the health disclosure was completed truthfully. The underwriting system must immediately return a decision of standard, loaded, exclusion, postponed, or declined once the application is submitted, supporting both online auto-underwriting and manual underwriter review.
The decision uses a two-stage design with hard gates up front. The Service first applies four hard declines: untruthful health disclosure (coverage rescinded and claims denied under Article 16 of the Insurance Law), malignant-tumor history applying for critical illness / medical / life products, age above the 65 auto-underwriting limit, and occupation class 6 (high risk). Any hit returns DECLINE without entering scoring. After passing the hard gates, the rule script performs five-dimension health scoring: age (0-25, younger scores higher), occupation class (0-25), BMI (0-20), smoking (0-10), and chronic disease history (0-20), totaling 100 points.
The underwriting decision is mapped uniformly by the underwritingResult script: >=85 is PREFERRED, >=70 STANDARD, >=55 LOADED, otherwise POSTPONE; specific disease-product matches take precedence over the score — diabetes applying for medical or critical illness, and hypertension applying for medical, are always decided as EXCLUSION regardless of score. Loaded cases carry a uniform 25% premium loading; preferred risks receive a 10% premium discount. The sum insured is also checked against a two-axis “occupation class x age” no-medical-exam / auto-underwriting limit (up to 2 million, minimum 300 thousand); when exceeded, the case is accepted at a reduced amount with a note in the underwriting explanation. Exclusion cases output concrete exclusion clauses for diabetes and hypertension.
Business pain points
Section titled “Business pain points”- Medical underwriting criteria scattered across manuals and code: age brackets, BMI intervals, occupation tables, the 25% loading, and preferred-risk discounts are spread everywhere, so when the medical affairs department adjusts a threshold, engineering lead time is long.
- Complex cross rules between chronic diseases and products: diabetes requires exclusions under medical / critical illness, hypertension only under medical; this “disease x product” matrix overlaps with score-based decisions, and hard coding easily gets the priority wrong.
- Hard declines mixed with health scoring: untruthful disclosure, malignant-tumor history, over-age, and high-risk occupations are legal- or channel-level hard declines; mixing them after scoring can produce the awkward path of a high score being rejected later, and fails regulatory audit-trail requirements.
- The sum-insured cap is a two-axis occupation-and-age matrix: different occupation classes and age bands correspond to caps of 2M / 1M / 600K / 300K, and over-limit cases need acceptance at a reduced amount with an explanation; manual underwriting easily miscalculates the limit.
- The decision must simultaneously support premium, exclusion list, and underwriting notes: loading amount, discount amount, final premium, exclusion clauses, and postpone/loading notices must be assembled in one piece; stitching across multiple systems easily produces inconsistent figures.
- Core life-insurance systems are legacy, and domestic-platform environments cannot adopt heavy rule platforms: existing policy systems mostly run on JDK 8; introducing standalone rule middleware is costly, so an embeddable lightweight rule capability is needed.
Solution architecture
Section titled “Solution architecture”This case follows the six-piece skeleton: input model + decision result model + rule interface + XML rule script + Service orchestration + 6 test cases covering real business profiles.
| Layer | Responsibility | Files |
|---|---|---|
| Domain model | Application underwriting fields, underwriting decision result (decision / reduced amount / premium / exclusion list / score breakdown) | UnderwritingApplication.java, UnderwritingDecision.java |
| Rule set & XML | Five-dimension health scoring, decision brackets, loading and discount rates, occupation-age sum-insured caps | InsuranceUnderwritingRules.java, insurance-underwriting.xml |
| Orchestration service | Four hard declines up front, score aggregation, reduced-amount acceptance, premium adjustment and exclusion assembly | UnderwritingService.java |
| Tests | 6 application profiles (preferred / standard / loaded / exclusion with reduction / untruthful disclosure / cancer history) | UnderwritingServiceTest.java |
Rule method breakdown
Section titled “Rule method breakdown”| Rule method | Criteria | Score/output |
|---|---|---|
scoreAge |
Age brackets: ≤30 -> 25, ≤40 -> 22, ≤50 -> 17, ≤60 -> 10, otherwise 4 | 0-25 |
scoreOccupation |
Occupation classes 1-5 score 25/22/17/11/5 respectively (class 6 is hard-declined by the Service) | 0-25 |
scoreBmi |
BMI 18.5-24.0 -> 20, 24-28 -> 14, 28-32 -> 7, otherwise 3 | 0-20 |
scoreSmoker |
Non-smoker 10, smoker 4 | 0-10 |
scoreChronic |
No chronic disease 20, hypertension 10, diabetes 6, others 0 | 0-20 |
underwritingResult |
Score >=85/70/55 maps to PREFERRED/STANDARD/LOADED, otherwise POSTPONE; diabetes+medical/critical-illness and hypertension+medical override to EXCLUSION | Six underwriting decisions |
extraPremiumRate |
Returns 25% only for LOADED cases, otherwise 0 | 0/25 (%) |
premiumDiscountRate |
Returns a 10% discount only for PREFERRED risks, otherwise 0 | 0/10 (%) |
maxSumInsuredWan |
Two-axis occupation x age: class 1-2 and age ≤40 -> 2M; ≤3 and ≤50 -> 1M; ≤4 -> 600K; otherwise 300K | Sum-insured cap in 10K yuan |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”Place the following files into the test directory of a JQuick-Java project following the source package structure: the two models go to src/test/java/com/github/paohaijiao/demo/model/, the rule interface and test class go to src/test/java/com/github/paohaijiao/demo/, the Service goes to src/test/java/com/github/paohaijiao/demo/service/, and the XML script goes to src/test/resources/demo/. Keep the package names exactly as in the source (com.github.paohaijiao.demo / .model / .service). Then run mvn test -Dtest=UnderwritingServiceTest to execute all 6 cases.
UnderwritingApplication.java
Section titled “UnderwritingApplication.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 人身险投保单核保要素 * <p> * 字段取自投保书、健康告知问卷与职业分类表,用于核保员风险评估与自动核保决策。 */@Data@NoArgsConstructor@AllArgsConstructorpublic 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;}UnderwritingDecision.java
Section titled “UnderwritingDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 核保决策结果 */@Datapublic 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); }}InsuranceUnderwritingRules.java
Section titled “InsuranceUnderwritingRules.java”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);}insurance-underwriting.xml
Section titled “insurance-underwriting.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.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>UnderwritingService.java
Section titled “UnderwritingService.java”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(); }}UnderwritingServiceTest.java
Section titled “UnderwritingServiceTest.java”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); }}Run & verified results
Section titled “Run & verified results”Run from the project root:
mvn test -Dtest=UnderwritingServiceTestTest coverage
Section titled “Test coverage”| Test method | Constructed business profile | Key assertions / expected decision |
|---|---|---|
shouldIssuePreferredWithDiscount |
Age 28, occupation class 1, BMI 22, non-smoker, no chronic disease, 1M life cover, standard premium 5,000 yuan | 100 points PREFERRED; no sum-insured reduction; 10% discount, final premium 4,500 yuan, with a “saves 500.0 yuan” note |
shouldIssueStandardWithoutAdjustment |
Age 42, class 3, BMI 26, critical illness 500K, standard premium 8,000 yuan | 78 points STANDARD; loading rate 0, final premium 8,000 yuan, exclusion list empty |
shouldIssueLoadedWithExtraPremium |
Age 48, class 3, smoker with hypertension, life cover 800K, standard premium 6,000 yuan | 62 points LOADED; loading rate 25%, loading 1,500 yuan, final premium 7,500 yuan, with a loaded-acceptance note |
shouldIssueWithExclusionAndReducedSumInsured |
Age 38, class 2, diabetes, medical cover 3M, standard premium 4,000 yuan | 74 points but decision EXCLUSION; sum insured reduced from 3M to 2M (occupation/age cap); no loading, final premium 4,000 yuan; exclusion list contains “diabetes and its complications (including treatment related to abnormal blood glucose)” |
shouldDeclineFalseHealthDeclaration |
Diabetic applicant but healthDeclarationPass=false |
DECLINE, total score 0, reason contains “coverage rescinded and claims denied under Article 16 of the Insurance Law” |
shouldDeclineCancerHistoryForCriticalIllness |
Age 45, malignant-tumor history applying for critical illness | DECLINE, reason contains “malignant-tumor history; critical-illness coverage not available”, final premium 0 |
Technical highlights
Section titled “Technical highlights”- All underwriting criteria externalized to XML: five-dimension health brackets, the 85/70/55 decision lines, the 25% loading, the 10% discount, and the occupation-age sum-insured matrix all live in the XML; medical/actuarial threshold changes require no modification to the core policy system — rebuilding the proxy takes effect immediately.
- The disease x product matrix explicitly overrides the score in the script:
underwritingResultfirst assigns a decision by score, then overrides it to EXCLUSION via positive checks for diabetes+medical/critical-illness and hypertension+medical; the priority is obvious at a glance and independently auditable. - Four hard declines up front in the Service: untruthful disclosure, malignant-tumor history, age over 65, and class-6 high-risk occupation short-circuit to DECLINE before scoring; legal-level declines never participate in health scoring, keeping the flow clear with a complete audit trail.
- Explainable automatic reduced-amount acceptance:
maxSumInsuredWanreturns the no-medical-exam cap on the occupation-and-age axes; on overflow the Service automatically reduces the amount, sets the reduction flag, and generates an underwriting note containing the requested amount, the cap, and the approved amount. - Integrated premium assembly: loading cases add
standard premium x 25%and preferred risks get a-10%discount, with the final premium computed in the same method; exclusion cases carry no loading — rates live in the rules while the amount math stays on the Java side. - The script does not support the
!negation operator, so boolean conditions are always written positively: e.g. the smoking score usesif (smoker) return 4 else return 10, avoiding the negation-syntax limitation. @Paramname binding + ASM proxy call chain: 9 rule methods share a single dynamic proxy, with the interface as the contract; Java 8 with no heavy dependencies, embeddable into JDK 8 life-insurance core systems and domestic-platform environments.