|
|
@@ -0,0 +1,168 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.pricing.BundleChild;
|
|
|
+import com.etotem.cfc.pricing.BundleDef;
|
|
|
+import com.etotem.cfc.pricing.BundleDiscountResult;
|
|
|
+import com.etotem.cfc.pricing.BundleHit;
|
|
|
+import com.etotem.cfc.pricing.BundlePricingCalculator;
|
|
|
+import com.etotem.cfc.pricing.LineItem;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 订单套餐自动定价核心算法单测(TDD)。
|
|
|
+ * 规则:凑整套餐+单品兜底;多套餐贪心选节省额最大;节省额<=0不触发;
|
|
|
+ * child_sku_id 为空按商品聚合(跨 SKU);不同基础款套餐互不干扰。
|
|
|
+ */
|
|
|
+class BundlePricingCalculatorTest {
|
|
|
+
|
|
|
+ private final BundlePricingCalculator calc = new BundlePricingCalculator();
|
|
|
+
|
|
|
+ private LineItem line(Long productId, Long skuId, int qty, int unitPrice) {
|
|
|
+ return new LineItem(productId, skuId, qty, unitPrice);
|
|
|
+ }
|
|
|
+
|
|
|
+ private BundleChild child(Long productId, Long childSkuId, int qty) {
|
|
|
+ return new BundleChild(productId, childSkuId, qty);
|
|
|
+ }
|
|
|
+
|
|
|
+ private BundleDef def(Long bundleSkuId, String name, int bundlePrice, BundleChild... children) {
|
|
|
+ return new BundleDef(bundleSkuId, name, bundlePrice, Arrays.asList(children));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void singleBundleHit_twoBasicItems() {
|
|
|
+ // 2×69900 配套餐(2个基础款=128000) → 命中,省 11800
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
|
|
|
+ List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertTrue(r.isActive());
|
|
|
+ assertEquals(139800, r.getOriginalTotal());
|
|
|
+ assertEquals(128000, r.getPayPrice());
|
|
|
+ assertEquals(11800, r.getDiscount());
|
|
|
+ assertEquals(1, r.getBundleList().size());
|
|
|
+ BundleHit hit = r.getBundleList().get(0);
|
|
|
+ assertEquals(3L, hit.getBundleSkuId());
|
|
|
+ assertEquals(1, hit.getTimes());
|
|
|
+ assertEquals(11800, hit.getSaving());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void wholeBundlePlusSingleFallback_threeItems() {
|
|
|
+ // 3×69900 → 1套套餐(128000) + 1个单品兜底(69900),只算命中部分
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 3, 69900));
|
|
|
+ List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertTrue(r.isActive());
|
|
|
+ assertEquals(1, r.getBundleList().get(0).getTimes());
|
|
|
+ assertEquals(139800, r.getOriginalTotal());
|
|
|
+ assertEquals(128000, r.getPayPrice());
|
|
|
+ assertEquals(11800, r.getDiscount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void multiBundle_pickMaxSaving() {
|
|
|
+ // 2×69900,两个套餐都凑得齐:defA省11800、defB省29800 → 选defB
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
|
|
|
+ List<BundleDef> defs = Arrays.asList(
|
|
|
+ def(3L, "套餐一", 128000, child(10L, null, 2)),
|
|
|
+ def(9L, "套餐二", 110000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertTrue(r.isActive());
|
|
|
+ assertEquals(1, r.getBundleList().size());
|
|
|
+ assertEquals(9L, r.getBundleList().get(0).getBundleSkuId());
|
|
|
+ assertEquals(29800, r.getDiscount());
|
|
|
+ assertEquals(110000, r.getPayPrice());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void savingNotPositive_notTriggered_memberPriceBelowBundle() {
|
|
|
+ // 会员价 55900×2=111800 < 128000 → 按套餐反而贵,不触发
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 55900));
|
|
|
+ List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertFalse(r.isActive());
|
|
|
+ assertEquals(0, r.getDiscount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void childSkuNull_aggregateAcrossSkus() {
|
|
|
+ // 两行不同 sku(69900 + 59900)配 product10 qty2(child_sku_id 空→按商品聚合)
|
|
|
+ List<LineItem> lines = Arrays.asList(
|
|
|
+ line(10L, 2L, 1, 69900),
|
|
|
+ line(10L, 4L, 1, 59900));
|
|
|
+ List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 120000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertTrue(r.isActive());
|
|
|
+ assertEquals(129800, r.getOriginalTotal());
|
|
|
+ assertEquals(9800, r.getDiscount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void emptyLinesOrDefs_inactive() {
|
|
|
+ assertFalse(calc.compute(Collections.emptyList(), Collections.singletonList(
|
|
|
+ def(3L, "套餐一", 128000, child(10L, null, 2)))).isActive());
|
|
|
+ assertFalse(calc.compute(Collections.singletonList(line(10L, null, 2, 69900)), Collections.emptyList()).isActive());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void bundlePriceAboveOriginal_notTriggered() {
|
|
|
+ // 套餐价 150000 > 原价 139800 → 不触发
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
|
|
|
+ List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 150000, child(10L, null, 2)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertFalse(r.isActive());
|
|
|
+ assertEquals(0, r.getDiscount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void twoIndependentBundles_bothHit() {
|
|
|
+ // 不同基础款的两个套餐同单各自命中:product10×2(69900)→套餐一128000省11800;
|
|
|
+ // product20×1(99900)→套餐二89900省10000
|
|
|
+ List<LineItem> lines = Arrays.asList(
|
|
|
+ line(10L, null, 2, 69900),
|
|
|
+ line(20L, null, 1, 99900));
|
|
|
+ List<BundleDef> defs = Arrays.asList(
|
|
|
+ def(3L, "套餐一", 128000, child(10L, null, 2)),
|
|
|
+ def(9L, "套餐二", 89900, child(20L, null, 1)));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertTrue(r.isActive());
|
|
|
+ assertEquals(2, r.getBundleList().size());
|
|
|
+ assertEquals(239700, r.getOriginalTotal());
|
|
|
+ assertEquals(217900, r.getPayPrice());
|
|
|
+ assertEquals(21800, r.getDiscount());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void defWithoutChildren_skipped() {
|
|
|
+ List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
|
|
|
+ List<BundleDef> defs = new ArrayList<>();
|
|
|
+ defs.add(new BundleDef(3L, "无子商品", 128000, Collections.emptyList()));
|
|
|
+
|
|
|
+ BundleDiscountResult r = calc.compute(lines, defs);
|
|
|
+
|
|
|
+ assertFalse(r.isActive());
|
|
|
+ }
|
|
|
+}
|