Browse Source

feat(backend): 购物车套餐offer检测(勾选子集/节省额>0)与一键替换接口

Xiaogang Liao 1 week ago
parent
commit
e28ccbe727

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller.cart;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.dto.CartListResultDTO;
+import com.etotem.cfc.pricing.ComboOfferResult;
 import com.etotem.cfc.service.CartService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
@@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
@@ -66,4 +68,32 @@ public class CartController {
         int count = cartService.getCartCount(userId);
         return Result.success(count);
     }
+
+    @PostMapping("/combo-offer")
+    public Result<ComboOfferResult> comboOffer(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long userId) {
+        List<Long> checkedCartItemIds = parseIdList(params.get("checkedCartItemIds"));
+        ComboOfferResult result = cartService.computeComboOffer(userId, checkedCartItemIds);
+        return Result.success(result);
+    }
+
+    @PostMapping("/replace-combo")
+    public Result<Void> replaceCombo(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("userId") Long userId) {
+        List<Long> checkedCartItemIds = parseIdList(params.get("checkedCartItemIds"));
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        return cartService.replaceCartWithCombo(userId, checkedCartItemIds, comboSkuId);
+    }
+
+    private List<Long> parseIdList(Object obj) {
+        List<Long> ids = new ArrayList<>();
+        if (obj instanceof List) {
+            for (Object o : (List<?>) obj) {
+                if (o == null) continue;
+                if (o instanceof Number) ids.add(((Number) o).longValue());
+                else ids.add(Long.valueOf(o.toString()));
+            }
+        }
+        return ids;
+    }
 }

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboChild.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/** 套餐内容子项:子商品 + 精确到子SKU + 数量 */
+@Data
+public class ComboChild {
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferCalculator.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.pricing;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 购物车套餐 offer 纯计算器(无 Spring 依赖,单测友好)。
+ *
+ * <p>算法:按 {@code (productId, skuId)} 聚合 {@link LineItem} 为池;对每个套餐定义计算
+ * 可覆盖组数 {@code groups = min over children(floor(haveQty/needQty))}(haveQty 严格按
+ * child_sku_id 匹配,缺失即 0);{@code originalTotal = Σ(组内 child 消耗数量×单价)×groups};
+ * {@code saving = originalTotal - comboPrice×groups};仅当 {@code saving>0 && groups>0} 才候选;
+ * 取 saving 最大(同额取先出现者),同一套餐重复只报一次;无命中返回 inactive。
+ */
+public class ComboOfferCalculator {
+
+    public ComboOfferResult compute(List<LineItem> lines, List<ComboOfferDef> defs) {
+        if (lines == null || lines.isEmpty() || defs == null || defs.isEmpty()) {
+            return ComboOfferResult.inactive();
+        }
+        // 池:key=(productId, skuId) -> {qty, unitPrice}
+        Map<String, PoolEntry> pool = new HashMap<>();
+        for (LineItem line : lines) {
+            if (line == null || line.getProductId() == null || line.getQuantity() == null) continue;
+            String key = key(line.getProductId(), line.getSkuId());
+            PoolEntry e = pool.get(key);
+            if (e == null) {
+                e = new PoolEntry();
+                e.qty = line.getQuantity();
+                e.unitPrice = line.getUnitPrice() != null ? line.getUnitPrice() : 0;
+                pool.put(key, e);
+            } else {
+                e.qty += line.getQuantity();
+            }
+        }
+
+        ComboOfferResult best = null;
+        Set<Long> seen = new HashSet<>();
+        for (ComboOfferDef def : defs) {
+            if (def == null || def.getComboSkuId() == null
+                    || def.getChildren() == null || def.getChildren().isEmpty()) continue;
+            if (!seen.add(def.getComboSkuId())) continue;   // 同一套餐重复只报一次
+            int groups = Integer.MAX_VALUE;
+            int perGroupOriginal = 0;
+            boolean valid = true;
+            for (ComboChild child : def.getChildren()) {
+                if (child == null || child.getChildProductId() == null) { valid = false; break; }
+                PoolEntry e = pool.get(key(child.getChildProductId(), child.getChildSkuId()));
+                int need = child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1;
+                int have = e != null ? e.qty : 0;
+                groups = Math.min(groups, have / need);
+                perGroupOriginal += need * (e != null ? e.unitPrice : 0);
+            }
+            if (!valid || groups == Integer.MAX_VALUE || groups <= 0) continue;
+            int comboPrice = def.getComboPrice() != null ? def.getComboPrice() : 0;
+            int originalTotal = perGroupOriginal * groups;
+            int saving = originalTotal - comboPrice * groups;
+            if (saving <= 0) continue;   // 套餐价>=总和:不提示(原价结算)
+            ComboOfferResult cand = new ComboOfferResult();
+            cand.setActive(true);
+            cand.setComboSkuId(def.getComboSkuId());
+            cand.setComboName(def.getComboName());
+            cand.setGroups(groups);
+            cand.setOriginalTotal(originalTotal);
+            cand.setComboPrice(comboPrice);
+            cand.setSaving(saving);
+            if (best == null || cand.getSaving() > best.getSaving()) {
+                best = cand;   // 同额不替换 → 保留先出现者
+            }
+        }
+        return best != null ? best : ComboOfferResult.inactive();
+    }
+
+    private String key(Long productId, Long skuId) {
+        return productId + "_" + (skuId == null ? "" : skuId);
+    }
+
+    private static class PoolEntry {
+        int qty;
+        int unitPrice;
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferDef.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 购物车套餐 offer 定义:套餐SKU锚点 + 展示名 + 套餐价(分) + 内容清单 */
+@Data
+public class ComboOfferDef {
+    private Long comboSkuId;
+    private String comboName;
+    private Integer comboPrice;
+    private List<ComboChild> children;
+}

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

@@ -0,0 +1,24 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/**
+ * 购物车套餐 offer 检测结果(只报最优一个):命中(节省额>0)时 active=true,
+ * 携带套餐SKU/展示名/可替换组数/原价合计/套餐价合计/节省额。
+ */
+@Data
+public class ComboOfferResult {
+    private boolean active;
+    private Long comboSkuId;
+    private String comboName;
+    private int groups;
+    private int originalTotal;
+    private int comboPrice;
+    private int saving;
+
+    public static ComboOfferResult inactive() {
+        ComboOfferResult r = new ComboOfferResult();
+        r.setActive(false);
+        return r;
+    }
+}

+ 143 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -1,6 +1,11 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.dto.CartListResultDTO;
 import com.etotem.cfc.entity.Cart;
@@ -20,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -48,6 +54,9 @@ public class CartService {
     @Resource
     private MemberPriceResolver memberPriceResolver;
 
+    @Resource
+    private ComboOfferService comboOfferService;
+
     @Transactional
     public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
         Product product = productMapper.selectById(productId);
@@ -279,4 +288,138 @@ public class CartService {
         promo.setRemaining(0);
         return promo;
     }
+
+    // ==================== 套餐 offer:勾选子集检测 + 一键替换 ====================
+
+    /**
+     * 购物车套餐 offer 检测:仅按勾选结算子集(checkedCartItemIds=购物车主键)计算,
+     * 节省额>0 才 active。列表接口不再烘焙 offer(勾选态是前端本地态,见 cart.vue item.checked)。
+     */
+    public ComboOfferResult computeComboOffer(Long userId, List<Long> checkedCartItemIds) {
+        List<Cart> carts = loadCheckedCarts(userId, checkedCartItemIds);
+        if (carts.isEmpty()) return ComboOfferResult.inactive();
+        List<LineItem> lines = buildOfferLines(carts, userId);
+        return comboOfferService.computeForLines(lines);
+    }
+
+    /**
+     * 一键替换为套餐:后端按勾选行 (productId, skuId) 重算组数(忽略前端传入),
+     * 逐行扣减子商品数量(不足整体失败回滚),删除数量为0的行,upsert 套餐 SKU 行(存在则合并数量)。
+     */
+    @Transactional
+    public Result<Void> replaceCartWithCombo(Long userId, List<Long> checkedCartItemIds, Long comboSkuId) {
+        if (checkedCartItemIds == null || checkedCartItemIds.isEmpty()) {
+            return Result.error("请选择要替换为套餐的购物车商品");
+        }
+        if (comboSkuId == null) {
+            return Result.error("套餐参数缺失");
+        }
+        List<Cart> carts = loadCheckedCarts(userId, checkedCartItemIds);
+        if (carts.isEmpty()) {
+            return Result.error("购物车商品不存在");
+        }
+        ComboOfferDef def = comboOfferService.loadDef(comboSkuId);
+        if (def == null || def.getChildren() == null || def.getChildren().isEmpty()) {
+            return Result.error("套餐不存在或未配置");
+        }
+        // 按 (productId, skuId) 聚合勾选行数量
+        Map<String, Integer> pool = new HashMap<>();
+        for (Cart c : carts) {
+            String k = offerKey(c.getProductId(), c.getSkuId());
+            pool.put(k, pool.getOrDefault(k, 0) + c.getQuantity());
+        }
+        // 后端重算组数(忽略前端传入组数)
+        int groups = Integer.MAX_VALUE;
+        for (ComboChild child : def.getChildren()) {
+            int need = child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1;
+            int have = pool.getOrDefault(offerKey(child.getChildProductId(), child.getChildSkuId()), 0);
+            groups = Math.min(groups, have / need);
+        }
+        if (groups == Integer.MAX_VALUE || groups <= 0) {
+            return Result.error("购物车商品数量不足,无法替换为套餐");
+        }
+        // 逐行扣减子商品数量(仅勾选行;不足则抛异常回滚)
+        for (ComboChild child : def.getChildren()) {
+            int needTotal = (child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1) * groups;
+            int remaining = needTotal;
+            for (Cart c : carts) {
+                if (c.getSkuId() != null && c.getSkuId().equals(child.getChildSkuId())
+                        && c.getProductId().equals(child.getChildProductId())) {
+                    int deduct = Math.min(c.getQuantity(), remaining);
+                    c.setQuantity(c.getQuantity() - deduct);
+                    remaining -= deduct;
+                    if (c.getQuantity() <= 0) {
+                        cartMapper.deleteById(c.getId());
+                    } else {
+                        c.setUpdatedAt(LocalDateTime.now());
+                        cartMapper.updateById(c);
+                    }
+                    if (remaining == 0) break;
+                }
+            }
+            if (remaining > 0) {
+                throw new RuntimeException("购物车商品数量不足,替换套餐失败");
+            }
+        }
+        // upsert 套餐 SKU 行(与现有 add 行为一致:同商品+SKU 合并数量)
+        ProductSku comboSku = productSkuService.getById(comboSkuId);
+        if (comboSku == null || comboSku.getProductId() == null) {
+            return Result.error("套餐SKU不存在");
+        }
+        Cart existing = cartMapper.selectOne(new LambdaQueryWrapper<Cart>()
+                .eq(Cart::getUserId, userId)
+                .eq(Cart::getProductId, comboSku.getProductId())
+                .eq(Cart::getSkuId, comboSkuId));
+        if (existing != null) {
+            existing.setQuantity(existing.getQuantity() + groups);
+            existing.setUpdatedAt(LocalDateTime.now());
+            cartMapper.updateById(existing);
+        } else {
+            Cart nc = new Cart();
+            nc.setUserId(userId);
+            nc.setProductId(comboSku.getProductId());
+            nc.setSkuId(comboSkuId);
+            nc.setQuantity(groups);
+            nc.setCreatedAt(LocalDateTime.now());
+            nc.setUpdatedAt(LocalDateTime.now());
+            cartMapper.insert(nc);
+        }
+        return Result.success();
+    }
+
+    private List<Cart> loadCheckedCarts(Long userId, List<Long> ids) {
+        if (ids == null || ids.isEmpty()) return Collections.emptyList();
+        return cartMapper.selectList(new LambdaQueryWrapper<Cart>()
+                .in(Cart::getId, ids)
+                .eq(Cart::getUserId, userId));
+    }
+
+    private List<LineItem> buildOfferLines(List<Cart> carts, Long userId) {
+        List<LineItem> lines = new ArrayList<>();
+        for (Cart cart : carts) {
+            if (cart == null || cart.getProductId() == null || cart.getQuantity() == null || cart.getQuantity() <= 0) continue;
+            Product product = productMapper.selectById(cart.getProductId());
+            if (product == null) continue;
+            Long resolvedProductId = product.getId();
+            int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, userId);
+            ProductSku sku = null;
+            if (cart.getSkuId() != null) sku = productSkuService.getById(cart.getSkuId());
+            if (sku != null && sku.getLinkedProductId() != null) {
+                Product linked = productMapper.selectById(sku.getLinkedProductId());
+                if (linked != null) {
+                    resolvedProductId = linked.getId();
+                    unitPrice = linked.getPrice() != null ? linked.getPrice() : 0;
+                }
+            } else if (sku != null && sku.getPrice() != null) {
+                unitPrice = sku.getPrice();
+            }
+            lines.add(new LineItem(resolvedProductId, cart.getSkuId(), cart.getQuantity(), unitPrice));
+        }
+        return lines;
+    }
+
+    private String offerKey(Long productId, Long skuId) {
+        return productId + "_" + (skuId == null ? "" : skuId);
+    }
 }
+

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ComboOfferService.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductComboItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferCalculator;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 购物车套餐 offer 装载与计算:从 {@code product_combo_items} 按 combo_sku_id 聚合组装
+ * {@link ComboOfferDef}(套餐价取套餐SKU.price,展示名取套餐SKU.label),脏行跳过,
+ * 委托 {@link ComboOfferCalculator} 计算最优 offer。
+ */
+@Slf4j
+@Service
+public class ComboOfferService {
+
+    @Resource
+    private ProductComboItemMapper comboItemMapper;
+
+    @Resource
+    private ProductSkuMapper skuMapper;
+
+    /** 购物车行 → 最优套餐 offer */
+    public ComboOfferResult computeForLines(List<LineItem> lines) {
+        if (lines == null || lines.isEmpty()) return ComboOfferResult.inactive();
+        List<ComboOfferDef> defs = loadAllDefs();
+        return new ComboOfferCalculator().compute(lines, defs);
+    }
+
+    /** 加载单个套餐定义(replace-combo 用);不存在或内容缺失返回 null */
+    public ComboOfferDef loadDef(Long comboSkuId) {
+        if (comboSkuId == null) return null;
+        List<ProductComboItem> items = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductComboItem>()
+                        .eq(ProductComboItem::getComboSkuId, comboSkuId));
+        return buildDef(comboSkuId, items);
+    }
+
+    private List<ComboOfferDef> loadAllDefs() {
+        List<ProductComboItem> items = comboItemMapper.selectList(null);
+        if (items == null || items.isEmpty()) return Collections.emptyList();
+        Map<Long, List<ProductComboItem>> bySku = new LinkedHashMap<>();
+        for (ProductComboItem it : items) {
+            if (it == null || it.getComboSkuId() == null) continue;
+            bySku.computeIfAbsent(it.getComboSkuId(), k -> new ArrayList<>()).add(it);
+        }
+        List<ComboOfferDef> defs = new ArrayList<>();
+        for (Map.Entry<Long, List<ProductComboItem>> e : bySku.entrySet()) {
+            ComboOfferDef def = buildDef(e.getKey(), e.getValue());
+            if (def != null) defs.add(def);
+        }
+        return defs;
+    }
+
+    private ComboOfferDef buildDef(Long comboSkuId, List<ProductComboItem> items) {
+        if (items == null || items.isEmpty()) return null;
+        ProductSku sku = skuMapper.selectById(comboSkuId);
+        if (sku == null) {
+            log.warn("套餐SKU不存在,跳过脏套餐定义: comboSkuId={}", comboSkuId);
+            return null;
+        }
+        List<ComboChild> children = new ArrayList<>();
+        for (ProductComboItem it : items) {
+            if (it == null || it.getChildProductId() == null || it.getChildSkuId() == null) {
+                log.warn("套餐内容脏行(缺子商品/子SKU),跳过: comboSkuId={}, itemId={}", comboSkuId, it == null ? null : it.getId());
+                continue;
+            }
+            ComboChild c = new ComboChild();
+            c.setChildProductId(it.getChildProductId());
+            c.setChildSkuId(it.getChildSkuId());
+            c.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1);
+            children.add(c);
+        }
+        if (children.isEmpty()) return null;
+        ComboOfferDef def = new ComboOfferDef();
+        def.setComboSkuId(comboSkuId);
+        def.setComboName(sku.getLabel() != null && !sku.getLabel().trim().isEmpty()
+                ? sku.getLabel() : String.valueOf(comboSkuId));
+        def.setComboPrice(sku.getPrice() != null ? sku.getPrice() : 0);
+        def.setChildren(children);
+        return def;
+    }
+}

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CartReplaceComboTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Cart;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.CartMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import com.etotem.cfc.service.CartService;
+import com.etotem.cfc.service.ComboOfferService;
+import com.etotem.cfc.service.MemberPriceResolver;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductSkuService;
+import com.etotem.cfc.service.SysConfigService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CartService.computeComboOffer / replaceCartWithCombo 服务层单测(mock mapper,Review Focus 4/5)。
+ * 勾选态是前端本地态(CartItemDTO.id=购物车主键),后端按勾选子集重算。
+ */
+@ExtendWith(MockitoExtension.class)
+class CartReplaceComboTest {
+
+    @Mock private CartMapper cartMapper;
+    @Mock private ProductMapper productMapper;
+    @Mock private ProductSkuService productSkuService;
+    @Mock private ProductGiftRuleService productGiftRuleService;
+    @Mock private SysConfigService sysConfigService;
+    @Mock private MemberPriceResolver memberPriceResolver;
+    @Mock private ComboOfferService comboOfferService;
+
+    @InjectMocks private CartService cartService;
+
+    private Product product(Long id, String name, int price) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        p.setPrice(price);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private ProductSku sku(Long id, Long productId, int price, Long linkedProductId) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setProductId(productId);
+        s.setPrice(price);
+        s.setLinkedProductId(linkedProductId);
+        return s;
+    }
+
+    private Cart cart(Long id, Long productId, Long skuId, Integer qty) {
+        Cart c = new Cart();
+        c.setId(id);
+        c.setUserId(100L);
+        c.setProductId(productId);
+        c.setSkuId(skuId);
+        c.setQuantity(qty);
+        return c;
+    }
+
+    private ComboOfferDef defAB() {
+        ComboOfferDef d = new ComboOfferDef();
+        d.setComboSkuId(900L);
+        d.setComboName("套餐X");
+        d.setComboPrice(15000);
+        ComboChild a = new ComboChild();
+        a.setChildProductId(1L);
+        a.setChildSkuId(11L);
+        a.setQuantity(2);
+        ComboChild b = new ComboChild();
+        b.setChildProductId(2L);
+        b.setChildSkuId(22L);
+        b.setQuantity(1);
+        d.setChildren(Arrays.asList(a, b));
+        return d;
+    }
+
+    /** 仅按勾选记录计算(未勾选行不参与),saving>0 才 active */
+    @Test
+    @SuppressWarnings("unchecked")
+    void computeComboOffer_onlyCheckedRowsInvolved() {
+        Cart ca = cart(1L, 1L, 11L, 2);
+        Cart cb = cart(2L, 2L, 22L, 1);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(productMapper.selectById(1L)).thenReturn(product(1L, "A", 5000));
+        when(productMapper.selectById(2L)).thenReturn(product(2L, "B", 6000));
+        when(productSkuService.getById(11L)).thenReturn(sku(11L, 1L, 5000, null));
+        when(productSkuService.getById(22L)).thenReturn(sku(22L, 2L, 6000, null));
+        when(memberPriceResolver.resolveEligibleMemberPrice(any(), any())).thenReturn(0);
+        ComboOfferResult stub = new ComboOfferResult();
+        stub.setActive(true);
+        when(comboOfferService.computeForLines(anyList())).thenReturn(stub);
+
+        ComboOfferResult r = cartService.computeComboOffer(100L, Arrays.asList(1L, 2L));
+
+        assertTrue(r.isActive());
+        ArgumentCaptor<List<LineItem>> captor = ArgumentCaptor.forClass((Class) List.class);
+        verify(comboOfferService).computeForLines(captor.capture());
+        assertEquals(2, captor.getValue().size());   // 仅勾选 2 行参与
+        LineItem l0 = captor.getValue().get(0);
+        assertEquals(1L, l0.getProductId());
+        assertEquals(11L, l0.getSkuId());
+        assertEquals(2, l0.getQuantity());
+        assertEquals(5000, l0.getUnitPrice());
+    }
+
+    /** 后端重算组数(忽略前端传入):A×4+B×2 传 99 份 → 只替换 2 组 */
+    @Test
+    void replaceCartWithCombo_recomputesGroupsIgnoringFrontend() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(null);   // 无既有套餐行
+
+        Result<Void> res = cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        assertNotNull(res);
+        // 子商品行扣 2 组:A 扣4→0 删、B 扣2→0 删
+        verify(cartMapper).deleteById(1L);
+        verify(cartMapper).deleteById(2L);
+        // 新增套餐 SKU 行 quantity=2(后端重算,非前端 99)
+        ArgumentCaptor<Cart> captor = ArgumentCaptor.forClass(Cart.class);
+        verify(cartMapper).insert(captor.capture());
+        assertEquals(99L, captor.getValue().getProductId());
+        assertEquals(900L, captor.getValue().getSkuId());
+        assertEquals(2, captor.getValue().getQuantity());
+    }
+
+    /** 余量保留:A×5+B×2 → A 余 1、B 余 0、套餐行 2 */
+    @Test
+    void replaceCartWithCombo_keepsRemainder() {
+        Cart ca = cart(1L, 1L, 11L, 5);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(null);
+
+        cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        verify(cartMapper).updateById(ca);            // A 扣 4 → 余 1
+        assertEquals(1, ca.getQuantity());
+        verify(cartMapper).deleteById(2L);            // B 扣 2 → 0 删
+        verify(cartMapper).insert(any());
+    }
+
+    /** 套餐商品已在购物车(同 product+sku)→ 合并数量而非重复套装 */
+    @Test
+    void replaceCartWithCombo_comboAlreadyInCart_mergesQuantity() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        Cart existingCombo = cart(3L, 99L, 900L, 1);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(existingCombo);
+
+        cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        verify(cartMapper, never()).insert(any());
+        verify(cartMapper).updateById(existingCombo);
+        assertEquals(3, existingCombo.getQuantity());   // 1 + 2
+    }
+
+    /** 事务性:任一步失败(deleteById 抛异常)→ 传播且未插入套餐行(整体回滚语义) */
+    @Test
+    void replace_anyStepFails_throwsAndNoComboInsert() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        doThrow(new RuntimeException("boom")).when(cartMapper).deleteById(anyLong());
+
+        assertThrows(RuntimeException.class,
+                () -> cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L));
+        verify(cartMapper, never()).insert(any());
+    }
+}

