案例 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 |
规则方法拆分
Section titled “规则方法拆分”| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
| merchantEligible | 信用分 ≥80、违约次数为 0、已缴保证金,三项同时成立才有预售资格 | true/false |
| availableStock | 当前现货库存减去已为预售锁定的库存 | 可售现货件数 |
| depositTreatment | 窗口前取消=REFUNDABLE;窗口逾期(2)=FORFEIT;进行中(1)已实名=DEDUCT、未实名=HOLD;未开启(0)=HOLD | REFUNDABLE/FORFEIT/DEDUCT/HOLD |
| canPayBalance | 尾款窗口进行中(状态 1)且买家已实名认证才可支付尾款 | true/false |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将下列文件按原始包名放入 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 即可运行。
PresaleOrder.java
Section titled “PresaleOrder.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 预售订单尾款支付与履约要素 */@Data@NoArgsConstructor@AllArgsConstructorpublic 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;}PresaleFinalPaymentDecision.java
Section titled “PresaleFinalPaymentDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 预售尾款资格与履约决策 */@Datapublic 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); }}PresaleFinalPaymentRules.java
Section titled “PresaleFinalPaymentRules.java”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);}presale-final-payment.xml
Section titled “presale-final-payment.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.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>PresaleFinalPaymentService.java
Section titled “PresaleFinalPaymentService.java”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; }}PresaleFinalPaymentServiceTest.java
Section titled “PresaleFinalPaymentServiceTest.java”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; }}在工程根目录执行:
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 老订单中心、收银台与信创环境。