跳转到内容

案例 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

规则接口共 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

将六个文件按源码原有包结构放入 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 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 贷后账户要素(逾期与催收所需的账户快照)
* <p>
* 字段来源于信贷核心系统的账户表与贷后催收台账,仅保留预警分级所需的关键要素。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 贷后预警与催收决策结果
*/
@Data
public 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);
}
}
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);
}
<?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>
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();
}
}
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,启动诉讼材料准备"));
}
}

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

Terminal window
mvn test -Dtest=PostLoanWarningServiceTest

测试类共 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 调用链与代理复用,可嵌入贷后批量作业与信创环境。