Przeglądaj źródła

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 1 tydzień temu
rodzic
commit
c7d48d2ff5

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -14880,7 +14880,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage());
         }
 
-        // 迁移427: product_bundle_items 增加套餐SKU锚点列(订单套餐自动定价)
+        // 迁移435: product_bundle_items 增加套餐SKU锚点列(订单套餐自动定价)
         // bundle_sku_id 非空 = 定价套餐行(套餐价取该SKU.price,CartService/ProductOrderService 匹配用);
         // 为空 = 仅出库联动行(InventoryService 语义,不参与定价)。
         ensureColumn("product_bundle_items", "bundle_sku_id",
@@ -14889,7 +14889,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         // (按 bundle_product_id 关联、仅 productType=bundle 商品)误触发出库联动。
         try {
             jdbcTemplate.execute("ALTER TABLE product_bundle_items MODIFY COLUMN bundle_product_id BIGINT NULL COMMENT '套餐商品ID(出库联动行=套餐商品ID;定价套餐行置NULL)'");
-            log.info("迁移427: product_bundle_items.bundle_product_id 已放开可空");
+            log.info("迁移435: product_bundle_items.bundle_product_id 已放开可空");
         } catch (Exception e) {
             log.warn("迁移427: 放开 bundle_product_id 可空失败(可能已放开): {}", e.getMessage());
         }

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java

@@ -17,6 +17,12 @@ public class ProductSkuDTO {
     private Integer stock;
     private String image;
     private Boolean enabled;
+    private Long linkedProductId;
+    private String linkedProductName;
+    private Integer linkedProductPrice;
+    private String linkedProductImage;
+    private Integer linkedProductStock;
+    private java.util.List<com.etotem.cfc.entity.ProductSkuComboItem> comboItems;
 
     public static ProductSkuDTO from(ProductSku sku) {
         if (sku == null) return null;
@@ -65,6 +71,12 @@ public class ProductSkuDTO {
                 dto.setSpecs(null);
             }
         }
+        dto.setLinkedProductId(sku.getLinkedProductId());
+        dto.setLinkedProductName(sku.getLinkedProductName());
+        dto.setLinkedProductPrice(sku.getLinkedProductPrice());
+        dto.setLinkedProductImage(sku.getLinkedProductImage());
+        dto.setLinkedProductStock(sku.getLinkedProductStock());
+        dto.setComboItems(sku.getComboItems());
         return dto;
     }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -19,6 +19,7 @@ public class ProductOrder implements Serializable {
     private Long productId;
     private String productName;
     private String productType;
+    private Long skuId;
     private Long buyerId;
     private Long familyId;
     private Integer quantity;

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java

@@ -30,4 +30,16 @@ public class ProductSku implements Serializable {
     private String label;
     private Date createTime;
     private Date updateTime;
+
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private java.util.List<ProductSkuComboItem> comboItems;
+
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductName;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductPrice;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private String linkedProductImage;
+    @com.baomidou.mybatisplus.annotation.TableField(exist = false)
+    private Integer linkedProductStock;
 }

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("product_sku_combo_items")
+public class ProductSkuComboItem implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long skuId;
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+    private Date createdAt;
+
+    @TableField(exist = false)
+    private String childProductName;
+    @TableField(exist = false)
+    private String childProductImage;
+    @TableField(exist = false)
+    private Integer childProductPrice;
+    @TableField(exist = false)
+    private String childSkuSpecs;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+
+public interface ProductSkuComboItemMapper extends BaseMapper<ProductSkuComboItem> {
+}

+ 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;
+    }
+}

+ 85 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/BundlePricingService.java