+ 140 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOfferCalculatorTest.java

@@ -0,0 +1,140 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferCalculator;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * ComboOfferCalculator 纯计算单测(Review Focus 1/2/3/7)。
+ * lines 用 (productId, skuId, quantity, unitPrice分) 构造。
+ */
+class ComboOfferCalculatorTest {
+
+    private final ComboOfferCalculator calc = new ComboOfferCalculator();
+
+    private static ComboOfferDef def(Long skuId, String name, int price, ComboChild... children) {
+        ComboOfferDef d = new ComboOfferDef();
+        d.setComboSkuId(skuId);
+        d.setComboName(name);
+        d.setComboPrice(price);
+        d.setChildren(Arrays.asList(children));
+        return d;
+    }
+
+    private static ComboChild child(Long pid, Long skuId, int qty) {
+        ComboChild c = new ComboChild();
+        c.setChildProductId(pid);
+        c.setChildSkuId(skuId);
+        c.setQuantity(qty);
+        return c;
+    }
+
+    private static LineItem line(Long pid, Long skuId, int qty, int price) {
+        return new LineItem(pid, skuId, qty, price);
+    }
+
+    /** 恰好覆盖:A×2+B×1(A=5000,B=6000)→ 套餐X(15000) 命中,saving=16000-15000=1000 */
+    @Test
+    void exactCoverage_hitsCombo() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals("套餐X", r.getComboName());
+        assertEquals(1, r.getGroups());
+        assertEquals(16000, r.getOriginalTotal());
+        assertEquals(15000, r.getComboPrice());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 套餐价>=总和:16000 → inactive(原价结算,Review Focus 1) */
+    @Test
+    void comboPriceEqualsTotal_inactive() {
+        ComboOfferDef x = def(900L, "套餐X", 16000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 覆盖不足:A×1+B×1 → inactive(Review Focus 2) */
+    @Test
+    void insufficientCoverage_inactive() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 1, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 超量覆盖:A×4+B×2 → groups=2,originalTotal=32000,套餐 15000×2,saving=32000-30000=2000(Review Focus 3) */
+    @Test
+    void overCoverage_twoGroups() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 4, 5000), line(2L, 22L, 2, 6000)),
+                Collections.singletonList(x));
+        assertTrue(r.isActive());
+        assertEquals(2, r.getGroups());
+        assertEquals(32000, r.getOriginalTotal());
+        assertEquals(15000, r.getComboPrice());   // 每组套餐价
+        assertEquals(2000, r.getSaving());
+    }
+
+    /** 多套餐候选:X省1000 vs Y省200 → 返回 X(节省最大,Review Focus 7) */
+    @Test
+    void multipleCandidates_picksMaxSaving() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferDef yc = def(901L, "Y", 15800, child(2L, 22L, 1), child(3L, 33L, 1));
+        // X: 16000-15000=1000;Y: B×1(6000)+C×1(10000) 原价16000-15800=200 → 选 X
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000), line(3L, 33L, 1, 10000)),
+                Arrays.asList(x, yc));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 两套餐同额 → 返回先出现者(Review Focus 7) */
+    @Test
+    void equalSaving_keepsFirst() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferDef y = def(901L, "Y", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Arrays.asList(x, y));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 脏行防御:child 的 childSkuId 不在 lines → 不命中该套餐 */
+    @Test
+    void dirtyChildSkuId_notInLines_inactive() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 999L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 空 lines / 空 defs → inactive */
+    @Test
+    void emptyInputs_inactive() {
+        assertFalse(calc.compute(null, Collections.singletonList(def(900L, "X", 15000))).isActive());
+        assertFalse(calc.compute(Collections.singletonList(line(1L, 11L, 2, 5000)), null).isActive());
+        assertFalse(calc.compute(Collections.emptyList(), Collections.emptyList()).isActive());
+    }
+}