Просмотр исходного кода

feat(bundle): BundlePricingCalculator 贪心省最多套餐匹配算法 + 9组单测

- 按(productId,skuId)聚合;多套餐贪心取节省额最大;节省额>0才触发
- childSkuId空按商品聚合(跨SKU);不同基础款套餐互不干扰
- 凑整+单品兜底由调用方处理;纯计算无外部依赖
Xiaogang Liao 1 неделя назад
Родитель
Сommit
cf15bd8428

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleChild.java

@@ -0,0 +1,22 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/**
+ * 套餐基础款组成:child_product_id 必填;child_sku_id 可空(空=按商品聚合所有 SKU 行);quantity=组成数量。
+ */
+@Data
+public class BundleChild {
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+
+    public BundleChild() {
+    }
+
+    public BundleChild(Long childProductId, Long childSkuId, Integer quantity) {
+        this.childProductId = childProductId;
+        this.childSkuId = childSkuId;
+        this.quantity = quantity;
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDef.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 定价套餐定义(来自 product_bundle_items 中 bundle_sku_id 非空的行,按套餐锚点 SKU 聚合)。
+ * bundlePrice 取锚点 SKU 的 price;children 为该套餐的基础款组成。
+ */
+@Data
+public class BundleDef {
+    /** 套餐锚点 SKU id(product_skus.id) */
+    private Long bundleSkuId;
+    /** 套餐展示名(取锚点 SKU 的 label,缺省回落 specs) */
+    private String bundleName;
+    /** 套餐价(分) */
+    private Integer bundlePrice;
+    /** 基础款组成(每行: child_product_id + 可选 child_sku_id + quantity) */
+    private List<BundleChild> children;
+
+    public BundleDef() {
+    }
+
+    public BundleDef(Long bundleSkuId, String bundleName, Integer bundlePrice, List<BundleChild> children) {
+        this.bundleSkuId = bundleSkuId;
+        this.bundleName = bundleName;
+        this.bundlePrice = bundlePrice;
+        this.children = children;
+    }
+}

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDiscountResult.java

@@ -0,0 +1,32 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 订单套餐定价结果:active=至少命中一套(节省额>0)。
+ * originalTotal=命中套餐部分的基础款原价合计;payPrice=命中套餐价合计;discount=节省额合计。
+ * bundleList=命中套餐明细(含套数/原价/套餐价/节省)。
+ */
+@Data
+public class BundleDiscountResult {
+    private boolean active;
+    private Integer originalTotal;
+    private Integer payPrice;
+    private Integer discount;
+    private List<BundleHit> bundleList;
+
+    public BundleDiscountResult() {
+        this.active = false;
+        this.originalTotal = 0;
+        this.payPrice = 0;
+        this.discount = 0;
+        this.bundleList = new ArrayList<>();
+    }
+
+    public static BundleDiscountResult inactive() {
+        return new BundleDiscountResult();
+    }
+}

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleHit.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/**
+ * 命中的套餐(一次套餐 = 该套餐按组成完整消费 1 套)。
+ */
+@Data
+public class BundleHit {
+    private Long bundleSkuId;
+    private String bundleName;
+    /** 命中套数 */
+    private Integer times;
+    /** 命中部分的基础款原价合计(消费的行 unitPrice×qty) */
+    private Integer originalTotal;
+    /** 套餐总价(bundlePrice × times) */
+    private Integer bundlePrice;
+    /** 节省额(originalTotal − bundlePrice) */
+    private Integer saving;
+
+    public BundleHit() {
+    }
+
+    public BundleHit(Long bundleSkuId, String bundleName, Integer times,
+                     Integer originalTotal, Integer bundlePrice, Integer saving) {
+        this.bundleSkuId = bundleSkuId;
+        this.bundleName = bundleName;
+        this.times = times;
+        this.originalTotal = originalTotal;
+        this.bundlePrice = bundlePrice;
+        this.saving = saving;
+    }
+}

+ 216 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundlePricingCalculator.java

@@ -0,0 +1,216 @@
+package com.etotem.cfc.pricing;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 订单套餐自动定价核心算法(纯计算,无外部依赖,便于单测)。
+ *
+ * 规则:
+ * 1. 商品行按 (productId, skuId) 聚合为「池」;
+ * 2. 多套餐可凑时贪心取「节省额最大」者,每轮命中 1 套并扣减组成数量;
+ * 3. 节省额 = 该套消费的基础款单价合计 − 套餐价,仅 >0 才触发(避免会员价低于套餐价反而更贵);
+ * 4. 超出套餐组成数量的数量留给调用方按单品兜底,本算法不参与兜底计价。
+ */
+public class BundlePricingCalculator {
+
+    /** 池内一行:某 (productId, skuId) 剩余数量与下单口径单价 */
+    private static class PoolEntry {
+        final Long productId;
+        final Long skuId;
+        int quantity;
+        final int unitPrice;
+
+        PoolEntry(Long productId, Long skuId, int quantity, int unitPrice) {
+            this.productId = productId;
+            this.skuId = skuId;
+            this.quantity = quantity;
+            this.unitPrice = unitPrice;
+        }
+    }
+
+    /** 某套餐的消费方案:从池中扣哪些行、各扣多少 */
+    private static class ConsumePlan {
+        final BundleDef def;
+        final List<PoolEntry> entries = new ArrayList<>();
+        final List<Integer> quantities = new ArrayList<>();
+        int originalTotal; // 该套消费的基础款单价合计
+
+        ConsumePlan(BundleDef def) {
+            this.def = def;
+        }
+
+        int saving() {
+            return originalTotal - safePrice(def.getBundlePrice());
+        }
+
+        int consumedOf(PoolEntry entry) {
+            int total = 0;
+            for (int i = 0; i < entries.size(); i++) {
+                if (entries.get(i) == entry) {
+                    total += quantities.get(i);
+                }
+            }
+            return total;
+        }
+    }
+
+    /**
+     * 计算订单内套餐优惠。
+     *
+     * @param lines 下单/结算口径商品行(productId/skuId 为解析后身份,unitPrice 为结算单价,单位分)
+     * @param defs  定价套餐定义(product_bundle_items 中 bundle_sku_id 非空的行按锚点聚合)
+     */
+    public BundleDiscountResult compute(List<LineItem> lines, List<BundleDef> defs) {
+        BundleDiscountResult result = new BundleDiscountResult();
+        if (lines == null || lines.isEmpty() || defs == null || defs.isEmpty()) {
+            return result;
+        }
+        Map<String, PoolEntry> pool = aggregate(lines);
+
+        // 同一套餐锚点命中多套时合并为一条 BundleHit(times>1)
+        Map<Long, BundleHit> hitByBundle = new LinkedHashMap<>();
+        while (true) {
+            ConsumePlan best = bestBundle(pool, defs);
+            if (best == null) {
+                break;
+            }
+            for (int i = 0; i < best.entries.size(); i++) {
+                PoolEntry entry = best.entries.get(i);
+                entry.quantity -= best.quantities.get(i);
+                if (entry.quantity < 0) {
+                    entry.quantity = 0;
+                }
+            }
+            pool.values().removeIf(e -> e.quantity <= 0);
+
+            BundleHit hit = hitByBundle.get(best.def.getBundleSkuId());
+            if (hit == null) {
+                hit = new BundleHit();
+                hit.setBundleSkuId(best.def.getBundleSkuId());
+                hit.setBundleName(best.def.getBundleName());
+                hit.setTimes(0);
+                hit.setOriginalTotal(0);
+                hit.setBundlePrice(0);
+                hit.setSaving(0);
+                hitByBundle.put(best.def.getBundleSkuId(), hit);
+            }
+            hit.setTimes(hit.getTimes() + 1);
+            hit.setOriginalTotal(hit.getOriginalTotal() + best.originalTotal);
+            hit.setBundlePrice(hit.getBundlePrice() + safePrice(best.def.getBundlePrice()));
+            hit.setSaving(hit.getSaving() + best.saving());
+        }
+
+        if (!hitByBundle.isEmpty()) {
+            result.setActive(true);
+            result.setBundleList(new ArrayList<>(hitByBundle.values()));
+            for (BundleHit hit : result.getBundleList()) {
+                result.setOriginalTotal(result.getOriginalTotal() + hit.getOriginalTotal());
+                result.setPayPrice(result.getPayPrice() + hit.getBundlePrice());
+                result.setDiscount(result.getDiscount() + hit.getSaving());
+            }
+        }
+        return result;
+    }
+
+    /** 聚合:同 (productId, skuId) 归并数量;单价取该聚合内首个非零值 */
+    private Map<String, PoolEntry> aggregate(List<LineItem> lines) {
+        Map<String, PoolEntry> pool = new LinkedHashMap<>();
+        for (LineItem line : lines) {
+            if (line == null || line.getProductId() == null
+                    || line.getQuantity() == null || line.getQuantity() <= 0) {
+                continue;
+            }
+            String key = line.getProductId() + "|" + (line.getSkuId() == null ? "" : line.getSkuId());
+            int unitPrice = safePrice(line.getUnitPrice());
+            PoolEntry entry = pool.get(key);
+            if (entry == null) {
+                pool.put(key, new PoolEntry(line.getProductId(), line.getSkuId(), line.getQuantity(), unitPrice));
+            } else {
+                entry.quantity += line.getQuantity();
+            }
+        }
+        return pool;
+    }
+
+    /** 在池上贪心找当前可凑且节省额最大的套餐(每轮只命中 1 套) */
+    private ConsumePlan bestBundle(Map<String, PoolEntry> pool, List<BundleDef> defs) {
+        ConsumePlan best = null;
+        int bestSaving = 0;
+        for (BundleDef def : defs) {
+            if (def == null || def.getBundlePrice() == null || def.getBundlePrice() <= 0) {
+                continue;
+            }
+            if (def.getChildren() == null || def.getChildren().isEmpty()) {
+                continue; // 无组成定义,防御脏数据
+            }
+            ConsumePlan trial = trialConsume(pool, def);
+            if (trial == null || trial.saving() <= 0) {
+                continue;
+            }
+            // 严格大于:节省额相同时取先出现的套餐(确定性)
+            if (trial.saving() > bestSaving) {
+                bestSaving = trial.saving();
+                best = trial;
+            }
+        }
+        return best;
+    }
+
+    /**
+     * 试凑一套套餐:不修改池,返回消费方案;任一基础款不足则返回 null。
+     * 同一 PoolEntry 可能被不同 child 消费(跨 child 共行),用 drawn 防重复扣。
+     */
+    private ConsumePlan trialConsume(Map<String, PoolEntry> pool, BundleDef def) {
+        ConsumePlan plan = new ConsumePlan(def);
+        Map<PoolEntry, Integer> drawn = new HashMap<>();
+        for (BundleChild child : def.getChildren()) {
+            if (child == null || child.getChildProductId() == null) {
+                return null;
+            }
+            int needQty = child.getQuantity() == null || child.getQuantity() <= 0 ? 1 : child.getQuantity();
+            boolean skuSpecified = child.getChildSkuId() != null;
+            int remaining = needQty;
+            for (PoolEntry entry : pool.values()) {
+                if (remaining <= 0) {
+                    break;
+                }
+                if (!matches(entry, child, skuSpecified)) {
+                    continue;
+                }
+                int already = drawn.getOrDefault(entry, 0);
+                int available = entry.quantity - already;
+                if (available <= 0) {
+                    continue;
+                }
+                int take = Math.min(available, remaining);
+                plan.entries.add(entry);
+                plan.quantities.add(take);
+                drawn.put(entry, already + take);
+                plan.originalTotal += entry.unitPrice * take;
+                remaining -= take;
+            }
+            if (remaining > 0) {
+                return null; // 该基础款数量不足
+            }
+        }
+        return plan;
+    }
+
+    private boolean matches(PoolEntry entry, BundleChild child, boolean skuSpecified) {
+        if (!entry.productId.equals(child.getChildProductId())) {
+            return false;
+        }
+        if (skuSpecified) {
+            return entry.skuId != null && entry.skuId.equals(child.getChildSkuId());
+        }
+        return true; // 未指定 childSkuId:按商品聚合,同商品任意 SKU 行都算
+    }
+
+    private static int safePrice(Integer price) {
+        return price == null || price < 0 ? 0 : price;
+    }
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/LineItem.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/**
+ * 下单/结算口径的商品行:productId/skuId 为解析后身份(linked-SKU 转发一致),unitPrice 为结算单价(分)。
+ */
+@Data
+public class LineItem {
+    private Long productId;
+    private Long skuId;
+    private Integer quantity;
+    private Integer unitPrice;
+
+    public LineItem() {
+    }
+
+    public LineItem(Long productId, Long skuId, Integer quantity, Integer unitPrice) {
+        this.productId = productId;
+        this.skuId = skuId;
+        this.quantity = quantity;
+        this.unitPrice = unitPrice;
+    }
+}

+ 168 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingCalculatorTest.java

@@ -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());
+    }
+}