跳转到内容

案例 30:预售尾款资格/履约条件校验

本案例对应 JQuick-Java 仓库 USE-CASES 场景 30「预售尾款资格/履约条件校验」,属于电商零售领域。系统在预售订单进入尾款环节时做一次综合裁决:先校验商家预售履约资格,再按尾款窗口状态、买家实名情况与扣减锁定库存后的现货余量,决定订单是可付尾款、等待、暂挂、取消、定金没收还是关单退款。

预售交易分定金与尾款两段:买家先付定金锁货,商家在约定时间发货前开放尾款窗口。平台必须在买家点击「支付尾款」时同时管住商家侧与买家侧两类风险:商家信用恶化(信用分低于 80、有历史违约、未缴保证金)时要中止预售并退还定金;买家侧则存在窗口未开、逾期未补款、未实名、现货被锁定库存占用等多种状态。

商家资格由一条三条件布尔规则收口:信用分达到 80、历史预售违约次数为 0、已缴纳履约保证金,三项同时满足才具备资格。任一不满足,订单动作置为 BLOCKED,定金原路退还(REFUNDABLE),并按不满足项逐条给出原因。买家侧状态机围绕尾款窗口状态码展开:0 未开启、1 进行中、2 已结束(逾期)。窗口开启前主动取消为 CANCELLED,定金全退;窗口结束仍未补款为 FORFEIT,定金按协议没收;窗口未开启为 WAITING,定金由平台暂存(HOLD);窗口进行中但买家未实名则 HOLD,补全实名后即可支付。

库存口径独立计算:可售现货 = 当前现货库存 − 已为预售锁定的库存。窗口进行中、买家已实名,若可售现货少于本单需求件数,订单按尾款支付顺序排队等待调拨补货(WAITING,定金作担保抵扣 DEDUCT);只有现货充足时才置为 PAYABLE,应支付尾款取订单约定尾款金额,定金作履约担保。

决策输出物为 PresaleFinalPaymentDecision:六种处置动作(PAYABLE/WAITING/CANCELLED/FORFEIT/HOLD/BLOCKED)、本次应付尾款、定金处理方式(DEDUCT/REFUNDABLE/FORFEIT/HOLD)、扣减锁定后的可售现货数、商家资格结论与逐条审核说明(reasons),供订单中心、收银台与客服系统共用。

  • 预售规则受平台协议与监管要求约束且按大促调整(信用门槛、保证金政策、窗口时长),资格阈值硬编码在订单中心,每次调整都要卡发布窗口。
  • 一笔尾款要串联商家资格、窗口状态、实名、库存四条独立口径,状态组合多,写在一坨 if-else 里极易漏掉「商家失格即使窗口中也必须关单退定金」这类优先级。
  • 定金涉及没收(FORFEIT)与退还(REFUNDABLE)的财务后果,状态码 0/1/2 到业务处置的映射必须可审计、可向买家解释。
  • 现货库存需要扣除预售锁定量后再比较需求件数,含「恰好够」临界场景,库存口径与资格口径耦合会让补货逻辑难以独立维护。
  • 商家失格可能同时命中信用不足、有违约、未缴保证金多项,裁决需要逐条列出原因而非笼统驳回,便于商家申诉与平台举证。
  • 老电商订单中心与收银台多运行在 JDK 8、信创 JDK 上,无法为预售单一环节引入重型决策中间件与独立服务。

本案例采用「入参模型 + 决策模型 + 规则接口 + XML 规则脚本 + Service 编排 + JUnit 测试」六件套结构:商家资格、现货口径、定金处置、尾款条件四个规则方法各自独立并外置 XML,Service 按业务优先级编排短路链并装配原因文案。

层次 职责 对应文件
领域模型层 承载预售订单尾款与商家履约要素,输出动作、定金处置、现货余量与说明 PresaleOrder.java、PresaleFinalPaymentDecision.java
规则接口层 声明商家资格、可售现货、定金处置、尾款条件四个方法,@Param 按名绑定 PresaleFinalPaymentRules.java
规则脚本层 信用门槛、库存差值、窗口状态映射等口径外置,支持热更新 presale-final-payment.xml
编排与测试层 资格→取消→逾期→未开→实名→库存→放行短路链与原因装配;6 组形态回归 PresaleFinalPaymentService.java、PresaleFinalPaymentServiceTest.java
规则方法 口径 分值/输出
merchantEligible 信用分 ≥80、违约次数为 0、已缴保证金,三项同时成立才有预售资格 true/false
availableStock 当前现货库存减去已为预售锁定的库存 可售现货件数
depositTreatment 窗口前取消=REFUNDABLE;窗口逾期(2)=FORFEIT;进行中(1)已实名=DEDUCT、未实名=HOLD;未开启(0)=HOLD REFUNDABLE/FORFEIT/DEDUCT/HOLD
canPayBalance 尾款窗口进行中(状态 1)且买家已实名认证才可支付尾款 true/false

