跳转到内容

案例 4:库存滞销动态定价

本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 4:库存滞销动态定价(生产级样板),属于电商零售领域,解决零售清仓场景下的滞销 SKU 自动定价:以库龄基准折扣叠加动销、季节、临期、外包仓储四个修正因子,在最低折扣与品类毛利保护线双重约束下,输出最终折扣、建议售价、清仓等级与促销路由。

库存周转是零售与电商企业的核心经营指标。商品入库后随库龄增长持续占用仓位与资金,换季、临期、滞销品如果不能及时降价出清,会进一步吞噬毛利。商品与库存快照数据来源于 WMS/ERP 的商品主数据与库存周转报表,包含 SKU、品类、库龄、当前库存数量、成本价、当前售价、近 30 天销量、季节系数(旺季/平季/淡季)、是否临期易腐、仓库类型(自营仓/外包仓)以及品类最低毛利保护线等要素。

定价采用「库龄基准折扣 + 多维修正因子」结构。库龄按 30/60/90/180 天分为五级,基准折扣分别为 1.0、0.9、0.8、0.65、0.5,库龄越老折扣越深;在此之上叠加四个修正因子:动销因子将近 30 天销量与库存量交叉比较,滞销最深再加 -0.15、畅销回补 +0.05;淡季 -0.05、旺季 +0.05;临期商品库龄满 7 天 -0.10、其他临期 -0.05;外包仓因持有成本更高再减 -0.03。

折扣聚合后受到双重约束:一是最低折扣 0.30(三折地板价,防止多重因子叠加后击穿成本),二是品类毛利保护线——建议售价不得低于成本价乘以(1 + 最低毛利率),触线时按保护价执行(例如小家电 20% 毛利保护线下,折扣价 50 元也要抬回 72 元)。最终售价由当前售价乘以最终折扣得到,并回算预计毛利率。

决策输出物包括库龄分级、基准折扣、综合折扣、建议售价、清仓等级(DEEP/MEDIUM/LIGHT/NONE)、促销路由(DEEP 且临期走限时秒杀 FLASH_SALE、DEEP 走组合捆绑 BUNDLE、MEDIUM 发优惠券 COUPON、LIGHT 给会员价 MEMBER_PRICE)、是否建议纳入清仓池(库龄 ≥90 天,或 ≥60 天且月销量不足以覆盖库存)、预计毛利率与运营备注(触底提醒、毛利保护、外包仓优先清库、临期闪购等)。畅销旺季品则维持原价、不清仓、备注为空。

  • 库龄档位与基准折扣硬编码:30/60/90/180 天的分档线与 1.0/0.9/0.8/0.65/0.5 的基准折扣散落在清仓批处理脚本与运营手工台账中,换季节奏、大促让利幅度变化时必须修改代码并发版。
  • 多维修正因子分散、叠加失控:动销、季节、临期、仓储四个因子分别由运营、采销、仓储团队维护,手工叠加后可能出现远低于成本的售价(本案例零销量临期品叠加后低至 0.17),缺少统一的折扣下限。
  • 动销率存在整数除法陷阱:动销判断本质是近 30 天销量与库存量的比值比较,若在脚本中直接写 销量 / 库存,两个整数相除会被截断为 0,所有商品都落入同一档,必须改写比较形式。
  • 折扣机制与毛利保护割裂:品类毛利保护线(如生鲜 10%、小家电 20%)与折扣计算分属两套系统,折扣价击穿毛利线时无法自动回抬,容易造成「越清仓越亏损」。
  • 促销路由依赖人工挑选:限时秒杀、组合捆绑、优惠券、会员价四种玩法与清仓深度、临期属性的对应关系靠运营经验匹配,执行不一致且无法批量自动化。
  • 老旧 ERP/WMS 与信创环境约束:不少零售中后台仍运行在 JDK 8 与国产化配套 JDK 上,无法为一个清仓定价场景引入需要独立部署的重型规则或定价平台。

