跳转到内容

案例 7:零售库存补货建议

本案例对应 JQuick-Java 仓库 USE-CASES.md 的场景 7:零售库存补货建议,属于供应链/连锁零售领域,解决多仓 SKU 的「需求测算 → 安全库存 → 目标库存 → 缺口 → MOQ/整箱/上限约束 → 紧急度与加急建议」整条补货决策链路。

连锁零售企业的补货系统需要按 SKU、按仓库给出日常下单建议。每个商品维护一组补货参数:当前库存、基础安全库存、日均销量、补货提前期(天)、最小起订量 MOQ、整箱包装规格、供应商履约率(0-1)、是否处于促销期、旺淡季标记(PEAK/NORMAL/OFF)以及单次最大下单量。

规则链路先计算提前期需求(日均销量 × 提前期天数);动态安全库存在基础值之上,促销期额外加 5 天日均销量,供应商履约率低于 0.9 时再加 3 天日均销量;目标库存为安全库存与提前期需求之和,旺季再上浮 7 天销量、淡季下调 3 天销量,并保证不为负。

随后计算库存缺口(目标库存减当前库存,不为负),建议下单量依次经过三道供应约束:不足 MOQ 时按 MOQ 起订、按整箱包装规格向上取整、超过供应商单次上限时封顶(超出部分提示拆单)。与此同时,系统按当前库存可售天数相对提前期的位置,输出 CRITICAL/HIGH/MEDIUM/LOW 四级紧急度,并在可售天数不足提前期(或旺季临近)时建议加急物流。

决策结果包含提前期需求、安全库存、目标库存、缺口、最终下单量、可售天数、紧急度、是否加急以及一组备注(MOQ 上调、整箱取整、封顶拆单、促销与履约率说明等),直接驱动采购下单与仓间调度。场景 7 覆盖 6 组形态:紧急缺货、库存充足短路、促销旺季按 MOQ 上调到 500、淡季整箱取整为 60、中等紧急 HIGH、爆款大单封顶 1000 建议拆单。

  • 促销加 5 天、低履约率加 3 天、旺季加 7 天、淡季减 3 天等修正系数散落在定时任务、SQL 与各处硬编码中,换季或换供应商时要协调多处发版。
  • MOQ 最小起订、整箱取整、单次上限三道约束顺序敏感,硬编码实现经常漏掉其中一道,导致采购下单被供应商退回。
  • 紧急度阈值随提前期浮动(不超过提前期、+3 天、+7 天),不同仓库、不同提前期的商品难以统一口径。
  • 大促前参数需要频繁调整,但发布窗口有限,改个安全库存天数都要排期上线,容易错过备货窗口。
  • 补货结果往往只给一个下单数量,采购不理解「缺口 390 为什么下单 500」「2085 的缺口为什么只下 1000」,缺乏可解释的约束备注。
  • 不少 WMS/ERP 仍运行在 JDK 8 或信创环境,重型供应链计划平台落不下去。

本案例采用标准「六件套」:入参模型承载 SKU 与供应参数,决策结果模型承载整条链路的中间值与建议,规则接口声明 10 个规则方法,XML 脚本外置全部测算口径与阈值,Service 串联补货流水线并组装备注,JUnit 以 6 组补货形态回归。

层次 职责 对应文件
领域模型 SKU 补货参数、补货决策结果 ReplenishmentItem.java、ReplenishDecision.java
规则集与 XML 需求/安全库存/目标库存、MOQ/整箱/上限、可售天数/紧急度共 10 个方法 InventoryRules.java、inventory.xml
编排服务 补货链路串联、封顶拆单提示、库存充足短路、备注组装 ReplenishmentService.java
测试 6 组补货形态(紧急/充足/促销旺季/淡季取整/中等紧急/封顶) ReplenishmentServiceTest.java
规则方法 口径 分值/输出
demandDuringLeadTime 日均销量 × 提前期天数 提前期需求量
safetyStockLevel 基础安全库存;促销 +日均×5;履约率低于 0.9 再 +日均×3 动态安全库存
targetStock 安全库存 + 提前期需求;旺季 PEAK +日均×7;淡季 OFF -日均×3;不为负 目标库存
shortage 目标库存 - 当前库存,结果不为负 库存缺口
adjustToMoq 下单量高于 0 且低于 MOQ 时按 MOQ 下单 MOQ 后数量
roundToPack 按整箱包装规格向上取整(脚本中以 while 循环累加实现) 整箱数量
stockDays 当前库存 / 日均销量;日均销量不大于 0 时返回 999 防除零 可售天数
urgency ≤提前期 CRITICAL / ≤提前期+3 HIGH / ≤提前期+7 MEDIUM / 否则 LOW CRITICAL/HIGH/MEDIUM/LOW
suggestExpress 可售天数 ≤提前期,或旺季且 ≤提前期+3 → 建议加急 true / false
cappedQty 下单量超过单次上限时封顶为上限 最终下单量