将下列文件按原始包名放入 JQuick-Java 测试工程:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试类放入 src/test/java/com/github/paohaijiao/demo/,Service 放入 src/test/java/com/github/paohaijiao/demo/service/,XML 脚本放入 src/test/resources/demo/。在工程根目录执行 mvn test -Dtest=PresaleFinalPaymentServiceTest 即可运行。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 预售订单尾款支付与履约要素
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PresaleOrder {
/**
* 订单号
*/
private String orderId;
/**
* 商品编码
*/
private String sku;
/**
* 已付定金(元)
*/
private double depositAmount;
/**
* 应付尾款(元)
*/
private double balanceAmount;
/**
* 尾款窗口状态:0 未开启 / 1 进行中 / 2 已结束(逾期)
*/
private int windowStatus;
/**
* 是否在窗口开启前主动取消
*/
private boolean cancelledBeforeWindow;
/**
* 本单购买件数
*/
private int demandQty;
/**
* 当前现货库存
*/
private int stockOnHand;
/**
* 已为预售锁定的库存
*/
private int reservedStock;
/**
* 买家是否已实名认证
*/
private boolean identityVerified;
/**
* 商家信用分(0-100)
*/
private int merchantCreditScore;
/**
* 商家历史预售违约次数
*/
private int merchantBreaches;
/**
* 商家是否已缴纳履约保证金
*/
private boolean merchantBondPaid;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 预售尾款资格与履约决策
*/
@Data
public class PresaleFinalPaymentDecision {
/**
* 订单号
*/
private String orderId;
/**
* 处置动作:PAYABLE 可付尾款 / WAITING 待窗口或待库存 / CANCELLED 已取消 /
* FORFEIT 定金没收 / HOLD 待实名 / BLOCKED 商家失格关单
*/
private String action;
/**
* 本次应支付尾款
*/
private double payableAmount;
/**
* 定金处理:DEDUCT 担保抵扣 / REFUNDABLE 可退 / FORFEIT 没收 / HOLD 暂挂
*/
private String depositTreatment;
/**
* 扣减锁定库存后的可售现货
*/
private int availableStock;
/**
* 商家是否具备预售履约资格
*/
private boolean merchantEligible;
/**
* 审核说明
*/
private List<String> reasons = new ArrayList<String>();
public void addReason(String reason) {
this.reasons.add(reason);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 30:预售尾款支付资格与商家履约规则
*/
public interface PresaleFinalPaymentRules {
/**
* 商家预售资格:信用分 ≥80、无违约记录、已缴保证金
*/
boolean merchantEligible(@Param("creditScore") int creditScore,
@Param("breaches") int breaches,
@Param("bondPaid") boolean bondPaid);
/**
* 扣减锁定库存后的可售现货
*/
int availableStock(@Param("stockOnHand") int stockOnHand,
@Param("reservedStock") int reservedStock);
/**
* 定金处理方式
* windowStatus:0 未开启 / 1 进行中 / 2 逾期
*/
String depositTreatment(@Param("windowStatus") int windowStatus,
@Param("identity") boolean identity,
@Param("cancelledBefore") boolean cancelledBefore);
/**
* 当前是否满足尾款支付条件(窗口进行中且已实名)
*/
boolean canPayBalance(@Param("windowStatus") int windowStatus,
@Param("identity") boolean identity);
}
<?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.PresaleFinalPaymentRules">
<java name="merchantEligible" returnClass="boolean">
<![CDATA[
boolean def merchantEligible(int:creditScore, int:breaches, boolean:bondPaid) {
if (creditScore >= 80) {
if (breaches == 0) {
if (bondPaid) {
return true;
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
}
]]>
</java>
<java name="availableStock" returnClass="int">
<![CDATA[
int def availableStock(int:stockOnHand, int:reservedStock) {
return stockOnHand - reservedStock;
}
]]>
</java>
<java name="depositTreatment" returnClass="java.lang.String">
<![CDATA[
str def depositTreatment(int:windowStatus, boolean:identity, boolean:cancelledBefore) {
if (cancelledBefore) {
return "REFUNDABLE";
} else if (windowStatus == 2) {
return "FORFEIT";
} else if (windowStatus == 1) {
if (identity) {
return "DEDUCT";
} else {
return "HOLD";
}
} else {
return "HOLD";
}
}
]]>
</java>
<java name="canPayBalance" returnClass="boolean">
<![CDATA[
boolean def canPayBalance(int:windowStatus, boolean:identity) {
if (windowStatus == 1) {
if (identity) {
return true;
} else {
return false;
}
} else {
return false;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.PresaleFinalPaymentRules;
import com.github.paohaijiao.demo.model.PresaleFinalPaymentDecision;
import com.github.paohaijiao.demo.model.PresaleOrder;
/**
* 场景 30:预售尾款资格、定金处置与商家履约校验
*/
public class PresaleFinalPaymentService {
private final PresaleFinalPaymentRules rules;
public PresaleFinalPaymentService(PresaleFinalPaymentRules rules) {
this.rules = rules;
}
public PresaleFinalPaymentDecision process(PresaleOrder order) {
PresaleFinalPaymentDecision d = new PresaleFinalPaymentDecision();
d.setOrderId(order.getOrderId());
int available = rules.availableStock(order.getStockOnHand(), order.getReservedStock());
d.setAvailableStock(available);
boolean eligible = rules.merchantEligible(order.getMerchantCreditScore(),
order.getMerchantBreaches(), order.isMerchantBondPaid());
d.setMerchantEligible(eligible);
if (!eligible) {
d.setAction("BLOCKED");
d.setPayableAmount(0.0);
d.setDepositTreatment("REFUNDABLE");
if (order.getMerchantCreditScore() < 80) {
d.addReason("商家信用分 " + order.getMerchantCreditScore() + " 低于 80 分预售门槛");
}
if (order.getMerchantBreaches() >= 1) {
d.addReason("商家存在 " + order.getMerchantBreaches() + " 次历史预售违约记录");
}
if (!order.isMerchantBondPaid()) {
d.addReason("商家未缴纳履约保证金");
}
d.addReason("商家预售资格中止,订单关闭,定金原路退还买家");
return d;
}
String treatment = rules.depositTreatment(order.getWindowStatus(),
order.isIdentityVerified(), order.isCancelledBeforeWindow());
if (order.isCancelledBeforeWindow()) {
d.setAction("CANCELLED");
d.setPayableAmount(0.0);
d.setDepositTreatment("REFUNDABLE");
d.addReason("尾款窗口开启前取消订单,定金全额退还");
return d;
}
if (order.getWindowStatus() == 2) {
d.setAction("FORFEIT");
d.setPayableAmount(0.0);
d.setDepositTreatment("FORFEIT");
d.addReason("尾款支付窗口已关闭且未补款,定金按预售协议不予退还");
return d;
}
if (order.getWindowStatus() == 0) {
d.setAction("WAITING");
d.setPayableAmount(0.0);
d.setDepositTreatment("HOLD");
d.addReason("尾款支付窗口尚未开启,定金由平台暂存");
return d;
}
if (!rules.canPayBalance(order.getWindowStatus(), order.isIdentityVerified())) {
d.setAction("HOLD");
d.setPayableAmount(0.0);
d.setDepositTreatment("HOLD");
d.addReason("买家尚未完成实名认证,补全实名后即可支付尾款");
return d;
}
if (available < order.getDemandQty()) {
d.setAction("WAITING");
d.setPayableAmount(0.0);
d.setDepositTreatment("DEDUCT");
d.addReason("扣减预售锁定库存后仅剩 " + available
+ " 件现货,不足本单 " + order.getDemandQty()
+ " 件需求,按尾款支付顺序排队等待调拨补货");
return d;
}
d.setAction("PAYABLE");
d.setPayableAmount(order.getBalanceAmount());
d.setDepositTreatment(treatment);
d.addReason("尾款窗口进行中且库存充足,应支付尾款 "
+ order.getBalanceAmount() + " 元,定金作履约担保");
return d;
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.PresaleFinalPaymentDecision;
import com.github.paohaijiao.demo.model.PresaleOrder;
import com.github.paohaijiao.demo.service.PresaleFinalPaymentService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 30:预售尾款资格与履约条件
* <p>
* 6 组形态:正常支付、窗口前取消退定金、逾期定金没收、库存不足排队与临界满足、
* 商家失格三种情形关单、未实名暂挂与补全放行。
*/
public class PresaleFinalPaymentServiceTest {
private PresaleFinalPaymentService service;
@Before
public void setUp() {
PresaleFinalPaymentRules rules = JQuickJava.create()
.createApi(PresaleFinalPaymentRules.class, "demo/presale-final-payment.xml");
this.service = new PresaleFinalPaymentService(rules);
}
/**
* 窗口进行中、实名、现货充足 → 支付 800 元尾款,定金担保
*/
@Test
public void shouldApproveFinalPayment() {
PresaleOrder o = base("PRE001");
o.setDepositAmount(200);
o.setBalanceAmount(800);
o.setWindowStatus(1);
o.setDemandQty(2);
o.setStockOnHand(100);
o.setReservedStock(30);
o.setIdentityVerified(true);
PresaleFinalPaymentDecision d = service.process(o);
Assert.assertEquals("PAYABLE", d.getAction());
Assert.assertEquals(800.0, d.getPayableAmount(), 0.0001);
Assert.assertEquals("DEDUCT", d.getDepositTreatment());
Assert.assertEquals(70, d.getAvailableStock());
Assert.assertTrue(d.isMerchantEligible());
}
/**
* 窗口未开启即取消 → 定金可退
*/
@Test
public void shouldRefundDepositWhenCancelledBeforeWindow() {
PresaleOrder o = base("PRE002");
o.setDepositAmount(200);
o.setWindowStatus(0);
o.setCancelledBeforeWindow(true);
PresaleFinalPaymentDecision d = service.process(o);
Assert.assertEquals("CANCELLED", d.getAction());
Assert.assertEquals(0.0, d.getPayableAmount(), 0.0001);
Assert.assertEquals("REFUNDABLE", d.getDepositTreatment());
Assert.assertTrue(d.getReasons().contains("尾款窗口开启前取消订单,定金全额退还"));
}
/**
* 窗口结束仍未付尾款 → 定金没收
*/
@Test
public void shouldForfeitDepositAfterWindowClosed() {
PresaleOrder o = base("PRE003");
o.setDepositAmount(200);
o.setWindowStatus(2);
o.setIdentityVerified(true);
PresaleFinalPaymentDecision d = service.process(o);
Assert.assertEquals("FORFEIT", d.getAction());
Assert.assertEquals("FORFEIT", d.getDepositTreatment());
Assert.assertEquals(0.0, d.getPayableAmount(), 0.0001);
Assert.assertTrue(d.getReasons().get(0).contains("定金按预售协议不予退还"));
}
/**
* 现货仅 1 件不足 5 件 → 排队;补库后恰好 5 件临界 → 放行
*/
@Test
public void shouldWaitWhenStockShortThenApproveAtExactAvailable() {
PresaleOrder o = base("PRE004");
o.setBalanceAmount(800);
o.setWindowStatus(1);
o.setIdentityVerified(true);
o.setDemandQty(5);
o.setStockOnHand(50);
o.setReservedStock(49);
PresaleFinalPaymentDecision d1 = service.process(o);
Assert.assertEquals("WAITING", d1.getAction());
Assert.assertEquals("DEDUCT", d1.getDepositTreatment());
Assert.assertTrue(d1.getReasons().get(0).contains("仅剩 1 件现货,不足本单 5 件需求"));
o.setReservedStock(45);
PresaleFinalPaymentDecision d2 = service.process(o);
Assert.assertEquals("PAYABLE", d2.getAction());
Assert.assertEquals(5, d2.getAvailableStock());
Assert.assertEquals(800.0, d2.getPayableAmount(), 0.0001);
}
/**
* 信用不足、历史违约、未缴保证金分别导致关单退定金
*/
@Test
public void shouldBlockWhenMerchantNotEligible() {
PresaleOrder lowCredit = base("PRE005");
lowCredit.setMerchantCreditScore(75);
PresaleFinalPaymentDecision d1 = service.process(lowCredit);
Assert.assertEquals("BLOCKED", d1.getAction());
Assert.assertEquals("REFUNDABLE", d1.getDepositTreatment());
Assert.assertTrue(d1.getReasons().contains("商家信用分 75 低于 80 分预售门槛"));
PresaleOrder breached = base("PRE006");
breached.setMerchantBreaches(1);
PresaleFinalPaymentDecision d2 = service.process(breached);
Assert.assertEquals("BLOCKED", d2.getAction());
Assert.assertTrue(d2.getReasons().contains("商家存在 1 次历史预售违约记录"));
PresaleOrder noBond = base("PRE007");
noBond.setMerchantBondPaid(false);
PresaleFinalPaymentDecision d3 = service.process(noBond);
Assert.assertEquals("BLOCKED", d3.getAction());
Assert.assertTrue(d3.getReasons().contains("商家未缴纳履约保证金"));
}
/**
* 窗口内未实名 → 暂挂;完成实名后放行
*/
@Test
public void shouldHoldForIdentityThenApprove() {
PresaleOrder o = base("PRE008");
o.setBalanceAmount(800);
o.setWindowStatus(1);
o.setIdentityVerified(false);
o.setDemandQty(1);
o.setStockOnHand(10);
o.setReservedStock(0);
PresaleFinalPaymentDecision d1 = service.process(o);
Assert.assertEquals("HOLD", d1.getAction());
Assert.assertEquals("HOLD", d1.getDepositTreatment());
Assert.assertTrue(d1.getReasons().get(0).contains("尚未完成实名认证"));
o.setIdentityVerified(true);
PresaleFinalPaymentDecision d2 = service.process(o);
Assert.assertEquals("PAYABLE", d2.getAction());
Assert.assertEquals(800.0, d2.getPayableAmount(), 0.0001);
}
private PresaleOrder base(String id) {
PresaleOrder o = new PresaleOrder();
o.setOrderId(id);
o.setSku("SKU300");
o.setDepositAmount(100);
o.setBalanceAmount(900);
o.setWindowStatus(1);
o.setCancelledBeforeWindow(false);
o.setDemandQty(1);
o.setStockOnHand(100);
o.setReservedStock(0);
o.setIdentityVerified(true);
o.setMerchantCreditScore(90);
o.setMerchantBreaches(0);
o.setMerchantBondPaid(true);
return o;
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=PresaleFinalPaymentServiceTest

测试覆盖形态(共 6 个 @Test 方法):

测试方法 业务形态 关键断言/期望决策
shouldApproveFinalPayment 窗口进行中、已实名、需 2 件、现货 100 锁定 30、尾款 800 action=PAYABLE、payableAmount=800、depositTreatment=DEDUCT、availableStock=70、merchantEligible=true
shouldRefundDepositWhenCancelledBeforeWindow 窗口未开启(0)且买家在窗口前主动取消 action=CANCELLED、应付 0、定金 REFUNDABLE;原因含「尾款窗口开启前取消订单,定金全额退还」
shouldForfeitDepositAfterWindowClosed 窗口已结束(2)、买家仍未补尾款 action=FORFEIT、depositTreatment=FORFEIT、应付 0;首条原因含「定金按预售协议不予退还」
shouldWaitWhenStockShortThenApproveAtExactAvailable 需 5 件:现货 50 锁定 49(仅剩 1)排队;锁定改为 45(恰剩 5)临界放行 d1=WAITING + DEDUCT,原因含「仅剩 1 件现货,不足本单 5 件需求」;d2=PAYABLE、availableStock=5、应付 800
shouldBlockWhenMerchantNotEligible 商家三种失格:信用 75、违约 1 次、未缴保证金 三者均 BLOCKED;定金均 REFUNDABLE;分别含「商家信用分 75 低于 80 分预售门槛」「商家存在 1 次历史预售违约记录」「商家未缴纳履约保证金」
shouldHoldForIdentityThenApprove 窗口进行中但未实名,现货充足;补全实名后重跑 先 HOLD + 定金 HOLD,原因含「尚未完成实名认证」;补实名后 PAYABLE、应付 800
  • 资格与状态口径外置热更新:信用 80 分门槛、窗口状态码到定金处置的映射、库存差值均写在 presale-final-payment.xml,预售协议调整只改 XML,订单中心无需发版。
  • Service 短路链即业务优先级:商家失格 BLOCKED → 窗口前取消 CANCELLED → 逾期 FORFEIT → 未开启 WAITING → 未实名 HOLD → 库存不足 WAITING → PAYABLE,七段链路命中即返回且各带可读原因。
  • 硬否决前置:商家资格先于一切买家侧状态判定,即使窗口进行中,信用不足或未缴保证金也直接关单退定金,风险裁决不被后续条件稀释。
  • 库存口径独立可测:availableStock 仅一行「现货 − 锁定」脚本,与资格、实名解耦;「仅剩 1 件排队、恰好 5 件放行」的临界断言锁定补货边界。
  • 正向 return 规避取反:merchantEligible、canPayBalance 等布尔脚本统一用嵌套 if 的正向分支返回 true、其余 return false,配合状态码 == 判等,不使用 !,状态机口径直观可审计。
  • ASM 代理、Java 8 无重型依赖:四个规则方法经动态代理执行并可复用,无独立决策服务进程,可直接嵌入 JDK 8 老订单中心、收银台与信创环境。