本案例采用「领域模型 + 规则接口与 XML 规则脚本 + 编排服务 + 测试」的六件套结构:库龄基准、四类修正系数、清仓分级与促销路由全部外置在 XML 中,Java 侧负责折扣聚合、上下限 clamp、毛利保护复核与备注组装。

层次 职责 对应文件
领域模型 SKU 库存与成本快照、动态定价决策结果 InventoryItem.java、PricingDecision.java
规则集与 XML 库龄基准 + 4 个修正因子 + 地板价/毛利保护 + 清仓/促销路由 DynamicPricingRules.java、dynamic-pricing.xml
编排服务 折扣聚合 clamp 0.30~1.0、毛利保护复核、毛利率回算、备注与精度处理 DynamicPricingService.java
测试 6 组库存业务形态 DynamicPricingServiceTest.java

规则接口共 10 个方法:1 个分级、1 个基准折扣、4 个修正因子、2 个约束与 2 个路由判定:

规则方法 口径 分值/输出
ageLevel 库龄 ≥180/≥90/≥60/≥30 天分档 4/3/2/1,否则 0 0 ~ 4
baseDiscount 库龄基准折扣 0.5 / 0.65 / 0.8 / 0.9 / 1.0 0.5 ~ 1.0
slowMovingFactor 近 30 天销量 ×10 与库存(及 ×3、×6)交叉比较:-0.15 / -0.08 / 0 / +0.05;库存 ≤0 返回 0 -0.15 ~ +0.05
seasonFactor 淡季 OFF -0.05、旺季 PEAK +0.05、平季 NORMAL 0 -0.05 ~ +0.05
perishableFactor 临期且库龄 ≥7 天 -0.10、其他临期 -0.05、非临期 0 -0.10 ~ 0
warehouseFactor 外包仓 OUTSOURCE -0.03,自营仓 0 -0.03 ~ 0
applyFloor 售价低于成本价 ×(1 + 毛利保护线)时按保护价执行 保护价(元)
clearanceLevel ≤0.55 DEEP / ≤0.75 MEDIUM / 小于 1.0 LIGHT / 否则 NONE DEEP / MEDIUM / LIGHT / NONE
promotionType DEEP 且临期限时秒杀、DEEP 组合捆绑、MEDIUM 优惠券、LIGHT 会员价、否则 NONE FLASH_SALE / BUNDLE / COUPON / MEMBER_PRICE / NONE
needClearance 库龄 ≥90 天,或库龄 ≥60 天且近 30 天销量 ×4 小于库存 boolean

