Case 4: Slow-moving inventory dynamic pricing
This case corresponds to Scenario 4: Slow-moving inventory dynamic pricing (production-grade template) in the JQuick-Java repository’s USE-CASES.md, in the e-commerce & retail domain. It automates pricing for slow-moving SKUs in retail clearance scenarios: a stock-age base discount is overlaid with four adjustment factors — sales velocity, seasonality, near-expiry, and outsourced warehousing — and, under the dual constraints of a minimum discount and a category gross-margin protection line, outputs the final discount, suggested price, clearance level, and promotion route.
Industry scenario
Section titled “Industry scenario”Inventory turnover is a core operating metric for retail and e-commerce businesses. Once warehoused, goods occupy storage and capital as their stock age grows; seasonal, near-expiry, and slow-moving items that are not marked down in time further erode gross margin. Product and inventory snapshot data comes from WMS/ERP product master data and inventory turnover reports, including SKU, category, stock age, current stock quantity, cost price, current selling price, last-30-day sales volume, season factor (peak / normal / off season), whether the item is near-expiry/perishable, warehouse type (self-operated / outsourced), and the category minimum gross-margin protection line.
Pricing uses a “stock-age base discount + multi-dimensional adjustment factors” structure. Stock age is divided into five tiers at 30/60/90/180 days, with base discounts of 1.0, 0.9, 0.8, 0.65, and 0.5 — the older the stock, the deeper the discount. On top of the base, four adjustment factors are overlaid: the sales-velocity factor cross-compares last-30-day sales against stock quantity, adding up to −0.15 for the most stagnant items and rebating +0.05 for fast sellers; off season −0.05, peak season +0.05; perishable items at 7+ days of stock age −0.10, other perishables −0.05; and outsourced warehouses, which cost more to hold, another −0.03.
After aggregation, the discount is subject to dual constraints: first, a minimum discount of 0.30 (a 70%-off floor price, preventing stacked factors from punching through cost); second, the category gross-margin protection line — the suggested price must not fall below cost price × (1 + minimum margin rate), and when the line is hit the protected price applies (for example, under a 20% margin floor for small home appliances, a discounted price of 50 yuan is raised back to 72 yuan). The final selling price is the current price multiplied by the final discount, and the expected gross margin rate is back-calculated.
The decision output includes the stock-age level, base discount, aggregated discount, suggested price, clearance level (DEEP/MEDIUM/LIGHT/NONE), promotion route (DEEP + perishable → flash sale FLASH_SALE, DEEP → bundle BUNDLE, MEDIUM → coupon COUPON, LIGHT → member price MEMBER_PRICE), whether to include the item in the clearance pool (stock age ≥90 days, or ≥60 days with monthly sales insufficient to cover stock), the expected gross margin rate, and operational notes (floor-hit reminder, margin protection, outsourced-warehouse priority clearance, near-expiry flash sale, etc.). Fast-selling peak-season items stay at full price, are not cleared, and carry no notes.
Business pain points
Section titled “Business pain points”- Hard-coded stock-age tiers and base discounts: the 30/60/90/180-day tier lines and the 1.0/0.9/0.8/0.65/0.5 base discounts are scattered across clearance batch scripts and manual operations ledgers; every seasonal rhythm change or promotion concession requires code changes and a release.
- Scattered adjustment factors that stack out of control: the four factors — sales velocity, seasonality, near-expiry, and warehousing — are maintained separately by operations, merchandising, and warehouse teams; manual stacking can produce prices far below cost (in this case, a zero-sales near-expiry item stacks down to 0.17), with no unified discount floor.
- An integer-division trap in sales-velocity checks: the velocity check is essentially a ratio comparison of last-30-day sales to stock quantity; writing
sales / stockdirectly in the script truncates two integers to 0, dropping every item into the same tier — the comparison must be rewritten in cross-multiplication form. - Discount mechanics divorced from margin protection: category margin floors (e.g., 10% for fresh food, 20% for small home appliances) and discount computation live in two separate systems; when a discounted price punches through the margin line there is no automatic lift-back, so “the more you clear, the more you lose”.
- Promotion routing relies on manual picks: the mapping between the four playbooks — flash sale, bundle, coupon, member price — and clearance depth / near-expiry attributes depends on operator experience, is applied inconsistently, and cannot be automated in bulk.
- Legacy ERP/WMS and domestic (Xinchuang) constraints: many retail mid/back-office systems still run on JDK 8 and domestic JDKs; a heavy, separately deployed rule or pricing platform cannot be justified for a single clearance-pricing scenario.
Solution architecture
Section titled “Solution architecture”This case uses the six-file structure of “domain model + rule interface & XML rule script + orchestration service + tests”: the stock-age base, four adjustment coefficients, clearance grading, and promotion routing are all externalized in XML, while the Java side handles discount aggregation, upper/lower clamping, margin-protection re-check, and note assembly.
| Layer | Responsibility | Files |
|---|---|---|
| Domain model | SKU inventory & cost snapshot, dynamic pricing decision result | InventoryItem.java, PricingDecision.java |
| Rule set & XML | Stock-age base + 4 adjustment factors + floor price / margin protection + clearance / promotion routing | DynamicPricingRules.java, dynamic-pricing.xml |
| Orchestration service | Discount aggregation clamped to 0.30–1.0, margin-protection re-check, margin-rate back-calculation, notes and precision handling | DynamicPricingService.java |
| Tests | 6 groups of inventory business shapes | DynamicPricingServiceTest.java |
Rule method breakdown
Section titled “Rule method breakdown”The rule interface has 10 methods: 1 grading, 1 base discount, 4 adjustment factors, 2 constraints, and 2 routing decisions:
| Rule method | Policy | Score / output |
|---|---|---|
ageLevel |
Stock age ≥180/≥90/≥60/≥30 days mapped to tiers 4/3/2/1, otherwise 0 | 0 ~ 4 |
baseDiscount |
Stock-age base discount: 0.5 / 0.65 / 0.8 / 0.9 / 1.0 | 0.5 ~ 1.0 |
slowMovingFactor |
Cross-compares last-30-day sales ×10 against stock (and ×3, ×6): -0.15 / -0.08 / 0 / +0.05; returns 0 when stock ≤0 | -0.15 ~ +0.05 |
seasonFactor |
Off season OFF -0.05, peak season PEAK +0.05, normal NORMAL 0 | -0.05 ~ +0.05 |
perishableFactor |
Perishable with stock age ≥7 days -0.10, other perishables -0.05, non-perishable 0 | -0.10 ~ 0 |
warehouseFactor |
Outsourced warehouse OUTSOURCE -0.03, self-operated 0 | -0.03 ~ 0 |
applyFloor |
When the price falls below cost × (1 + margin floor), the protected price applies | Protected price (yuan) |
clearanceLevel |
≤0.55 DEEP / ≤0.75 MEDIUM / below 1.0 LIGHT / otherwise NONE | DEEP / MEDIUM / LIGHT / NONE |
promotionType |
DEEP + perishable → flash sale, DEEP → bundle, MEDIUM → coupon, LIGHT → member price, otherwise NONE | FLASH_SALE / BUNDLE / COUPON / MEMBER_PRICE / NONE |
needClearance |
Stock age ≥90 days, or ≥60 days with last-30-day sales ×4 below stock | boolean |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”Place the six files into the corresponding src/test directories of a Maven project following the original package structure: model classes InventoryItem.java and PricingDecision.java under com.github.paohaijiao.demo.model; the rule interface DynamicPricingRules.java and test class DynamicPricingServiceTest.java under com.github.paohaijiao.demo; the orchestration service DynamicPricingService.java under com.github.paohaijiao.demo.service; and the rule script dynamic-pricing.xml under src/test/resources/demo/. Keep the package names exactly as in the source; the model classes depend on Lombok’s @Data annotation. Run mvn test -Dtest=DynamicPricingServiceTest at the project root to execute all 6 test cases.
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); }}Run & verified results
Section titled “Run & verified results”Run this case with the following command at the project root:
mvn test -Dtest=DynamicPricingServiceTestTest coverage
Section titled “Test coverage”The test class has 6 @Test methods covering six inventory shapes: deep clearance, fast-selling full price, near-expiry flash sale, margin protection, extreme floor-hit clearance, and boundary light clearance:
| Test method | Constructed business shape | Key assertions / expected decision |
|---|---|---|
shouldDeepClearSeverelyAgedStock |
Winter down jacket: stock age 200 days, stock 100, cost 60, current price 200, only 5 sold in 30 days, normal season, self-operated warehouse | Age level 4, base 0.5, final discount 0.35 (with velocity −0.15), price 70.0, DEEP + BUNDLE, clearance needed, margin rate 0.1429 |
shouldKeepFastSellingItemAtFullPrice |
In-season dress: stock age 10 days, 80 sold in 30 days, peak season, velocity rebate +0.05 | Age level 0, aggregated 1.1 clamped to 1.0, price 100.0, NONE + NONE, no clearance, margin rate 0.5, notes empty |
shouldFlashSalePerishableAgedItem |
Imported cold-chain steak: stock age 75 days, stock 200, off season, perishable, outsourced warehouse, velocity −0.08 | Final discount 0.54 (0.8−0.08−0.05−0.10−0.03), price 27.0, DEEP + FLASH_SALE, clearance needed, margin rate 0.2593 |
shouldProtectGrossMarginFloor |
Slow-moving small appliance: stock age 120 days, cost 60, current price 100, 10 sold monthly, 20% margin floor | Final discount 0.50, discounted price 50 yuan punches through the 72-yuan floor, sold at protected price 72.0, DEEP + BUNDLE, margin rate 0.1667, notes include “margin floor hit” |
shouldApplyFloorDiscountOnDeadStock |
Near-expiry gift box: stock age 200 days, zero sales, off season, perishable, outsourced warehouse, factors stack to 0.17 | Discount clamped to 0.30 by the 70%-off floor price, price 30.0, DEEP + FLASH_SALE, clearance needed, margin rate 0.1667 |
shouldApplyLightClearanceForMidAgedItem |
Regular sneakers: stock age 65 days, 45 sold monthly, velocity adjustment 0, normal season, self-operated | Final discount 0.8, price 64.0, LIGHT + MEMBER_PRICE, no clearance (sales ×4 not below stock), margin rate 0.2188 |
Verified output
Section titled “Verified output”Severely slow-moving item (stock age 200 days, stock 100, only 2 sold in 30 days, cost 100, original price 200):
最终折扣: 0.35 建议售价: 70.0 清仓等级: DEEP促销路由: BUNDLE 需清仓: trueFast-selling peak-season item (stock age 10 days, healthy velocity, positive season factor):
最终折扣: 1.0 清仓等级: NONE 需清仓: false(备注为空)Technical highlights
Section titled “Technical highlights”- Integer cross-multiplication avoids the integer-division trap: in the script,
int * 1.0 / intdoes not promote to floating-point division, so all velocity ratio comparisons are rewritten as integer cross-multiplication —slowMovingFactorusesrecentSalesQty30d * 10 <= stockQty(andstockQty * 3,stockQty * 6) for tiering, andneedClearanceusesrecentSalesQty30d * 4 < stockQty, completely eliminating truncation-to-zero issues. - Discount thresholds and adjustment coefficients all externalized in XML: the five-tier stock-age base, the -0.15/-0.08/+0.05 velocity tiers, ±0.05 seasonality, -0.10/-0.05 near-expiry, -0.03 outsourced warehouse, and the 0.55/0.75 clearance lines are all hot-updatable — seasonal promotion tuning needs no release.
- Dual Java-side price protection: the Service first clamps the aggregated discount to 0.30–1.0 (never below the 70%-off floor, never a markup), then calls
applyFloorfor the category margin-protection re-check, automatically lifting the price back when the discounted price falls below the cost-protection price (50 yuan raised back to 72 yuan). - Clearance grading decoupled from promotion routing in two stages: the discount first maps to DEEP/MEDIUM/LIGHT/NONE, then combines with the perishable flag to route to flash sale / bundle / coupon / member price — DEEP perishable items automatically enter flash sales, keeping promotion playbooks strictly aligned with discount depth.
- Decisions ship with a business-oriented view: the Service back-calculates the expected gross margin rate
(price − cost) / priceand assembles notes per hit condition (margin protection, clearance pool inclusion, near-expiry flash sale, outsourced-warehouse cost reduction, discount floor at 30%); fast sellers carry no notes — ready to drive clearance operations directly. @Paramname binding + proxy reuse + ASM invocation chain: rule methods only take scalars; the proxy is created once and reused across all 6 cases; amounts and discounts are rounded to 2 and 4 decimals respectively withBigDecimal; the engine targets Java 8 with no heavy dependencies and embeds into WMS/ERP batch jobs and domestic (Xinchuang) environments, supporting full-catalog daily repricing.