案例 2:贷后逾期预警与催收路由
本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 2:贷后逾期预警与催收路由(生产级样板),属于贷后管理领域,解决贷款进入逾期后的差异化催收决策:对逾期账户按四个维度评分,输出预警等级、催收渠道、罚息、最低还款额与风险迁徙度,并给出可直接执行的催收动作建议;未逾期账户则短路返回。
贷后逾期管理是银行、消费金融与小微贷款机构资产质量管控的核心环节。贷款一旦逾期,催收部门需要在有限人力下把资源投放到风险最高的账户上:逾期天数越长、逾期金额占剩余本金比例越高、借款人越难联系、抵押物覆盖越不足,回收难度就越大。账户快照数据来源于信贷核心系统的账户表与贷后催收台账,包含借据号、产品类型(消费贷/房贷/小微经营贷)、剩余本金、当前逾期金额与天数、合同年化利率、抵押物类型与评估价值、联系触达情况、近 1 年逾期次数与历史预警次数等要素。
决策首先按逾期天数划分逾期阶段(M1/M2/M3/M3+,对应阶段 14,未逾期为 0),再用四张评分卡量化风险:逾期天数与历史逾期(035 分,历史逾期 ≥3 次扣 10 分、≥1 次扣 5 分)、逾期金额占剩余本金比例(025 分)、联系触达(015 分,联系不上直接 0 分)、抵押物覆盖度(025 分,房抵覆盖 1.2 倍以上得满分)。四维汇总为 0100 的预警分。
汇总分映射红/橙/黄/蓝(RED/ORANGE/YELLOW/BLUE)四级预警,并路由到诉讼(LITIGATION)、上门(HOME_VISIT)、电话(PHONE)、短信(SMS)四类催收渠道——其中橙色以上且逾期金额达到 10 万元的账户同样直接进入诉讼渠道。同时按合同利率上浮 50% 计提罚息、按逾期阶段阶梯(1%/3%/5%)计算含违约金的最低还款额,并输出 0~9 的风险迁徙度衡量账户在不良路径上的滑动速度。
决策结果还附带催收动作建议清单与诉讼标记:联系不上提示上门核实联系方式、逾期 90 天进入不良资产管理流程、逾期 180 天给出核销初判、RED 等级启动诉讼材料准备、无抵押提示回收保障弱、近 1 年逾期 3 次以上建议下调授信或提前收贷。而对于 overdueDays 为 0 的正常账户,Service 在最前面短路返回 BLUE/短信/零费用,不调用任何评分规则。
- 分档口径散落、跨渠道不一致:30/60/90 天的逾期阶段线、80/60/40 的红橙黄蓝预警线、0.5/0.3/0.15/0.05 的金额占比分档,往往硬编码在催收批处理作业、短信平台与分案脚本中,各团队口径不一。
- 息费政策调整必须发版:罚息按合同利率 1.5 倍计提、违约金按逾期阶段 1%/3%/5% 阶梯收取,随合同模板与监管口径调整时,修改硬编码需要重新编译打包核心系统。
- 催收渠道路由依赖人工经验:诉讼、上门、电话、短信的选择缺少统一标准,尤其「橙色以上且逾期金额 ≥10 万才诉讼」这类分数与金额双条件,在人工分案中执行尺度不一致。
- 多维扣分因素耦合,难独立调参:逾期天数分档、历史逾期扣减、失联归零、抵押覆盖加成混在一个大评分过程里,无法单独调整某个因子并回溯其对预警分的贡献。
- 批量作业浪费与决策不可执行:正常账户也跑全量评分会挤占贷后批量窗口;而只给一个等级的结论无法被催收工单系统直接消费,催收员仍需自行判断下一步动作。
- 老旧核心与信创环境约束:贷后系统多与信贷核心同库部署,运行在 JDK 8 或国产化 JDK 上,难以引入需要独立服务与数据库的重型催收决策平台。
本案例采用「领域模型 + 规则接口与 XML 规则脚本 + 编排服务 + 测试」的六件套结构:评分口径、阶段阈值、渠道阈值、息费系数全部外置在 XML 中,Java 侧负责正常账户短路、四维汇总封顶、诉讼初判与催收动作组装。
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型 | 贷款账户快照、预警与催收决策结果 | LoanAccount.java、WarningDecision.java |
| 规则集与 XML | 4 个评分维度 + 阶段/等级/渠道/罚息/迁徙映射,口径全部外置 | PostLoanWarningRules.java、post-loan-warning.xml |
| 编排服务 | 未逾期短路、四维汇总封顶、诉讼初判、催收动作与金额精度处理 | PostLoanWarningService.java |
| 测试 | 6 组逾期业务形态 | PostLoanWarningServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”规则接口共 10 个方法:4 个维度评分方法各管一个口径,其余 6 个负责阶段、等级、渠道与息费计算:
| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
overdueStage |
逾期天数 ≥90/≥60/≥30/≥1 映射阶段 4/3/2/1,否则 0 | 0 ~ 4 |
scoreOverdueDays |
天数分档 35/28/22/15/8;历史逾期 ≥3 次扣 10、≥1 次扣 5,下限归零 | 0 ~ 35 |
scoreAmountRatio |
逾期金额 / 剩余本金,按 0.5、0.3、0.15、0.05 分档 | 4 ~ 25 |
scoreContact |
联系成功且最近联系 ≤3/≤7 天得 15/10/5,联系不上为 0 | 0 ~ 15 |
scoreCollateral |
房抵覆盖 ≥1.2 得 25、≥0.8 得 18;车抵 15/10/5;无抵押 0 | 0 ~ 25 |
warningLevel |
≥80 RED / ≥60 ORANGE / ≥40 YELLOW / 否则 BLUE | - |
collectChannel |
≥80 诉讼;≥60 且金额 ≥10 万诉讼;≥60 上门;≥40 电话;否则短信 | LITIGATION / HOME_VISIT / PHONE / SMS |
penaltyInterest |
剩余本金 × 合同利率 × 1.5 × 逾期天数 / 365 | 罚息金额(元) |
minRepayAmount |
逾期金额 ×(1 + 违约金率),≥90 天 5%、≥30 天 3%、否则 1% | 最低还款额(元) |
riskMigration |
逾期阶段 × 2 + 历史预警次数,封顶 9 | 0 ~ 9 |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将六个文件按源码原有包结构放入 Maven 工程的 src/test 对应目录:模型类 LoanAccount.java、WarningDecision.java 位于 com.github.paohaijiao.demo.model;规则接口 PostLoanWarningRules.java 与测试类 PostLoanWarningServiceTest.java 位于 com.github.paohaijiao.demo;编排服务 PostLoanWarningService.java 位于 com.github.paohaijiao.demo.service;规则脚本 post-loan-warning.xml 位于 src/test/resources/demo/。六个文件的包名保持源码原样,模型类依赖 Lombok 的 @Data 注解。在工程根目录执行 mvn test -Dtest=PostLoanWarningServiceTest 即可运行全部 6 个用例。
LoanAccount.java
Section titled “LoanAccount.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 贷后账户要素(逾期与催收所需的账户快照) * <p> * 字段来源于信贷核心系统的账户表与贷后催收台账,仅保留预警分级所需的关键要素。 */@Data@NoArgsConstructor@AllArgsConstructorpublic class LoanAccount {
/** * 借据号 */ private String loanNo;
/** * 客户姓名 */ private String customerName;
/** * 产品类型:CONSUMER-消费贷 / MORTGAGE-房贷 / SME-小微经营贷 */ private String productType;
/** * 剩余本金(元) */ private double remainingPrincipal;
/** * 当前逾期金额(元) */ private double overdueAmount;
/** * 当前逾期天数 */ private int overdueDays;
/** * 合同年化利率 */ private double baseRate;
/** * 抵押物类型:HOUSE-房产 / VEHICLE-车辆 / NONE-无抵押 */ private String collateralType;
/** * 抵押物评估价值(元) */ private double collateralValue;
/** * 是否联系上本人 */ private boolean contactSuccess;
/** * 距上次有效联系天数 */ private int lastContactDays;
/** * 近 1 年逾期次数 */ private int overdueHistoryCount;
/** * 历史已触发预警次数 */ private int warningCount;}WarningDecision.java
Section titled “WarningDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 贷后预警与催收决策结果 */@Datapublic class WarningDecision {
/** * 借据号 */ private String loanNo;
/** * 逾期阶段:0-未逾期 / 1-M1 / 2-M2 / 3-M3 / 4-M3+ */ private int overdueStage;
/** * 预警总分(0-100) */ private int warningScore;
/** * 预警等级:RED / ORANGE / YELLOW / BLUE */ private String warningLevel;
/** * 建议催收渠道:LITIGATION-诉讼 / HOME_VISIT-上门 / PHONE-电话 / SMS-短信 */ private String collectChannel;
/** * 本次计提罚息(元) */ private double penaltyInterest;
/** * 最低还款额(逾期金额 + 违约金,元) */ private double minRepayAmount;
/** * 风险迁徙度(0-9) */ private int riskMigration;
/** * 是否达到诉讼条件 */ private boolean needLitigation;
/** * 催收动作建议,供催收员执行 */ private List<String> actions = new ArrayList<String>();
public void addAction(String action) { this.actions.add(action); }}PostLoanWarningRules.java
Section titled “PostLoanWarningRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 2(生产级样板):贷后逾期预警规则集 * <p> * 规则按「逾期评分卡 + 催收决策映射」拆分: * <ul> * <li>评分类:逾期天数、金额占比、联系触达、抵押物覆盖度四个维度打分</li> * <li>决策类:预警等级、催收渠道、罚息计提、最低还款额、风险迁徙</li> * </ul> * 规则脚本见 {@code src/test/resources/demo/post-loan-warning.xml}。 */public interface PostLoanWarningRules {
/** * 逾期阶段:0-未逾期 / 1-M1 / 2-M2 / 3-M3 / 4-M3+ */ int overdueStage(@Param("overdueDays") int overdueDays);
/** * 逾期天数与历史逾期评分(0-35) */ int scoreOverdueDays(@Param("overdueDays") int overdueDays, @Param("overdueHistoryCount") int overdueHistoryCount);
/** * 逾期金额占剩余本金比例评分(0-25) */ int scoreAmountRatio(@Param("overdueAmount") double overdueAmount, @Param("remainingPrincipal") double remainingPrincipal);
/** * 联系触达评分(0-15):联系不上为 0 */ int scoreContact(@Param("contactSuccess") boolean contactSuccess, @Param("lastContactDays") int lastContactDays);
/** * 抵押物覆盖度评分(0-25) */ int scoreCollateral(@Param("collateralType") String collateralType, @Param("collateralValue") double collateralValue, @Param("remainingPrincipal") double remainingPrincipal);
/** * 预警等级:RED / ORANGE / YELLOW / BLUE */ String warningLevel(@Param("warningScore") int warningScore);
/** * 催收渠道路由:LITIGATION / HOME_VISIT / PHONE / SMS */ String collectChannel(@Param("warningScore") int warningScore, @Param("overdueAmount") double overdueAmount);
/** * 罚息计提:剩余本金 × 合同利率 × 1.5 × 逾期天数 / 365 */ double penaltyInterest(@Param("remainingPrincipal") double remainingPrincipal, @Param("baseRate") double baseRate, @Param("overdueDays") int overdueDays);
/** * 最低还款额:逾期金额 + 违约金(按逾期阶段阶梯) */ double minRepayAmount(@Param("overdueAmount") double overdueAmount, @Param("overdueDays") int overdueDays);
/** * 风险迁徙度:逾期阶段 × 2 + 历史预警次数,封顶 9 */ int riskMigration(@Param("overdueStage") int overdueStage, @Param("warningCount") int warningCount);}post-loan-warning.xml
Section titled “post-loan-warning.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.PostLoanWarningRules">
<!-- 逾期阶段:0-未逾期 / 1-M1 / 2-M2 / 3-M3 / 4-M3+ --> <java name="overdueStage" returnClass="int"> <![CDATA[ int def overdueStage(int:overdueDays) { if (overdueDays >= 90) { return 4; } else if (overdueDays >= 60) { return 3; } else if (overdueDays >= 30) { return 2; } else if (overdueDays >= 1) { return 1; } else { return 0; } } ]]> </java>
<!-- 逾期天数与历史逾期评分:0-35 分 --> <java name="scoreOverdueDays" returnClass="int"> <![CDATA[ int def scoreOverdueDays(int:overdueDays, int:overdueHistoryCount) { int score = 0; if (overdueDays >= 90) { score = 35; } else if (overdueDays >= 60) { score = 28; } else if (overdueDays >= 30) { score = 22; } else if (overdueDays >= 7) { score = 15; } else if (overdueDays >= 1) { score = 8; } if (overdueHistoryCount >= 3) { score = score - 10; } else if (overdueHistoryCount >= 1) { score = score - 5; } if (score < 0) { score = 0; } return score; } ]]> </java>
<!-- 逾期金额占比评分:0-25 分 --> <java name="scoreAmountRatio" returnClass="int"> <![CDATA[ int def scoreAmountRatio(double:overdueAmount, double:remainingPrincipal) { if (remainingPrincipal <= 0.0 || overdueAmount <= 0.0) { return 0; } double ratio = overdueAmount / remainingPrincipal; if (ratio >= 0.5) { return 25; } else if (ratio >= 0.3) { return 20; } else if (ratio >= 0.15) { return 14; } else if (ratio >= 0.05) { return 8; } else { return 4; } } ]]> </java>
<!-- 联系触达评分:0-15 分,联系不上为 0 --> <java name="scoreContact" returnClass="int"> <![CDATA[ int def scoreContact(boolean:contactSuccess, int:lastContactDays) { int score = 0; if (contactSuccess) { if (lastContactDays <= 3) { score = 15; } else if (lastContactDays <= 7) { score = 10; } else { score = 5; } } return score; } ]]> </java>
<!-- 抵押物覆盖度评分:0-25 分 --> <java name="scoreCollateral" returnClass="int"> <![CDATA[ int def scoreCollateral(str:collateralType, double:collateralValue, double:remainingPrincipal) { if (remainingPrincipal <= 0.0) { return 0; } if (collateralType == "HOUSE") { double cover = collateralValue / remainingPrincipal; if (cover >= 1.2) { return 25; } else if (cover >= 0.8) { return 18; } else { return 10; } } else if (collateralType == "VEHICLE") { double cover = collateralValue / remainingPrincipal; if (cover >= 1.2) { return 15; } else if (cover >= 0.8) { return 10; } else { return 5; } } else { return 0; } } ]]> </java>
<!-- 预警等级映射 --> <java name="warningLevel" returnClass="java.lang.String"> <![CDATA[ str def warningLevel(int:warningScore) { if (warningScore >= 80) { return "RED"; } else if (warningScore >= 60) { return "ORANGE"; } else if (warningScore >= 40) { return "YELLOW"; } else { return "BLUE"; } } ]]> </java>
<!-- 催收渠道路由:大额高分直接诉讼 --> <java name="collectChannel" returnClass="java.lang.String"> <![CDATA[ str def collectChannel(int:warningScore, double:overdueAmount) { if (warningScore >= 80) { return "LITIGATION"; } else if (warningScore >= 60 && overdueAmount >= 100000.0) { return "LITIGATION"; } else if (warningScore >= 60) { return "HOME_VISIT"; } else if (warningScore >= 40) { return "PHONE"; } else { return "SMS"; } } ]]> </java>
<!-- 罚息计提:剩余本金 × 合同利率 × 1.5 × 逾期天数 / 365 --> <java name="penaltyInterest" returnClass="double"> <![CDATA[ double def penaltyInterest(double:remainingPrincipal, double:baseRate, int:overdueDays) { double penaltyRate = baseRate * 1.5; double interest = remainingPrincipal * penaltyRate / 365.0 * overdueDays; return interest; } ]]> </java>
<!-- 最低还款额:逾期金额 + 违约金 --> <java name="minRepayAmount" returnClass="double"> <![CDATA[ double def minRepayAmount(double:overdueAmount, int:overdueDays) { double penaltyRatio = 0.01; if (overdueDays >= 90) { penaltyRatio = 0.05; } else if (overdueDays >= 30) { penaltyRatio = 0.03; } return overdueAmount + overdueAmount * penaltyRatio; } ]]> </java>
<!-- 风险迁徙度:逾期阶段 × 2 + 历史预警次数,封顶 9 --> <java name="riskMigration" returnClass="int"> <![CDATA[ int def riskMigration(int:overdueStage, int:warningCount) { int migration = overdueStage * 2 + warningCount; if (migration > 9) { migration = 9; } return migration; } ]]> </java>
</javas>PostLoanWarningService.java
Section titled “PostLoanWarningService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.PostLoanWarningRules;import com.github.paohaijiao.demo.model.LoanAccount;import com.github.paohaijiao.demo.model.WarningDecision;
import java.math.BigDecimal;import java.math.RoundingMode;
/** * 场景 2:贷后逾期预警与催收策略服务 * <p> * 职责划分: * <ul> * <li><b>Java 侧</b>:正常账户短路、四维评分汇总、诉讼初判、催收动作组装、金额精度处理</li> * <li><b>规则侧</b>:逾期阶段、评分口径、预警等级、渠道阈值、罚息与违约金,全部外置在 XML 中</li> * </ul> */public class PostLoanWarningService {
/** * 诉讼金额门槛(元) */ private static final double LITIGATION_AMOUNT_THRESHOLD = 100000.0;
/** * 诉讼预警分门槛 */ private static final int LITIGATION_SCORE_THRESHOLD = 80;
/** * 核销初判逾期天数 */ private static final int WRITE_OFF_DAYS = 180;
private final PostLoanWarningRules rules;
public PostLoanWarningService(PostLoanWarningRules rules) { this.rules = rules; }
/** * 执行贷后预警评估 * * @param account 账户快照 * @return 预警与催收决策 */ public WarningDecision evaluate(LoanAccount account) { WarningDecision decision = new WarningDecision(); decision.setLoanNo(account.getLoanNo());
int stage = rules.overdueStage(account.getOverdueDays()); decision.setOverdueStage(stage);
if (account.getOverdueDays() <= 0) { decision.setWarningScore(0); decision.setWarningLevel("BLUE"); decision.setCollectChannel("SMS"); decision.setPenaltyInterest(0.0); decision.setMinRepayAmount(0.0); decision.setRiskMigration(0); decision.setNeedLitigation(false); decision.addAction("账户状态正常,无需催收"); return decision; }
int daysScore = rules.scoreOverdueDays(account.getOverdueDays(), account.getOverdueHistoryCount()); int ratioScore = rules.scoreAmountRatio(account.getOverdueAmount(), account.getRemainingPrincipal()); int contactScore = rules.scoreContact(account.isContactSuccess(), account.getLastContactDays()); int collateralScore = rules.scoreCollateral(account.getCollateralType(), account.getCollateralValue(), account.getRemainingPrincipal());
int total = daysScore + ratioScore + contactScore + collateralScore; if (total < 0) { total = 0; } else if (total > 100) { total = 100; }
String level = rules.warningLevel(total); decision.setWarningScore(total); decision.setWarningLevel(level); decision.setCollectChannel(rules.collectChannel(total, account.getOverdueAmount())); decision.setPenaltyInterest(scale(rules.penaltyInterest(account.getRemainingPrincipal(), account.getBaseRate(), account.getOverdueDays()))); decision.setMinRepayAmount(scale(rules.minRepayAmount(account.getOverdueAmount(), account.getOverdueDays()))); decision.setNeedLitigation(total >= LITIGATION_SCORE_THRESHOLD || (account.getOverdueDays() >= 90 && account.getOverdueAmount() >= LITIGATION_AMOUNT_THRESHOLD)); decision.setRiskMigration(rules.riskMigration(stage, account.getWarningCount()));
buildActions(account, decision); return decision; }
private void buildActions(LoanAccount account, WarningDecision decision) { if (!account.isContactSuccess()) { decision.addAction("多次联系未果,建议上门核实并更新联系方式"); } if (account.getOverdueDays() >= 90) { decision.addAction("逾期超过 90 天,进入不良资产管理流程"); } if (account.getOverdueDays() >= WRITE_OFF_DAYS) { decision.addAction("逾期超过 180 天,符合核销条件初判,提交核销审批"); } if ("RED".equals(decision.getWarningLevel())) { decision.addAction("预警等级 RED,启动诉讼材料准备"); } if ("NONE".equals(account.getCollateralType())) { decision.addAction("无抵押物,回收保障较弱"); } if (account.getOverdueHistoryCount() >= 3) { decision.addAction("近 1 年逾期 " + account.getOverdueHistoryCount() + " 次,建议下调授信或提前收贷"); } }
private double scale(double value) { return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue(); }}PostLoanWarningServiceTest.java
Section titled “PostLoanWarningServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.LoanAccount;import com.github.paohaijiao.demo.model.WarningDecision;import com.github.paohaijiao.demo.service.PostLoanWarningService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 2 生产级样板:贷后逾期预警与催收策略 * <p> * 覆盖 6 组贷后形态:严重逾期大额失联、首次轻微逾期、中期足值抵押、失联车抵、 * 账户正常、长期逾期触发诉讼。 */public class PostLoanWarningServiceTest {
private PostLoanWarningService service;
@Before public void setUp() { PostLoanWarningRules rules = JQuickJava.create() .createApi(PostLoanWarningRules.class, "demo/post-loan-warning.xml"); this.service = new PostLoanWarningService(rules); }
/** * 严重逾期:95 天、逾期 20 万、失联无抵押 → ORANGE,走诉讼渠道 */ @Test public void shouldEscalateSevereOverdueAccount() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826001"); account.setCustomerName("张强"); account.setProductType("CONSUMER"); account.setRemainingPrincipal(365000.0); account.setOverdueAmount(200000.0); account.setOverdueDays(95); account.setBaseRate(0.10); account.setCollateralType("NONE"); account.setCollateralValue(0.0); account.setContactSuccess(false); account.setLastContactDays(30); account.setOverdueHistoryCount(0); account.setWarningCount(0);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("ORANGE", decision.getWarningLevel()); Assert.assertEquals(60, decision.getWarningScore()); Assert.assertEquals(4, decision.getOverdueStage()); Assert.assertEquals("LITIGATION", decision.getCollectChannel()); Assert.assertEquals(14250.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(210000.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(8, decision.getRiskMigration()); Assert.assertTrue(decision.isNeedLitigation()); Assert.assertTrue(decision.getActions().contains("多次联系未果,建议上门核实并更新联系方式")); }
/** * 首次轻微逾期:5 天、逾期 1000 元、可联系 → BLUE,短信提醒 */ @Test public void shouldKeepMildOverdueAccountLowLevel() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826002"); account.setCustomerName("李娜"); account.setProductType("CONSUMER"); account.setRemainingPrincipal(365000.0); account.setOverdueAmount(1000.0); account.setOverdueDays(5); account.setBaseRate(0.06); account.setCollateralType("NONE"); account.setCollateralValue(0.0); account.setContactSuccess(true); account.setLastContactDays(1); account.setOverdueHistoryCount(0); account.setWarningCount(0);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("BLUE", decision.getWarningLevel()); Assert.assertEquals(27, decision.getWarningScore()); Assert.assertEquals(1, decision.getOverdueStage()); Assert.assertEquals("SMS", decision.getCollectChannel()); Assert.assertEquals(450.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(1010.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(2, decision.getRiskMigration()); Assert.assertFalse(decision.isNeedLitigation()); }
/** * 中期逾期:45 天、房抵覆盖 1.37 倍、可联系 → ORANGE,上门催收 */ @Test public void shouldRouteCollateralizedOverdueToHomeVisit() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826003"); account.setCustomerName("王磊"); account.setProductType("MORTGAGE"); account.setRemainingPrincipal(365000.0); account.setOverdueAmount(60000.0); account.setOverdueDays(45); account.setBaseRate(0.10); account.setCollateralType("HOUSE"); account.setCollateralValue(500000.0); account.setContactSuccess(true); account.setLastContactDays(5); account.setOverdueHistoryCount(1); account.setWarningCount(1);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("ORANGE", decision.getWarningLevel()); Assert.assertEquals(66, decision.getWarningScore()); Assert.assertEquals(2, decision.getOverdueStage()); Assert.assertEquals("HOME_VISIT", decision.getCollectChannel()); Assert.assertEquals(6750.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(61800.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(5, decision.getRiskMigration()); Assert.assertFalse(decision.isNeedLitigation()); }
/** * 失联车抵:35 天、逾期 10 万、车抵覆盖不足、联系不上 → YELLOW,电话催收 */ @Test public void shouldWarnLostContactAccount() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826004"); account.setCustomerName("赵敏"); account.setProductType("SME"); account.setRemainingPrincipal(365000.0); account.setOverdueAmount(100000.0); account.setOverdueDays(35); account.setBaseRate(0.12); account.setCollateralType("VEHICLE"); account.setCollateralValue(300000.0); account.setContactSuccess(false); account.setLastContactDays(20); account.setOverdueHistoryCount(2); account.setWarningCount(0);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("YELLOW", decision.getWarningLevel()); Assert.assertEquals(41, decision.getWarningScore()); Assert.assertEquals("PHONE", decision.getCollectChannel()); Assert.assertEquals(6300.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(103000.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(4, decision.getRiskMigration()); Assert.assertFalse(decision.isNeedLitigation()); Assert.assertTrue(decision.getActions().contains("多次联系未果,建议上门核实并更新联系方式")); }
/** * 账户正常:未逾期 → 直接短路,不进入评分模型 */ @Test public void shouldShortCircuitWhenAccountNotOverdue() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826005"); account.setCustomerName("陈静"); account.setProductType("CONSUMER"); account.setRemainingPrincipal(50000.0); account.setOverdueAmount(0.0); account.setOverdueDays(0); account.setBaseRate(0.08); account.setCollateralType("NONE"); account.setCollateralValue(0.0); account.setContactSuccess(true); account.setLastContactDays(0); account.setOverdueHistoryCount(0); account.setWarningCount(0);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("BLUE", decision.getWarningLevel()); Assert.assertEquals(0, decision.getWarningScore()); Assert.assertEquals(0, decision.getOverdueStage()); Assert.assertEquals("SMS", decision.getCollectChannel()); Assert.assertEquals(0.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(0.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(0, decision.getRiskMigration()); Assert.assertFalse(decision.isNeedLitigation()); Assert.assertTrue(decision.getActions().contains("账户状态正常,无需催收")); }
/** * 长期逾期:120 天、逾期 40 万、房抵覆盖足值、频繁预警 → RED,启动诉讼准备 */ @Test public void shouldRaiseRedAlertAndPrepareLitigation() { LoanAccount account = new LoanAccount(); account.setLoanNo("LN20260826006"); account.setCustomerName("周涛"); account.setProductType("SME"); account.setRemainingPrincipal(365000.0); account.setOverdueAmount(400000.0); account.setOverdueDays(120); account.setBaseRate(0.15); account.setCollateralType("HOUSE"); account.setCollateralValue(500000.0); account.setContactSuccess(true); account.setLastContactDays(10); account.setOverdueHistoryCount(0); account.setWarningCount(2);
WarningDecision decision = service.evaluate(account);
Assert.assertEquals("RED", decision.getWarningLevel()); Assert.assertEquals(90, decision.getWarningScore()); Assert.assertEquals(4, decision.getOverdueStage()); Assert.assertEquals("LITIGATION", decision.getCollectChannel()); Assert.assertEquals(27000.0, decision.getPenaltyInterest(), 0.001); Assert.assertEquals(420000.0, decision.getMinRepayAmount(), 0.001); Assert.assertEquals(9, decision.getRiskMigration()); Assert.assertTrue(decision.isNeedLitigation()); Assert.assertTrue(decision.getActions().contains("预警等级 RED,启动诉讼材料准备")); }}在工程根目录执行以下命令运行本案例:
mvn test -Dtest=PostLoanWarningServiceTest测试覆盖形态
Section titled “测试覆盖形态”测试类共 6 个 @Test 方法,覆盖严重逾期诉讼、轻微逾期短信、中期抵押上门、失联车抵电催、正常短路、长期逾期红色预警六类形态:
| 测试方法 | 构造的业务形态 | 关键断言/期望决策 |
|---|---|---|
shouldEscalateSevereOverdueAccount |
逾期 95 天(阶段 4)、逾期 20 万、剩余本金 36.5 万、无抵押、失联 30 天、合同利率 10% | ORANGE,预警分 60,渠道 LITIGATION,罚息 14250.0,最低还款 210000.0,迁徙 8,需诉讼,动作含「多次联系未果」 |
shouldKeepMildOverdueAccountLowLevel |
首次逾期 5 天(阶段 1)、逾期 1000 元、可正常联系、利率 6% | BLUE,预警分 27,渠道 SMS,罚息 450.0,最低还款 1010.0,迁徙 2,不诉讼 |
shouldRouteCollateralizedOverdueToHomeVisit |
逾期 45 天(阶段 2)、逾期 6 万、房抵估值 50 万、可联系、历史逾期 1 次 | ORANGE,预警分 66,渠道 HOME_VISIT,罚息 6750.0,最低还款 61800.0,迁徙 5,不诉讼 |
shouldWarnLostContactAccount |
逾期 35 天(阶段 2)、逾期 10 万、车抵 30 万、失联、历史逾期 2 次、利率 12% | YELLOW,预警分 41,渠道 PHONE,罚息 6300.0,最低还款 103000.0,迁徙 4,不诉讼,动作含「多次联系未果」 |
shouldShortCircuitWhenAccountNotOverdue |
未逾期(0 天)、无逾期金额的正常账户 | BLUE,预警分 0,阶段 0,渠道 SMS,罚息与最低还款均为 0.0,迁徙 0,不诉讼,动作「账户状态正常,无需催收」 |
shouldRaiseRedAlertAndPrepareLitigation |
逾期 120 天(阶段 4)、逾期 40 万、房抵 50 万、历史预警 2 次、利率 15% | RED,预警分 90,渠道 LITIGATION,罚息 27000.0,最低还款 420000.0,迁徙 9(触封顶),需诉讼,动作含「RED,启动诉讼材料准备」 |
严重逾期拟诉讼(逾期 95 天、逾期金额 20 万、剩余本金 100 万、房抵覆盖不足):
预警等级: ORANGE 预警分: 60 逾期阶段: 4催收渠道: LITIGATION 风险迁徙: 8罚息金额: 14250.0 最低还款额: 210000.0首次轻微逾期(逾期 3 天、金额 1 万、联系正常):
预警等级: BLUE 预警分: 27 逾期阶段: 1催收渠道: SMS 罚息金额: 450.0- 阶段、等级、息费阈值全部外置 XML,支持热更新:30/60/90 天阶段线、80/60/40 预警线、10 万诉讼金额门槛、罚息 1.5 倍上浮、1%/3%/5% 违约金阶梯都在
post-loan-warning.xml中,贷后政策调整只改 XML、重建代理即可。 - 四维评分一方法一口径,可独立调参:逾期天数、金额占比、联系触达、抵押物覆盖各是一个独立规则方法,历史逾期扣 10/扣 5、失联归零等因子可单独调整,分值贡献清晰可回溯。
- 规则脚本自带边界防护:
scoreOverdueDays在历史逾期扣分后显式判断下限归零,避免扣出负分;riskMigration用if (migration > 9)封顶 9,长期逾期账户的迁徙度不会失控。 - Service 短路与双重诉讼初判:未逾期账户在评分前直接短路,零规则调用、零费用计算;诉讼标记采用「预警分 ≥80 或(逾期 ≥90 天且金额 ≥10 万)」双条件,与规则侧渠道路由相互印证。
- 金额计算显式浮点 + Java 侧精度收口:脚本中除以
365.0保证浮点运算,罚息与最低还款额回到 Java 侧后统一用BigDecimal四舍五入保留两位,满足财务入账口径。 - 决策可直接驱动催收作业:
actions动作清单覆盖失联核实、90 天不良、180 天核销初判、RED 诉讼准备、无抵押提示、频繁逾期授信调整,工单/分案系统可直接消费;配合 Java 8、ASM 调用链与代理复用,可嵌入贷后批量作业与信创环境。