将六个文件按源码原有包结构放入 Maven 工程的 src/test 对应目录:模型类 InventoryItem.java、PricingDecision.java 位于 com.github.paohaijiao.demo.model;规则接口 DynamicPricingRules.java 与测试类 DynamicPricingServiceTest.java 位于 com.github.paohaijiao.demo;编排服务 DynamicPricingService.java 位于 com.github.paohaijiao.demo.service;规则脚本 dynamic-pricing.xml 位于 src/test/resources/demo/。六个文件的包名保持源码原样,模型类依赖 Lombok 的 @Data 注解。在工程根目录执行 mvn test -Dtest=DynamicPricingServiceTest 即可运行全部 6 个用例。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 库存商品要素(滞销定价所需的商品与库存快照)
* <p>
* 字段来源于 WMS/ERP 的商品主数据与库存周转报表。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class InventoryItem {
/**
* 商品编码
*/
private String sku;
/**
* 商品名称
*/
private String skuName;
/**
* 品类
*/
private String category;
/**
* 库龄(天)
*/
private int stockAgeDays;
/**
* 当前库存数量
*/
private int stockQty;
/**
* 成本价(元)
*/
private double costPrice;
/**
* 当前售价(元)
*/
private double currentPrice;
/**
* 近 30 天销量
*/
private int recentSalesQty30d;
/**
* 季节系数:PEAK-旺季 / NORMAL-平季 / OFF-淡季
*/
private String seasonFactor;
/**
* 是否临期/易腐商品
*/
private boolean perishable;
/**
* 仓库类型:MAIN-自营仓 / OUTSOURCE-外包仓
*/
private String warehouseType;
/**
* 最低毛利保护线(如 0.1 表示不低于 10% 毛利)
*/
private double marginFloor;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 动态定价决策结果
*/
@Data
public class PricingDecision {
/**
* 商品编码
*/
private String sku;
/**
* 库龄分级:0-4
*/
private int ageLevel;
/**
* 库龄基准折扣
*/
private double baseDiscount;
/**
* 综合折扣(含周转、季节、临期、仓储修正)
*/
private double finalDiscount;
/**
* 建议售价(元)
*/
private double suggestedPrice;
/**
* 清仓等级:DEEP / MEDIUM / LIGHT / NONE
*/
private String clearanceLevel;
/**
* 促销方式:FLASH_SALE / BUNDLE / COUPON / MEMBER_PRICE / NONE
*/
private String promotionType;
/**
* 是否建议清仓
*/
private boolean needClearance;
/**
* 预计毛利率
*/
private double grossMarginRate;
/**
* 定价说明,供运营与商品同学参考
*/
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;
/**
* 场景 4(生产级样板):库存滞销动态定价规则集
* <p>
* 规则按「库龄基准折扣 + 多维修正 + 促销路由」拆分:
* <ul>
* <li>折扣类:库龄基准、周转、季节、临期、仓储五类修正</li>
* <li>决策类:清仓等级、促销方式、是否清仓、最低毛利保护</li>
* </ul>
* 规则脚本见 {@code src/test/resources/demo/dynamic-pricing.xml}。
*/
public interface DynamicPricingRules {
/**
* 库龄分级:0-4(<30 / 30-59 / 60-89 / 90-179 / >=180)
*/
int ageLevel(@Param("stockAgeDays") int stockAgeDays);
/**
* 库龄基准折扣
*/
double baseDiscount(@Param("stockAgeDays") int stockAgeDays);
/**
* 周转修正:近 30 天销量 / 库存,越滞销折扣越低
*/
double slowMovingFactor(@Param("recentSalesQty30d") int recentSalesQty30d,
@Param("stockQty") int stockQty);
/**
* 季节修正:淡季降价、旺季抬价
*/
double seasonFactor(@Param("seasonFactor") String seasonFactor);
/**
* 临期修正:易腐商品额外降价
*/
double perishableFactor(@Param("perishable") boolean perishable,
@Param("stockAgeDays") int stockAgeDays);
/**
* 仓储修正:外包仓额外降价
*/
double warehouseFactor(@Param("warehouseType") String warehouseType);
/**
* 最低毛利保护:售价不得低于 成本价 ×(1 + marginFloor)
*/
double applyFloor(@Param("price") double price,
@Param("costPrice") double costPrice,
@Param("marginFloor") double marginFloor);
/**
* 清仓等级:DEEP / MEDIUM / LIGHT / NONE
*/
String clearanceLevel(@Param("finalDiscount") double finalDiscount);
/**
* 促销方式路由
*/
String promotionType(@Param("clearanceLevel") String clearanceLevel,
@Param("perishable") boolean perishable);
/**
* 是否建议清仓:库龄 ≥ 90 天,或库龄 ≥ 60 天且可售月数 > 4
*/
boolean needClearance(@Param("stockAgeDays") int stockAgeDays,
@Param("stockQty") int stockQty,
@Param("recentSalesQty30d") int recentSalesQty30d);
}
<?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.DynamicPricingRules">
<!-- 库龄分级 -->
<java name="ageLevel" returnClass="int">
<![CDATA[
int def ageLevel(int:stockAgeDays) {
if (stockAgeDays >= 180) {
return 4;
} else if (stockAgeDays >= 90) {
return 3;
} else if (stockAgeDays >= 60) {
return 2;
} else if (stockAgeDays >= 30) {
return 1;
} else {
return 0;
}
}
]]>
</java>
<!-- 库龄基准折扣 -->
<java name="baseDiscount" returnClass="double">
<![CDATA[
double def baseDiscount(int:stockAgeDays) {
if (stockAgeDays >= 180) {
return 0.5;
} else if (stockAgeDays >= 90) {
return 0.65;
} else if (stockAgeDays >= 60) {
return 0.8;
} else if (stockAgeDays >= 30) {
return 0.9;
} else {
return 1.0;
}
}
]]>
</java>
<!-- 周转修正:近 30 天销量 / 库存 -->
<java name="slowMovingFactor" returnClass="double">
<![CDATA[
double def slowMovingFactor(int:recentSalesQty30d, int:stockQty) {
if (stockQty <= 0) {
return 0.0;
}
if (recentSalesQty30d * 10 <= stockQty) {
return -0.15;
} else if (recentSalesQty30d * 10 <= stockQty * 3) {
return -0.08;
} else if (recentSalesQty30d * 10 <= stockQty * 6) {
return 0.0;
} else {
return 0.05;
}
}
]]>
</java>
<!-- 季节修正 -->
<java name="seasonFactor" returnClass="double">
<![CDATA[
double def seasonFactor(str:seasonFactor) {
if (seasonFactor == "OFF") {
return -0.05;
} else if (seasonFactor == "PEAK") {
return 0.05;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 临期修正 -->
<java name="perishableFactor" returnClass="double">
<![CDATA[
double def perishableFactor(boolean:perishable, int:stockAgeDays) {
if (perishable && stockAgeDays >= 7) {
return -0.10;
} else if (perishable) {
return -0.05;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 仓储修正 -->
<java name="warehouseFactor" returnClass="double">
<![CDATA[
double def warehouseFactor(str:warehouseType) {
if (warehouseType == "OUTSOURCE") {
return -0.03;
} else {
return 0.0;
}
}
]]>
</java>
<!-- 最低毛利保护 -->
<java name="applyFloor" returnClass="double">
<![CDATA[
double def applyFloor(double:price, double:costPrice, double:marginFloor) {
double floor = costPrice * (1.0 + marginFloor);
if (price < floor) {
return floor;
} else {
return price;
}
}
]]>
</java>
<!-- 清仓等级 -->
<java name="clearanceLevel" returnClass="java.lang.String">
<![CDATA[
str def clearanceLevel(double:finalDiscount) {
if (finalDiscount <= 0.55) {
return "DEEP";
} else if (finalDiscount <= 0.75) {
return "MEDIUM";
} else if (finalDiscount < 1.0) {
return "LIGHT";
} else {
return "NONE";
}
}
]]>
</java>
<!-- 促销方式路由 -->
<java name="promotionType" returnClass="java.lang.String">
<![CDATA[
str def promotionType(str:clearanceLevel, boolean:perishable) {
if (clearanceLevel == "DEEP" && perishable) {
return "FLASH_SALE";
} else if (clearanceLevel == "DEEP") {
return "BUNDLE";
} else if (clearanceLevel == "MEDIUM") {
return "COUPON";
} else if (clearanceLevel == "LIGHT") {
return "MEMBER_PRICE";
} else {
return "NONE";
}
}
]]>
</java>
<!-- 是否建议清仓 -->
<java name="needClearance" returnClass="boolean">
<![CDATA[
boolean def needClearance(int:stockAgeDays, int:stockQty, int:recentSalesQty30d) {
if (stockAgeDays >= 90) {
return true;
} else if (stockAgeDays >= 60 && recentSalesQty30d * 4 < stockQty) {
return true;
} else {
return false;
}
}
]]>
</java>
</javas>
package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.DynamicPricingRules;
import com.github.paohaijiao.demo.model.InventoryItem;
import com.github.paohaijiao.demo.model.PricingDecision;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 场景 4:库存滞销动态定价服务
* <p>
* 职责划分:
* <ul>
* <li><b>Java 侧</b>:折扣聚合、上下限封顶、毛利保护复核、促销说明组装、金额精度处理</li>
* <li><b>规则侧</b>:库龄基准折扣、各类修正系数、清仓等级与促销路由,全部外置在 XML 中</li>
* </ul>
*/
public class DynamicPricingService {
/**
* 折扣下限,避免击穿成本
*/
private static final double MIN_DISCOUNT = 0.30;
/**
* 折扣上限,不允许加价
*/
private static final double MAX_DISCOUNT = 1.0;
private final DynamicPricingRules rules;
public DynamicPricingService(DynamicPricingRules rules) {
this.rules = rules;
}
/**
* 计算动态定价
*
* @param item 商品与库存快照
* @return 定价决策
*/
public PricingDecision price(InventoryItem item) {
PricingDecision decision = new PricingDecision();
decision.setSku(item.getSku());
int ageLevel = rules.ageLevel(item.getStockAgeDays());
double base = rules.baseDiscount(item.getStockAgeDays());
double discount = base
+ rules.slowMovingFactor(item.getRecentSalesQty30d(), item.getStockQty())
+ rules.seasonFactor(item.getSeasonFactor())
+ rules.perishableFactor(item.isPerishable(), item.getStockAgeDays())
+ rules.warehouseFactor(item.getWarehouseType());
if (discount < MIN_DISCOUNT) {
discount = MIN_DISCOUNT;
} else if (discount > MAX_DISCOUNT) {
discount = MAX_DISCOUNT;
}
String clearance = rules.clearanceLevel(discount);
double suggested = scale2(item.getCurrentPrice() * discount);
double floorPrice = rules.applyFloor(suggested, item.getCostPrice(), item.getMarginFloor());
boolean floorApplied = floorPrice > suggested;
double finalPrice = scale2(floorPrice);
decision.setAgeLevel(ageLevel);
decision.setBaseDiscount(base);
decision.setFinalDiscount(scale4(discount));
decision.setSuggestedPrice(finalPrice);
decision.setClearanceLevel(clearance);
decision.setPromotionType(rules.promotionType(clearance, item.isPerishable()));
decision.setNeedClearance(rules.needClearance(item.getStockAgeDays(), item.getStockQty(),
item.getRecentSalesQty30d()));
if (finalPrice > 0.0) {
decision.setGrossMarginRate(scale4((finalPrice - item.getCostPrice()) / finalPrice));
}
buildNotes(item, decision, floorApplied);
return decision;
}
private void buildNotes(InventoryItem item, PricingDecision decision, boolean floorApplied) {
if (floorApplied) {
decision.addNote("清仓折扣触及最低毛利保护线,按保护价执行");
}
if (decision.isNeedClearance()) {
decision.addNote("建议纳入清仓池,当前库龄 " + item.getStockAgeDays() + " 天");
}
if (item.isPerishable() && item.getStockAgeDays() >= 7) {
decision.addNote("临期商品,优先通过闪购与打包方式清货");
}
if ("OUTSOURCE".equals(item.getWarehouseType())) {
decision.addNote("外包仓商品,优先清库以降低仓储成本");
}
if (decision.getFinalDiscount() <= MIN_DISCOUNT) {
decision.addNote("折扣已触及下限 30%,请评估损失后处理");
}
}
private double scale2(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
private double scale4(double value) {
return BigDecimal.valueOf(value).setScale(4, RoundingMode.HALF_UP).doubleValue();
}
}
package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.InventoryItem;
import com.github.paohaijiao.demo.model.PricingDecision;
import com.github.paohaijiao.demo.service.DynamicPricingService;
import com.github.paohaijiao.xml.JQuickJava;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
/**
* 场景 4 生产级样板:库存滞销动态定价
* <p>
* 覆盖 6 组商品形态:严重滞销、畅销旺季、临期外包仓、触发毛利保护、
* 极限清仓、库龄临界待观察。
*/
public class DynamicPricingServiceTest {
private DynamicPricingService service;
@Before
public void setUp() {
DynamicPricingRules rules = JQuickJava.create()
.createApi(DynamicPricingRules.class, "demo/dynamic-pricing.xml");
this.service = new DynamicPricingService(rules);
}
/**
* 严重滞销:库龄 200 天、近 30 天仅售出 5 件 → 深度清仓,打包促销
*/
@Test
public void shouldDeepClearSeverelyAgedStock() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2001");
item.setSkuName("冬季羽绒服");
item.setCategory("服饰");
item.setStockAgeDays(200);
item.setStockQty(100);
item.setCostPrice(60.0);
item.setCurrentPrice(200.0);
item.setRecentSalesQty30d(5);
item.setSeasonFactor("NORMAL");
item.setPerishable(false);
item.setWarehouseType("MAIN");
item.setMarginFloor(0.10);
PricingDecision decision = service.price(item);
Assert.assertEquals(4, decision.getAgeLevel());
Assert.assertEquals(0.5, decision.getBaseDiscount(), 0.0001);
Assert.assertEquals(0.35, decision.getFinalDiscount(), 0.0001);
Assert.assertEquals(70.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("DEEP", decision.getClearanceLevel());
Assert.assertEquals("BUNDLE", decision.getPromotionType());
Assert.assertTrue(decision.isNeedClearance());
Assert.assertEquals(0.1429, decision.getGrossMarginRate(), 0.0001);
}
/**
* 畅销旺季:库龄 10 天、周转快、旺季 → 不打折,无促销
*/
@Test
public void shouldKeepFastSellingItemAtFullPrice() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2002");
item.setSkuName("当季连衣裙");
item.setCategory("女装");
item.setStockAgeDays(10);
item.setStockQty(100);
item.setCostPrice(50.0);
item.setCurrentPrice(100.0);
item.setRecentSalesQty30d(80);
item.setSeasonFactor("PEAK");
item.setPerishable(false);
item.setWarehouseType("MAIN");
item.setMarginFloor(0.10);
PricingDecision decision = service.price(item);
Assert.assertEquals(0, decision.getAgeLevel());
Assert.assertEquals(1.0, decision.getFinalDiscount(), 0.0001);
Assert.assertEquals(100.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("NONE", decision.getClearanceLevel());
Assert.assertEquals("NONE", decision.getPromotionType());
Assert.assertFalse(decision.isNeedClearance());
Assert.assertEquals(0.5, decision.getGrossMarginRate(), 0.0001);
Assert.assertTrue(decision.getNotes().isEmpty());
}
/**
* 临期外包仓:库龄 75 天、淡季、易腐、外包仓 → 深度清仓走闪购
*/
@Test
public void shouldFlashSalePerishableAgedItem() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2003");
item.setSkuName("进口冷链牛排");
item.setCategory("生鲜");
item.setStockAgeDays(75);
item.setStockQty(200);
item.setCostPrice(20.0);
item.setCurrentPrice(50.0);
item.setRecentSalesQty30d(40);
item.setSeasonFactor("OFF");
item.setPerishable(true);
item.setWarehouseType("OUTSOURCE");
item.setMarginFloor(0.10);
PricingDecision decision = service.price(item);
Assert.assertEquals(0.54, decision.getFinalDiscount(), 0.001);
Assert.assertEquals(27.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("DEEP", decision.getClearanceLevel());
Assert.assertEquals("FLASH_SALE", decision.getPromotionType());
Assert.assertTrue(decision.isNeedClearance());
Assert.assertEquals(0.2593, decision.getGrossMarginRate(), 0.0001);
}
/**
* 触发毛利保护:库龄 120 天、周转极慢,折扣价击穿毛利线 → 按保护价执行
*/
@Test
public void shouldProtectGrossMarginFloor() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2004");
item.setSkuName("滞销小家电");
item.setCategory("家电");
item.setStockAgeDays(120);
item.setStockQty(100);
item.setCostPrice(60.0);
item.setCurrentPrice(100.0);
item.setRecentSalesQty30d(10);
item.setSeasonFactor("NORMAL");
item.setPerishable(false);
item.setWarehouseType("MAIN");
item.setMarginFloor(0.20);
PricingDecision decision = service.price(item);
Assert.assertEquals(0.50, decision.getFinalDiscount(), 0.001);
Assert.assertEquals(72.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("DEEP", decision.getClearanceLevel());
Assert.assertEquals("BUNDLE", decision.getPromotionType());
Assert.assertEquals(0.1667, decision.getGrossMarginRate(), 0.0001);
Assert.assertTrue(decision.getNotes().contains("清仓折扣触及最低毛利保护线,按保护价执行"));
}
/**
* 极限清仓:库龄 200 天、零销量、临期、外包仓、淡季 → 折扣触底 30%
*/
@Test
public void shouldApplyFloorDiscountOnDeadStock() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2005");
item.setSkuName("临期礼盒");
item.setCategory("食品");
item.setStockAgeDays(200);
item.setStockQty(300);
item.setCostPrice(25.0);
item.setCurrentPrice(100.0);
item.setRecentSalesQty30d(0);
item.setSeasonFactor("OFF");
item.setPerishable(true);
item.setWarehouseType("OUTSOURCE");
item.setMarginFloor(0.05);
PricingDecision decision = service.price(item);
Assert.assertEquals(0.30, decision.getFinalDiscount(), 0.001);
Assert.assertEquals(30.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("DEEP", decision.getClearanceLevel());
Assert.assertEquals("FLASH_SALE", decision.getPromotionType());
Assert.assertTrue(decision.isNeedClearance());
Assert.assertEquals(0.1667, decision.getGrossMarginRate(), 0.0001);
}
/**
* 库龄临界:库龄 65 天、周转中等 → 轻度清仓,会员价促销,暂不强制清仓
*/
@Test
public void shouldApplyLightClearanceForMidAgedItem() {
InventoryItem item = new InventoryItem();
item.setSku("SKU-2006");
item.setSkuName("常规运动鞋");
item.setCategory("鞋靴");
item.setStockAgeDays(65);
item.setStockQty(100);
item.setCostPrice(50.0);
item.setCurrentPrice(80.0);
item.setRecentSalesQty30d(45);
item.setSeasonFactor("NORMAL");
item.setPerishable(false);
item.setWarehouseType("MAIN");
item.setMarginFloor(0.10);
PricingDecision decision = service.price(item);
Assert.assertEquals(0.8, decision.getFinalDiscount(), 0.0001);
Assert.assertEquals(64.0, decision.getSuggestedPrice(), 0.001);
Assert.assertEquals("LIGHT", decision.getClearanceLevel());
Assert.assertEquals("MEMBER_PRICE", decision.getPromotionType());
Assert.assertFalse(decision.isNeedClearance());
Assert.assertEquals(0.2188, decision.getGrossMarginRate(), 0.0001);
}
}

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

Terminal window
mvn test -Dtest=DynamicPricingServiceTest

测试类共 6 个 @Test 方法,覆盖深度清仓、畅销原价、临期闪购、毛利保护、极限触底、临界轻度清仓六类库存形态:

测试方法 构造的业务形态 关键断言/期望决策
shouldDeepClearSeverelyAgedStock 冬季羽绒服,库龄 200 天、库存 100、成本 60、现价 200、近 30 天售 5 件、平季、自营仓 库龄级 4,基准 0.5,最终折扣 0.35(叠加动销 -0.15),售价 70.0,DEEP + BUNDLE,需清仓,毛利率 0.1429
shouldKeepFastSellingItemAtFullPrice 当季连衣裙,库龄 10 天、近 30 天售 80 件、旺季、动销回补 +0.05 库龄级 0,聚合 1.1 被 clamp 到 1.0,售价 100.0,NONE + NONE,不清仓,毛利率 0.5,备注为空
shouldFlashSalePerishableAgedItem 进口冷链牛排,库龄 75 天、库存 200、淡季、临期、外包仓,动销 -0.08 最终折扣 0.54(0.8-0.08-0.05-0.10-0.03),售价 27.0,DEEP + FLASH_SALE,需清仓,毛利率 0.2593
shouldProtectGrossMarginFloor 滞销小家电,库龄 120 天、成本 60、现价 100、月销 10 件、毛利保护线 20% 最终折扣 0.50,折扣价 50 元击穿保护价 72 元,按保护价售 72.0,DEEP + BUNDLE,毛利率 0.1667,备注含「触及最低毛利保护线」
shouldApplyFloorDiscountOnDeadStock 临期礼盒,库龄 200 天、零销量、淡季、临期、外包仓,因子叠加至 0.17 折扣被三折地板价 clamp 到 0.30,售价 30.0,DEEP + FLASH_SALE,需清仓,毛利率 0.1667
shouldApplyLightClearanceForMidAgedItem 常规运动鞋,库龄 65 天、月销 45 件、动销修正为 0、平季自营 最终折扣 0.8,售价 64.0,LIGHT + MEMBER_PRICE,不需清仓(销量 ×4 不小于库存),毛利率 0.2188

严重滞销品(库龄 200 天、库存 100、近 30 天仅售 2 件、成本价 100、原价 200):

最终折扣: 0.35 建议售价: 70.0 清仓等级: DEEP
促销路由: BUNDLE 需清仓: true

畅销旺季品(库龄 10 天、动销健康、旺季因子为正):

最终折扣: 1.0 清仓等级: NONE 需清仓: false(备注为空)
  • 整数交叉相乘规避整数除法陷阱:脚本中 int * 1.0 / int 不会提升为浮点除法,动销率比较一律改写为整数交叉相乘——slowMovingFactor 用 recentSalesQty30d * 10 <= stockQty(及 stockQty * 3、stockQty * 6)分档,needClearance 用 recentSalesQty30d * 4 < stockQty,彻底避免相除截断为 0 的问题。
  • 折扣阈值与修正系数全部外置 XML:库龄五档基准、-0.15/-0.08/+0.05 动销档、淡旺季 ±0.05、临期 -0.10/-0.05、外包仓 -0.03、清仓等级线 0.55/0.75 均可热更新,换季大促调参无需发版。
  • Java 侧双重价格保护:Service 先把聚合折扣 clamp 到 0.30~1.0(不允许击穿三折地板、不允许加价),再调用 applyFloor 做品类毛利保护复核,折扣价低于成本保护价时自动回抬(50 元抬回 72 元)。
  • 清仓分级与促销路由两段解耦:先由折扣映射 DEEP/MEDIUM/LIGHT/NONE,再结合临期标记路由秒杀/捆绑/券/会员价,DEEP 临期商品自动进入限时秒杀,运营玩法与折扣深度严格对齐。
  • 决策自带经营视角输出:Service 回算预计毛利率 (售价-成本)/售价,并按命中条件组装备注(毛利保护、纳入清仓池、临期闪购、外包仓降本、折扣触底 30%),畅销品备注为空,可直接驱动清仓作业。
  • @Param 按名绑定 + 代理复用 + ASM 调用链:规则方法只收标量,代理一次创建、6 个用例复用;金额与折扣分别用 BigDecimal 保留 2 位与 4 位;引擎 Java 8 起步、无重型依赖,可嵌入 WMS/ERP 批处理与信创环境支撑全量 SKU 每日重定价。