Skip to content

Case 2: Overdue warning & collection routing

This case corresponds to Scenario 2: Post-loan overdue warning & collection routing (production-grade template) in the JQuick-Java repository’s USE-CASES.md, in the post-loan management domain. It handles differentiated collection decisions after a loan becomes overdue: score the overdue account across four dimensions, output the warning level, collection channel, penalty interest, minimum repayment amount, and risk migration degree, plus directly actionable collection suggestions; non-overdue accounts are short-circuited.

Post-loan overdue management is central to asset quality control at banks, consumer finance companies, and micro-lenders. Once a loan is overdue, collection teams must allocate limited manpower to the riskiest accounts: the longer the overdue days, the higher the overdue amount relative to remaining principal, the harder the borrower is to reach, and the weaker the collateral coverage, the harder the recovery. Account snapshot data comes from the account tables of the credit core system and the post-loan collection ledger, including loan number, product type (consumer loan / mortgage / SME business loan), remaining principal, current overdue amount and days, contractual annualized rate, collateral type and appraised value, contact reachability, overdue counts in the last year, and historical warning counts.

The decision first buckets the account into an overdue stage by days overdue (M1/M2/M3/M3+, i.e., stages 1–4, with 0 for not overdue), then quantifies risk with four scorecards: overdue days & overdue history (0–35 points; −10 for ≥3 historical overdues, −5 for ≥1), overdue amount as a ratio of remaining principal (0–25), contact reachability (0–15, unreachable scores 0), and collateral coverage (0–25, full marks when house collateral covers ≥1.2×). The four dimensions aggregate into a 0–100 warning score.

The aggregated score maps to four warning levels — RED/ORANGE/YELLOW/BLUE — and routes to four collection channels: litigation (LITIGATION), home visit (HOME_VISIT), phone (PHONE), and SMS. Accounts at ORANGE or above with an overdue amount reaching 100,000 yuan also go straight to litigation. Meanwhile, penalty interest accrues at 1.5× the contractual rate, the minimum repayment including liquidated damages is computed on an overdue-stage ladder (1%/3%/5%), and a 0–9 risk migration degree measures how fast the account is sliding down the non-performing path.

The decision output also ships a collection action list and a litigation flag: unreachable accounts prompt a home visit to verify contact details; 90 days overdue enters the non-performing asset process; 180 days triggers a preliminary write-off assessment; RED level starts litigation material preparation; unsecured accounts are flagged for weak recovery protection; and 3+ overdues in the past year suggest lowering the credit line or calling the loan early. For normal accounts with overdueDays equal to 0, the Service short-circuits at the very front, returning BLUE/SMS/zero fees without invoking any scoring rules.

  • Scattered, inconsistent tiering across channels: the 30/60/90-day stage lines, the 80/60/40 RED/ORANGE/YELLOW/BLUE warning lines, and the 0.5/0.3/0.15/0.05 amount-ratio tiers are often hard-coded into collection batch jobs, SMS platforms, and case-assignment scripts, with each team keeping its own version.
  • Interest & fee policy changes require a release: penalty interest at 1.5× the contractual rate and liquidated damages on a 1%/3%/5% stage ladder change with contract templates and regulatory policies; modifying hard-coded values means recompiling and repackaging the core system.
  • Collection channel routing relies on manual experience: there is no unified standard for choosing litigation vs. home visit vs. phone vs. SMS, and dual conditions like “ORANGE or above AND overdue amount ≥ 100k goes to litigation” are enforced inconsistently in manual case assignment.
  • Coupled multi-factor deductions resist independent tuning: day tiers, historical overdue deductions, unreachable-to-zero, and collateral coverage bonuses are mixed into one big scoring procedure, so a single factor cannot be tuned in isolation or traced for its contribution to the warning score.
  • Wasted batch runs and non-actionable decisions: running full scoring for normal accounts consumes the post-loan batch window; and a verdict that only gives a level cannot be consumed directly by the collection ticket system — collectors still have to decide the next step themselves.
  • Legacy cores and domestic (Xinchuang) constraints: post-loan systems are usually deployed alongside the credit core database on JDK 8 or domestic JDKs, making it hard to introduce a heavy collection decision platform that requires its own services and databases.

