案例 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 |
规则方法拆分
Section titled “规则方法拆分”规则接口共 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 |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将六个文件按源码原有包结构放入 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 个用例。
InventoryItem.java
Section titled “InventoryItem.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 库存商品要素(滞销定价所需的商品与库存快照) * <p> * 字段来源于 WMS/ERP 的商品主数据与库存周转报表。 */@Data@NoArgsConstructor@AllArgsConstructorpublic 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;}PricingDecision.java
Section titled “PricingDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 动态定价决策结果 */@Datapublic 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); }}DynamicPricingRules.java
Section titled “DynamicPricingRules.java”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);}dynamic-pricing.xml
Section titled “dynamic-pricing.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.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>DynamicPricingService.java
Section titled “DynamicPricingService.java”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(); }}DynamicPricingServiceTest.java
Section titled “DynamicPricingServiceTest.java”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); }}在工程根目录执行以下命令运行本案例:
mvn test -Dtest=DynamicPricingServiceTest测试覆盖形态
Section titled “测试覆盖形态”测试类共 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 每日重定价。