将六个文件按仓库原有包结构放入 Maven 工程:两个模型放入 src/test/java/com/github/paohaijiao/demo/model/,规则接口与测试放入 com.github.paohaijiao.demo 包,编排服务放入 com.github.paohaijiao.demo.service 包,XML 脚本放入 src/test/resources/demo/inventory.xml,包名全部保持源码原样。工程根目录执行 mvn test -Dtest=ReplenishmentServiceTest 即可运行。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 补货决策要素(零售补货所需的商品、库存与供应参数)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReplenishmentItem {
/**
* 商品编码
*/
private String sku;
/**
* 商品名称
*/
private String skuName;
/**
* 仓库
*/
private String warehouse;
/**
* 当前库存
*/
private int currentStock;
/**
* 基础安全库存
*/
private int safetyStock;
/**
* 日均销量
*/
private int avgDailySales;
/**
* 补货提前期(天)
*/
private int leadTimeDays;
/**
* 最小起订量
*/
private int moq;
/**
* 包装规格(整箱数量)
*/
private int packSize;
/**
* 供应商履约率(0-1)
*/
private double supplierReliability;
/**
* 是否促销期
*/
private boolean promotion;
/**
* 季节系数:PEAK-旺季 / NORMAL-平季 / OFF-淡季
*/
private String seasonFactor;
/**
* 单次最大下单量
*/
private int maxOrder;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 补货决策结果
*/
@Data
public class ReplenishDecision {
/**
* 商品编码
*/
private String sku;
/**
* 补货提前期需求
*/
private int demandDuringLeadTime;
/**
* 动态安全库存
*/
private int safetyStockLevel;
/**
* 目标库存
*/
private int targetStock;
/**
* 库存缺口
*/
private int shortage;
/**
* 最终建议下单量(已考虑 MOQ、包装与上限)
*/
private int orderQty;
/**
* 可售天数
*/
private int stockDays;
/**
* 紧急度:CRITICAL / HIGH / MEDIUM / LOW
*/
private String urgency;
/**
* 是否建议加急补货
*/
private boolean suggestExpress;
/**
* 说明与建议
*/
private List<String> notes = new ArrayList<String>();
public void addNote(String note) {
this.notes.add(note);
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/**
* 场景 7(生产级样板):零售库存补货规则集
* <p>
* 规则按「需求测算 → 缺口计算 → 下单约束 → 紧急度」拆分:
* <ul>
* <li>测算类:提前期需求、动态安全库存、目标库存</li>
* <li>约束类:MOQ、包装规格取整(规则内演示 while 循环)、单次上限</li>
* <li>决策类:可售天数、紧急度、是否加急</li>
* </ul>
* 规则脚本见 {@code src/test/resources/demo/inventory.xml}。
*/
public interface InventoryRules {
/**
* 补货提前期需求 = 日均销量 × 提前期
*/
int demandDuringLeadTime(@Param("avgDailySales") int avgDailySales,
@Param("leadTimeDays") int leadTimeDays);
/**
* 动态安全库存:促销期与低履约率供应商需要更高安全库存
*/
int safetyStockLevel(@Param("safetyStock") int safetyStock,
@Param("avgDailySales") int avgDailySales,
@Param("supplierReliability") double supplierReliability,
@Param("promotion") boolean promotion);
/**
* 目标库存:安全库存 + 提前期需求,并按季节系数修正
*/
int targetStock(@Param("safetyStock") int safetyStock,
@Param("demandDuringLeadTime") int demandDuringLeadTime,
@Param("seasonFactor") String seasonFactor,
@Param("avgDailySales") int avgDailySales);
/**
* 库存缺口
*/
int shortage(@Param("targetStock") int targetStock,
@Param("currentStock") int currentStock);
/**
* 最小起订量约束
*/
int adjustToMoq(@Param("qty") int qty,
@Param("moq") int moq);
/**
* 包装规格向上取整(while 循环示例)
*/
int roundToPack(@Param("qty") int qty,
@Param("packSize") int packSize);
/**
* 可售天数
*/
int stockDays(@Param("currentStock") int currentStock,
@Param("avgDailySales") int avgDailySales);
/**
* 紧急度:CRITICAL / HIGH / MEDIUM / LOW
*/
String urgency(@Param("stockDays") int stockDays,
@Param("leadTimeDays") int leadTimeDays);
/**
* 是否建议加急补货
*/
boolean suggestExpress(@Param("stockDays") int stockDays,
@Param("leadTimeDays") int leadTimeDays,
@Param("seasonFactor") String seasonFactor);
/**
* 单次下单量上限
*/
int cappedQty(@Param("qty") int qty,
@Param("maxOrder") int maxOrder);
}
<?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.InventoryRules">
<!-- 提前期需求 -->
<java name="demandDuringLeadTime" returnClass="int">
<![CDATA[
int def demandDuringLeadTime(int:avgDailySales, int:leadTimeDays) {
return avgDailySales * leadTimeDays;
}
]]>
</java>
<!-- 动态安全库存 -->
<java name="safetyStockLevel" returnClass="int">
<![CDATA[
int def safetyStockLevel(int:safetyStock, int:avgDailySales, double:supplierReliability, boolean:promotion) {
int level = safetyStock;
if (promotion) {
level = level + avgDailySales * 5;
}
if (supplierReliability < 0.9) {
level = level + avgDailySales * 3;
}
return level;
}
]]>
</java>
<!-- 目标库存 -->
<java name="targetStock" returnClass="int">
<![CDATA[
int def targetStock(int:safetyStock, int:demandDuringLeadTime, str:seasonFactor, int:avgDailySales) {
int target = safetyStock + demandDuringLeadTime;
if (seasonFactor == "PEAK") {
target = target + avgDailySales * 7;
} else if (seasonFactor == "OFF") {
target = target - avgDailySales * 3;
}
if (target < 0) {
target = 0;
}
return target;
}
]]>
</java>
<!-- 库存缺口 -->
<java name="shortage" returnClass="int">
<![CDATA[
int def shortage(int:targetStock, int:currentStock) {
int gap = targetStock - currentStock;
if (gap < 0) {
gap = 0;
}
return gap;
}
]]>
</java>
<!-- 最小起订量约束 -->
<java name="adjustToMoq" returnClass="int">
<![CDATA[
int def adjustToMoq(int:qty, int:moq) {
int result = qty;
if (qty > 0 && qty < moq) {
result = moq;
}
return result;
}
]]>
</java>
<!-- 包装规格向上取整:while 循环示例 -->
<java name="roundToPack" returnClass="int">
<![CDATA[
int def roundToPack(int:qty, int:packSize) {
if (qty <= 0) {
return 0;
}
if (packSize <= 1) {
return qty;
}
int rounded = 0;
while (rounded < qty) {
rounded = rounded + packSize;
}
return rounded;
}
]]>
</java>
<!-- 可售天数 -->
<java name="stockDays" returnClass="int">
<![CDATA[
int def stockDays(int:currentStock, int:avgDailySales) {
if (avgDailySales <= 0) {
return 999;
}
return currentStock / avgDailySales;
}
]]>
</java>
<!-- 紧急度 -->
<java name="urgency" returnClass="java.lang.String">
<![CDATA[
str def urgency(int:stockDays, int:leadTimeDays) {
if (stockDays <= leadTimeDays) {
return "CRITICAL";
} else if (stockDays <= leadTimeDays + 3) {
return "HIGH";
} else if (stockDays <= leadTimeDays + 7) {
return "MEDIUM";
} else {
return "LOW";
}
}
]]>
</java>
<!-- 是否加急补货 -->
<java name="suggestExpress" returnClass="boolean">
<![CDATA[
boolean def suggestExpress(int:stockDays, int:leadTimeDays, str:seasonFactor) {
if (stockDays <= leadTimeDays) {
return true;
} else if (seasonFactor == "PEAK" && stockDays <= leadTimeDays + 3) {
return true;
} else {
return false;
}
}
]]>
</java>
<!-- 单次下单量上限 -->
<java name="cappedQty" returnClass="int">
<![CDATA[
int def cappedQty(int:qty, int:maxOrder) {
int result = qty;
if (result > maxOrder) {
result = maxOrder;
}
return result;
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.InventoryRules;
import com.github.paohaijiao.demo.model.ReplenishDecision;
import com.github.paohaijiao.demo.model.ReplenishmentItem;
/**
* 场景 7:零售库存补货建议服务
* <p>
* 职责划分:
* <ul>
* <li><b>Java 侧</b>:链路串联(需求 → 安全库存 → 目标 → 缺口 → 约束 → 上限)、建议组装</li>
* <li><b>规则侧</b>:销量测算口径、安全库存策略、季节系数、取整规则、紧急度阈值,全部外置在 XML 中</li>
* </ul>
*/
public class ReplenishmentService {
private final InventoryRules rules;
public ReplenishmentService(InventoryRules rules) {
this.rules = rules;
}
/**
* 计算补货建议
*
* @param item 商品与库存快照
* @return 补货决策
*/
public ReplenishDecision replenish(ReplenishmentItem item) {
ReplenishDecision decision = new ReplenishDecision();
decision.setSku(item.getSku());
int ddlt = rules.demandDuringLeadTime(item.getAvgDailySales(), item.getLeadTimeDays());
int safety = rules.safetyStockLevel(item.getSafetyStock(), item.getAvgDailySales(),
item.getSupplierReliability(), item.isPromotion());
int target = rules.targetStock(safety, ddlt, item.getSeasonFactor(), item.getAvgDailySales());
int shortage = rules.shortage(target, item.getCurrentStock());
int roundedQty = rules.roundToPack(rules.adjustToMoq(shortage, item.getMoq()), item.getPackSize());
int orderQty = rules.cappedQty(roundedQty, item.getMaxOrder());
int stockDays = rules.stockDays(item.getCurrentStock(), item.getAvgDailySales());
String urgency = rules.urgency(stockDays, item.getLeadTimeDays());
decision.setDemandDuringLeadTime(ddlt);
decision.setSafetyStockLevel(safety);
decision.setTargetStock(target);
decision.setShortage(shortage);
decision.setOrderQty(orderQty);
decision.setStockDays(stockDays);
decision.setUrgency(urgency);
decision.setSuggestExpress(rules.suggestExpress(stockDays, item.getLeadTimeDays(), item.getSeasonFactor()));
buildNotes(item, decision, roundedQty, orderQty);
return decision;
}
private void buildNotes(ReplenishmentItem item, ReplenishDecision decision, int roundedQty, int orderQty) {
if (decision.getShortage() == 0) {
decision.addNote("库存充足,本次无需补货");
return;
}
if (roundedQty > decision.getShortage()) {
decision.addNote("按 MOQ/整箱包装取整后下单量高于库存缺口,属正常约束");
}
if (orderQty < roundedQty) {
decision.addNote("单次下单量受上限约束封顶为 " + orderQty + ",超出部分建议拆单");
}
if (decision.getShortage() < item.getMoq()) {
decision.addNote("缺口低于最小起订量,按 MOQ 下单");
}
if (item.isPromotion()) {
decision.addNote("促销期需求上浮,安全库存已自动抬升");
}
if (item.getSupplierReliability() < 0.9) {
decision.addNote("供应商履约率 " + item.getSupplierReliability() + " 偏低,安全库存已上调");
}
if ("PEAK".equals(item.getSeasonFactor())) {
decision.addNote("旺季备货,目标库存上调 7 天销量");
}
if (decision.isSuggestExpress()) {
decision.addNote("可售天数不足提前期,建议加急补货");
}
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.ReplenishDecision;
import com.github.paohaijiao.demo.model.ReplenishmentItem;
import com.github.paohaijiao.demo.service.ReplenishmentService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 7 生产级样板:零售库存补货建议
* <p>
* 覆盖 6 组补货形态:紧急缺货、库存充足、促销旺季、淡季整箱取整、
* 中等紧急、单次上限封顶。
*/
public class ReplenishmentServiceTest {
private ReplenishmentService service;
@Before
public void setUp() {
InventoryRules rules = JQuickJava.create()
.createApi(InventoryRules.class, "demo/inventory.xml");
this.service = new ReplenishmentService(rules);
}
/**
* 紧急缺货:库存 20、日均 30、供应商履约率 0.8 → 目标 290,紧急 CRITICAL
*/
@Test
public void shouldUrgentlyReplenishShortage() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7001");
item.setSkuName("矿泉水 550ml");
item.setWarehouse("华东仓");
item.setCurrentStock(20);
item.setSafetyStock(50);
item.setAvgDailySales(30);
item.setLeadTimeDays(5);
item.setMoq(50);
item.setPackSize(10);
item.setSupplierReliability(0.8);
item.setPromotion(false);
item.setSeasonFactor("NORMAL");
item.setMaxOrder(5000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(150, decision.getDemandDuringLeadTime());
Assert.assertEquals(140, decision.getSafetyStockLevel());
Assert.assertEquals(290, decision.getTargetStock());
Assert.assertEquals(270, decision.getShortage());
Assert.assertEquals(270, decision.getOrderQty());
Assert.assertEquals(0, decision.getStockDays());
Assert.assertEquals("CRITICAL", decision.getUrgency());
Assert.assertTrue(decision.isSuggestExpress());
}
/**
* 库存充足:库存 500、目标 200 → 无需补货,紧急度 LOW
*/
@Test
public void shouldSkipWhenStockSufficient() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7002");
item.setSkuName("卷纸");
item.setWarehouse("华北仓");
item.setCurrentStock(500);
item.setSafetyStock(50);
item.setAvgDailySales(30);
item.setLeadTimeDays(5);
item.setMoq(50);
item.setPackSize(10);
item.setSupplierReliability(0.95);
item.setPromotion(false);
item.setSeasonFactor("NORMAL");
item.setMaxOrder(5000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(150, decision.getDemandDuringLeadTime());
Assert.assertEquals(50, decision.getSafetyStockLevel());
Assert.assertEquals(200, decision.getTargetStock());
Assert.assertEquals(0, decision.getShortage());
Assert.assertEquals(0, decision.getOrderQty());
Assert.assertEquals(16, decision.getStockDays());
Assert.assertEquals("LOW", decision.getUrgency());
Assert.assertFalse(decision.isSuggestExpress());
Assert.assertTrue(decision.getNotes().contains("库存充足,本次无需补货"));
}
/**
* 促销旺季:低履约 + 促销 + 旺季,缺口 390 但 MOQ 500 → 按 MOQ 下单
*/
@Test
public void shouldBoostSafetyStockInPromotionPeak() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7003");
item.setSkuName("空调扇");
item.setWarehouse("华南仓");
item.setCurrentStock(100);
item.setSafetyStock(50);
item.setAvgDailySales(20);
item.setLeadTimeDays(7);
item.setMoq(500);
item.setPackSize(100);
item.setSupplierReliability(0.85);
item.setPromotion(true);
item.setSeasonFactor("PEAK");
item.setMaxOrder(5000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(140, decision.getDemandDuringLeadTime());
Assert.assertEquals(210, decision.getSafetyStockLevel());
Assert.assertEquals(490, decision.getTargetStock());
Assert.assertEquals(390, decision.getShortage());
Assert.assertEquals(500, decision.getOrderQty());
Assert.assertEquals(5, decision.getStockDays());
Assert.assertEquals("CRITICAL", decision.getUrgency());
Assert.assertTrue(decision.getNotes().contains("促销期需求上浮,安全库存已自动抬升"));
}
/**
* 淡季整箱取整:目标 110、缺口 50、包装 20 → 取整为 60
*/
@Test
public void shouldRoundUpToPackSize() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7004");
item.setSkuName("暖宝宝");
item.setWarehouse("华北仓");
item.setCurrentStock(60);
item.setSafetyStock(40);
item.setAvgDailySales(10);
item.setLeadTimeDays(10);
item.setMoq(20);
item.setPackSize(20);
item.setSupplierReliability(0.98);
item.setPromotion(false);
item.setSeasonFactor("OFF");
item.setMaxOrder(5000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(100, decision.getDemandDuringLeadTime());
Assert.assertEquals(110, decision.getTargetStock());
Assert.assertEquals(50, decision.getShortage());
Assert.assertEquals(60, decision.getOrderQty());
Assert.assertEquals(6, decision.getStockDays());
Assert.assertEquals("CRITICAL", decision.getUrgency());
}
/**
* 中等紧急:可售 6 天、提前期 3 天 → HIGH(提前期 +3 天内)
*/
@Test
public void shouldMarkHighUrgency() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7005");
item.setSkuName("咖啡豆");
item.setWarehouse("华东仓");
item.setCurrentStock(30);
item.setSafetyStock(40);
item.setAvgDailySales(5);
item.setLeadTimeDays(3);
item.setMoq(10);
item.setPackSize(5);
item.setSupplierReliability(0.95);
item.setPromotion(false);
item.setSeasonFactor("NORMAL");
item.setMaxOrder(1000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(15, decision.getDemandDuringLeadTime());
Assert.assertEquals(55, decision.getTargetStock());
Assert.assertEquals(25, decision.getShortage());
Assert.assertEquals(25, decision.getOrderQty());
Assert.assertEquals(6, decision.getStockDays());
Assert.assertEquals("HIGH", decision.getUrgency());
Assert.assertFalse(decision.isSuggestExpress());
}
/**
* 单次上限封顶:缺口 2085、整箱取整 2100,但单次上限 1000 → 下单 1000
*/
@Test
public void shouldCapOrderQuantity() {
ReplenishmentItem item = new ReplenishmentItem();
item.setSku("SKU-7006");
item.setSkuName("爆款零食");
item.setWarehouse("华中仓");
item.setCurrentStock(15);
item.setSafetyStock(100);
item.setAvgDailySales(400);
item.setLeadTimeDays(5);
item.setMoq(100);
item.setPackSize(100);
item.setSupplierReliability(0.9);
item.setPromotion(false);
item.setSeasonFactor("NORMAL");
item.setMaxOrder(1000);
ReplenishDecision decision = service.replenish(item);
Assert.assertEquals(2000, decision.getDemandDuringLeadTime());
Assert.assertEquals(100, decision.getSafetyStockLevel());
Assert.assertEquals(2100, decision.getTargetStock());
Assert.assertEquals(2085, decision.getShortage());
Assert.assertEquals(1000, decision.getOrderQty());
Assert.assertEquals(0, decision.getStockDays());
Assert.assertEquals("CRITICAL", decision.getUrgency());
Assert.assertTrue(decision.getNotes().contains("按 MOQ/整箱包装取整后下单量高于库存缺口,属正常约束"));
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=ReplenishmentServiceTest
测试方法 构造的业务形态 关键断言/期望决策
shouldUrgentlyReplenishShortage 矿泉水:库存 20、日均 30、提前期 5 天、履约率 0.8 提前期需求 150、安全库存 140、目标 290、缺口 270、下单 270、可售 0 天、CRITICAL、加急 true
shouldSkipWhenStockSufficient 卷纸:库存 500、履约率 0.95 目标 200、缺口 0、下单 0、可售 16 天、LOW、不加急、含「库存充足,本次无需补货」备注
shouldBoostSafetyStockInPromotionPeak 空调扇:促销 + 旺季 PEAK + 履约率 0.85、MOQ 500 需求 140、安全库存 210、目标 490、缺口 390、下单 500(按 MOQ 上调)、可售 5 天、CRITICAL
shouldRoundUpToPackSize 暖宝宝:淡季 OFF、缺口 50、箱规 20 需求 100、目标 110、缺口 50、整箱取整后下单 60、可售 6 天、CRITICAL
shouldMarkHighUrgency 咖啡豆:库存 30、日均 5、提前期 3 天 需求 15、目标 55、缺口 25、下单 25、可售 6 天、HIGH、不加急
shouldCapOrderQuantity 爆款零食:日均 400、缺口 2085、整箱 2100、单次上限 1000 需求 2000、目标 2100、下单 1000(封顶)、可售 0 天、CRITICAL、含取整约束备注

USE-CASES.md 场景 7 收录的两组实测输出:

紧急缺货(当前库存 20、目标库存 290、缺口 270):

目标库存: 290 缺口: 270 紧急度: CRITICAL 建议加急: true

促销旺季大单(目标 490、缺口 390,受 MOQ 约束):

目标库存: 490 缺口: 390 下单量: 500(按 MOQ 上调)
  • 安全库存修正天数(5/3)、旺淡季修正(+7/-3)、MOQ、箱规、单次上限、紧急度阈值全部外置在 XML 中,换季与供应商变更改脚本即可,无需重新发版。
  • roundToPack 在规则脚本内用 while 循环累加实现整箱向上取整,并对数量不大于 0、箱规不大于 1 做了边界保护,展示了脚本的循环表达能力。
  • Service 以流水线方式串联「需求 → 安全库存 → 目标 → 缺口 → MOQ → 整箱 → 上限」,库存充足时备注短路,并在封顶时输出拆单提示,业务约束顺序一目了然。
  • stockDays 用整数除法计算可售天数,对日均销量不大于 0 的冷僻商品返回 999 天,避免除零;缺口与目标库存在脚本侧统一兜底不为负。
  • 规则接口通过 @Param 按名绑定多参数(如 targetStock 的 4 个入参),代理实例创建一次即可在批量补货任务中复用,底层为 ASM 调用链。
  • Java 8 即可运行、无重型规则平台依赖,可直接嵌入 JDK 8 的 WMS/ERP 与信创环境,高频 SKU 决策无额外进程开销。