@@ -0,0 +1,85 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.ProductBundleItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductBundleItemMapper;
+import com.etotem.cfc.pricing.BundleChild;
+import com.etotem.cfc.pricing.BundleDef;
+import com.etotem.cfc.pricing.BundleDiscountResult;
+import com.etotem.cfc.pricing.BundlePricingCalculator;
+import com.etotem.cfc.pricing.LineItem;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 订单套餐自动定价:装载 product_bundle_items 中 bundle_sku_id 非空的定价套餐定义,
+ * 组装为 {@link BundleDef} 后委托 {@link BundlePricingCalculator} 计算。
+ */
+@Service
+public class BundlePricingService {
+
+    private final ProductBundleItemMapper productBundleItemMapper;
+    private final ProductSkuService productSkuService;
+    private final BundlePricingCalculator calculator = new BundlePricingCalculator();
+
+    public BundlePricingService(ProductBundleItemMapper productBundleItemMapper,
+                                ProductSkuService productSkuService) {
+        this.productBundleItemMapper = productBundleItemMapper;
+        this.productSkuService = productSkuService;
+    }
+
+    /**
+     * 计算订单内套餐优惠。
+     *
+     * @param lines 下单/结算口径商品行(productId/skuId 为解析后身份,unitPrice 为结算单价)
+     */
+    public BundleDiscountResult compute(List<LineItem> lines) {
+        List<ProductBundleItem> rows = productBundleItemMapper.selectList(
+                new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"));
+        if (rows == null || rows.isEmpty()) {
+            return BundleDiscountResult.inactive();
+        }
+        // 按套餐锚点 SKU 分组组装定义
+        Map<Long, BundleDef> defMap = new LinkedHashMap<>();
+        for (ProductBundleItem row : rows) {
+            if (row == null || row.getBundleSkuId() == null || row.getChildProductId() == null) {
+                continue; // 脏数据防御
+            }
+            BundleDef def = defMap.get(row.getBundleSkuId());
+            if (def == null) {
+                ProductSku sku = productSkuService.getById(row.getBundleSkuId());
+                if (sku == null || sku.getPrice() == null || sku.getPrice() <= 0) {
+                    continue; // 锚点 SKU 不存在或无价,跳过该套餐(防御)
+                }
+                def = new BundleDef();
+                def.setBundleSkuId(row.getBundleSkuId());
+                def.setBundleName(chooseName(sku));
+                def.setBundlePrice(sku.getPrice());
+                def.setChildren(new ArrayList<>());
+                defMap.put(row.getBundleSkuId(), def);
+            }
+            BundleChild child = new BundleChild();
+            child.setChildProductId(row.getChildProductId());
+            child.setChildSkuId(row.getChildSkuId());
+            child.setQuantity(row.getQuantity() == null || row.getQuantity() <= 0 ? 1 : row.getQuantity());
+            def.getChildren().add(child);
+        }
+        if (defMap.isEmpty()) {
+            return BundleDiscountResult.inactive();
+        }
+        return calculator.compute(lines, new ArrayList<>(defMap.values()));
+    }
+
+    /** 套餐展示名:锚点 SKU 的 label 优先,缺省回落 specs */
+    private String chooseName(ProductSku sku) {
+        if (sku.getLabel() != null && !sku.getLabel().trim().isEmpty()) {
+            return sku.getLabel();
+        }
+        return sku.getSpecs();
+    }
+}

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

+ 126 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingServiceTest.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.entity.ProductBundleItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductBundleItemMapper;
+import com.etotem.cfc.pricing.BundleDiscountResult;
+import com.etotem.cfc.pricing.LineItem;
+import com.etotem.cfc.service.BundlePricingService;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+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;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * BundlePricingService 映射单测:装载 bundle_sku_id 非空行、组装 BundleDef、委托计算。
+ */
+@ExtendWith(MockitoExtension.class)
+class BundlePricingServiceTest {
+
+    @Mock
+    private ProductBundleItemMapper productBundleItemMapper;
+    @Mock
+    private ProductSkuService productSkuService;
+    @InjectMocks
+    private BundlePricingService service;
+
+    private ProductBundleItem row(Long bundleSkuId, Long childProductId, Long childSkuId, int qty) {
+        ProductBundleItem r = new ProductBundleItem();
+        r.setBundleSkuId(bundleSkuId);
+        r.setChildProductId(childProductId);
+        r.setChildSkuId(childSkuId);
+        r.setQuantity(qty);
+        return r;
+    }
+
+    private ProductSku sku(Long id, String label, int price) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setLabel(label);
+        s.setPrice(price);
+        return s;
+    }
+
+    @Test
+    void noPricingRows_inactive() {
+        when(productBundleItemMapper.selectList(any())).thenReturn(Collections.emptyList());
+        BundleDiscountResult r = service.compute(Collections.singletonList(
+                new LineItem(10L, null, 2, 69900)));
+        assertFalse(r.isActive());
+    }
+
+    @Test
+    void assembleDefsAndDelegate_hit() {
+        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
+                Collections.singletonList(row(3L, 10L, null, 2)));
+        when(productSkuService.getById(3L)).thenReturn(sku(3L, "套餐一", 128000));
+
+        BundleDiscountResult r = service.compute(Collections.singletonList(
+                new LineItem(10L, null, 2, 69900)));
+
+        assertTrue(r.isActive());
+        assertEquals(11800, r.getDiscount());
+        assertEquals("套餐一", r.getBundleList().get(0).getBundleName());
+        assertEquals(1, r.getBundleList().get(0).getTimes());
+    }
+
+    @Test
+    void anchorSkuMissing_skipDef() {
+        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
+                Collections.singletonList(row(3L, 10L, null, 2)));
+        when(productSkuService.getById(3L)).thenReturn(null);
+
+        BundleDiscountResult r = service.compute(Collections.singletonList(
+                new LineItem(10L, null, 2, 69900)));
+
+        assertFalse(r.isActive());
+    }
+
+    @Test
+    void multipleChildrenSameDef_merged() {
+        // 同一套餐锚点两行子商品:childA(10,qty2) + childB(20,qty1)
+        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(
+                row(3L, 10L, null, 2),
+                row(3L, 20L, null, 1)));
+        when(productSkuService.getById(3L)).thenReturn(sku(3L, "套餐一", 128000));
+
+        BundleDiscountResult r = service.compute(Arrays.asList(
+                new LineItem(10L, null, 2, 69900),
+                new LineItem(20L, null, 1, 50000)));
+
+        assertTrue(r.isActive());
+        assertEquals(1, r.getBundleList().size());
+        // 原价 = 2×69900 + 1×50000 = 189800;省 = 189800 - 128000 = 61800
+        assertEquals(61800, r.getDiscount());
+    }
+
+    @Test
+    void labelFallbackToSpecs() {
+        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
+                Collections.singletonList(row(3L, 10L, null, 2)));
+        ProductSku s = new ProductSku();
+        s.setId(3L);
+        s.setLabel(null);
+        s.setSpecs("成长烦恼、学业提升");
+        s.setPrice(128000);
+        when(productSkuService.getById(3L)).thenReturn(s);
+
+        BundleDiscountResult r = service.compute(Collections.singletonList(
+                new LineItem(10L, null, 2, 69900)));
+
+        assertEquals("成长烦恼、学业提升", r.getBundleList().get(0).getBundleName());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-39f02428049259aab7854e0ee69d024886d557e9
+ff182147995e891b6ffbe9668695d930a4eb452d

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1879",
+  "version": "1.0.1880",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1879",
+      "version": "1.0.1880",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1880",
+  "version": "1.0.1881",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 23 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,29 @@
 
 ---
 