This case uses the six-file structure of “domain model + rule interface & XML rule script + orchestration service + tests”: scoring policies, stage thresholds, channel thresholds, and interest/fee coefficients are all externalized in XML, while the Java side handles normal-account short-circuiting, four-dimension aggregation and capping, preliminary litigation determination, and collection action assembly.

Layer Responsibility Files
Domain model Loan account snapshot, warning & collection decision result LoanAccount.java, WarningDecision.java
Rule set & XML 4 scoring dimensions + stage/level/channel/penalty/migration mappings, all policies externalized PostLoanWarningRules.java, post-loan-warning.xml
Orchestration service Non-overdue short-circuit, four-dimension aggregation and capping, preliminary litigation determination, action assembly and amount precision handling PostLoanWarningService.java
Tests 6 groups of overdue business shapes PostLoanWarningServiceTest.java

The rule interface has 10 methods in total: 4 dimension scoring methods each own one policy, and the remaining 6 handle stage, level, channel, and fee computation:

Rule method Policy Score / output
overdueStage Days overdue ≥90/≥60/≥30/≥1 mapped to stages 4/3/2/1, otherwise 0 0 ~ 4
scoreOverdueDays Day tiers 35/28/22/15/8; −10 for ≥3 historical overdues, −5 for ≥1, floored at 0 0 ~ 35
scoreAmountRatio Overdue amount / remaining principal, tiered at 0.5, 0.3, 0.15, 0.05 4 ~ 25
scoreContact Reachable with last contact ≤3/≤7 days scores 15/10/5; unreachable scores 0 0 ~ 15
scoreCollateral House coverage ≥1.2 scores 25, ≥0.8 scores 18; vehicle 15/10/5; unsecured 0 0 ~ 25
warningLevel ≥80 RED / ≥60 ORANGE / ≥40 YELLOW / otherwise BLUE -
collectChannel ≥80 litigation; ≥60 and amount ≥100k litigation; ≥60 home visit; ≥40 phone; otherwise SMS LITIGATION / HOME_VISIT / PHONE / SMS
penaltyInterest Remaining principal × contractual rate × 1.5 × overdue days / 365 Penalty interest (yuan)
minRepayAmount Overdue amount × (1 + liquidated damages rate): 5% for ≥90 days, 3% for ≥30 days, otherwise 1% Minimum repayment (yuan)
riskMigration Overdue stage × 2 + historical warning count, capped at 9 0 ~ 9

Place the six files into the corresponding src/test directories of a Maven project following the original package structure: model classes LoanAccount.java and WarningDecision.java under com.github.paohaijiao.demo.model; the rule interface PostLoanWarningRules.java and test class PostLoanWarningServiceTest.java under com.github.paohaijiao.demo; the orchestration service PostLoanWarningService.java under com.github.paohaijiao.demo.service; and the rule script post-loan-warning.xml under src/test/resources/demo/. Keep the package names exactly as in the source; the model classes depend on Lombok’s @Data annotation. Run mvn test -Dtest=PostLoanWarningServiceTest at the project root to execute all 6 test cases.

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,启动诉讼材料准备"));
}
}

Run this case with the following command at the project root:

Terminal window
mvn test -Dtest=PostLoanWarningServiceTest

The test class has 6 @Test methods covering six shapes: severe overdue litigation, mild overdue SMS, mid-term collateralized home visit, unreachable vehicle-collateral phone collection, normal-account short-circuit, and long-term overdue red alert:

Test method Constructed business shape Key assertions / expected decision
shouldEscalateSevereOverdueAccount 95 days overdue (stage 4), overdue amount 200k, remaining principal 365k, no collateral, unreachable for 30 days, contractual rate 10% ORANGE, warning score 60, channel LITIGATION, penalty 14250.0, minimum repayment 210000.0, migration 8, litigation needed, actions include “repeated contact failed”
shouldKeepMildOverdueAccountLowLevel First overdue of 5 days (stage 1), amount 1000 yuan, reachable, rate 6% BLUE, warning score 27, channel SMS, penalty 450.0, minimum repayment 1010.0, migration 2, no litigation
shouldRouteCollateralizedOverdueToHomeVisit 45 days overdue (stage 2), amount 60k, house collateral appraised at 500k, reachable, 1 historical overdue ORANGE, warning score 66, channel HOME_VISIT, penalty 6750.0, minimum repayment 61800.0, migration 5, no litigation
shouldWarnLostContactAccount 35 days overdue (stage 2), amount 100k, vehicle collateral 300k, unreachable, 2 historical overdues, rate 12% YELLOW, warning score 41, channel PHONE, penalty 6300.0, minimum repayment 103000.0, migration 4, no litigation, actions include “repeated contact failed”
shouldShortCircuitWhenAccountNotOverdue A normal account: not overdue (0 days), no overdue amount BLUE, warning score 0, stage 0, channel SMS, penalty and minimum repayment both 0.0, migration 0, no litigation, action “account normal, no collection needed”
shouldRaiseRedAlertAndPrepareLitigation 120 days overdue (stage 4), amount 400k, house collateral 500k, 2 historical warnings, rate 15% RED, warning score 90, channel LITIGATION, penalty 27000.0, minimum repayment 420000.0, migration 9 (hits the cap), litigation needed, actions include “RED, start litigation material preparation”

Severe overdue pending litigation (95 days overdue, overdue amount 200k, remaining principal 1M, insufficient house collateral coverage):

预警等级: ORANGE 预警分: 60 逾期阶段: 4
催收渠道: LITIGATION 风险迁徙: 8
罚息金额: 14250.0 最低还款额: 210000.0

First mild overdue (3 days overdue, amount 10k, contact normal):

预警等级: BLUE 预警分: 27 逾期阶段: 1
催收渠道: SMS 罚息金额: 450.0
  • Stage, level, and fee thresholds all externalized in XML, hot-updatable: the 30/60/90-day stage lines, 80/60/40 warning lines, 100k litigation amount threshold, 1.5× penalty uplift, and 1%/3%/5% liquidated-damages ladder all live in post-loan-warning.xml — post-loan policy changes only require editing the XML and rebuilding the proxy.
  • Four scoring dimensions, one method per policy, independently tunable: overdue days, amount ratio, contact reachability, and collateral coverage are each an independent rule method; factors like the −10/−5 historical overdue deduction and unreachable-to-zero can be tuned individually, with clear traceable score contributions.
  • Built-in boundary guards in the rule scripts: scoreOverdueDays explicitly floors the score at zero after historical overdue deductions to avoid negative scores; riskMigration caps at 9 via if (migration > 9) so the migration degree of long-term overdue accounts stays bounded.
  • Service short-circuit + dual litigation check: non-overdue accounts short-circuit before scoring with zero rule calls and zero fee computation; the litigation flag uses the dual condition “warning score ≥80 OR (overdue ≥90 days AND amount ≥100k)”, cross-validating the rule-side channel routing.
  • Explicit floating-point in scripts + Java-side precision close-out: the script divides by 365.0 to force floating-point arithmetic; penalty interest and minimum repayment return to the Java side and are uniformly rounded to two decimals with BigDecimal, meeting financial booking standards.
  • Decisions directly drive collection work: the actions list covers unreachable verification, 90-day non-performing process, 180-day preliminary write-off, RED litigation preparation, unsecured-asset warnings, and credit adjustment for frequent overdue — ready for ticket/case-assignment systems to consume. Combined with Java 8, the ASM invocation chain, and proxy reuse, it embeds into post-loan batch jobs and domestic (Xinchuang) environments.