+## v1.0.1881 (2026-10-31)
+
+### 文档
+- 迁移编号 433→434(远程主线家庭指标聚合表已用433)
+- 组合迁移编号 432→433(432已被report_type_registry占用)
+
+### 新功能
+- 迁移434 SKU商品组合明细表与订单sku_id
+- product_bundle_items 新增 bundle_sku_id 列(定价套餐锚点,迁移427幂等)
+- 补齐DanAssessmentResultDTO并新增DAN报告显示页面开发计划
+
+### 其他
+- - 放开 bundle_product_id 可空:定价行置NULL避免误触发出库联动
+- - 同步 schema.sql 与 ProductBundleItem entity
+- 
+-   EMI心理四维、大五OCEAN五维+综合、EMI情绪商数四维,及报告外壳字段
+-   dimension/danLevel/structuredAnalysis,成为完整结果视图
+- - service/TeacherService: toAssessmentResultDTO 同步补全上述字段赋值
+- - docs/design: 新增 DAN报告页面开发计划.md(8类报告开发里程碑/任务清单/技术约束/风险),
+-   连同既有设计规范与交互原型一并纳入版本控制
+- 
+
+
 ## v1.0.1880 (2026-10-31)
 
 ### Bug 修复

+ 24 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1880
+> 当前版本: v1.0.1881
 
 ## 历史版本
 
@@ -8,6 +8,29 @@
 
 ---
 
+## v1.0.1881 (2026-10-31)
+
+### 文档
+- 迁移编号 433→434(远程主线家庭指标聚合表已用433)
+- 组合迁移编号 432→433(432已被report_type_registry占用)
+
+### 新功能
+- 迁移434 SKU商品组合明细表与订单sku_id
+- product_bundle_items 新增 bundle_sku_id 列(定价套餐锚点,迁移427幂等)
+- 补齐DanAssessmentResultDTO并新增DAN报告显示页面开发计划
+
+### 其他
+- - 放开 bundle_product_id 可空:定价行置NULL避免误触发出库联动
+- - 同步 schema.sql 与 ProductBundleItem entity
+- 
+-   EMI心理四维、大五OCEAN五维+综合、EMI情绪商数四维,及报告外壳字段
+-   dimension/danLevel/structuredAnalysis,成为完整结果视图
+- - service/TeacherService: toAssessmentResultDTO 同步补全上述字段赋值
+- - docs/design: 新增 DAN报告页面开发计划.md(8类报告开发里程碑/任务清单/技术约束/风险),
+-   连同既有设计规范与交互原型一并纳入版本控制
+- 
+
+
 ## v1.0.1880 (2026-10-31)
 
 ### Bug 修复