Răsfoiți Sursa

feat(bundle): 购物车返回 bundlePromo,移除一口价(threshold/payPrice)结算

- computeGiftPromo 返回空(送会员一口价废弃),giftPromo 置空兼容旧前端
- computeBundlePromo:按 MemberPriceResolver 下单口径聚合购物车,委托 BundlePricingService
- CartItemDTO/CartListResultDTO 新增 BundlePromo/BundleItemHit 结构
Xiaogang Liao 1 săptămână în urmă
părinte
comite
f97585868a

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -69,4 +69,34 @@ public class CartItemDTO {
         /** 还差多少到阈值(分) */
         private Integer remaining;
     }
+
+    @Data
+    public static class BundlePromo {
+        /** 是否命中至少一套套餐(节省额>0) */
+        private Boolean active;
+        /** 命中套餐部分的基础款原价合计(分) */
+        private Integer originalTotal;
+        /** 命中套餐价合计(分) */
+        private Integer payPrice;
+        /** 套餐节省额合计(分) */
+        private Integer discount;
+        /** 命中套餐明细 */
+        private List<BundleItemHit> bundleList;
+    }
+
+    @Data
+    public static class BundleItemHit {
+        /** 套餐锚点 SKU id */
+        private Long bundleSkuId;
+        /** 套餐展示名 */
+        private String bundleName;
+        /** 命中套数 */
+        private Integer times;
+        /** 命中部分原价合计(分) */
+        private Integer originalTotal;
+        /** 套餐价合计(分) */
+        private Integer bundlePrice;
+        /** 节省额(分) */
+        private Integer saving;
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CartListResultDTO.java

@@ -9,4 +9,7 @@ public class CartListResultDTO {
     private List<CartItemDTO> items;
 
     private CartItemDTO.GiftPromo giftPromo;
+
+    /** 订单套餐自动定价优惠(product_bundle_items 定价行 bundle_sku_id 匹配) */
+    private CartItemDTO.BundlePromo bundlePromo;
 }

+ 73 - 57
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -11,6 +11,9 @@ import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.mapper.CartMapper;
 import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.pricing.BundleDiscountResult;
+import com.etotem.cfc.pricing.BundleHit;
+import com.etotem.cfc.pricing.LineItem;
 import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductGiftRuleService;
 import com.etotem.cfc.service.ProductSkuService;
@@ -50,6 +53,12 @@ public class CartService {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Resource
+    private BundlePricingService bundlePricingService;
+
+    @Resource
+    private MemberPriceResolver memberPriceResolver;
+
     @Transactional
     public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
         Product product = productMapper.selectById(productId);
@@ -127,12 +136,14 @@ public class CartService {
         // 自动套餐模式:检测购物车子商品组合是否凑成某套餐,命中则挂 bundleMatch 提示
         attachBundleMatches(cartList, result);
 
-        // 计算送会员商品聚合信息
+        // 订单套餐自动定价优惠(替换原"送会员一口价"结算价逻辑;giftPromo 置空保持旧前端兼容)
         CartItemDTO.GiftPromo giftPromo = computeGiftPromo(cartList, productMap);
+        CartItemDTO.BundlePromo bundlePromo = computeBundlePromo(cartList, productMap, userId);
 
         CartListResultDTO cartResult = new CartListResultDTO();
         cartResult.setItems(result);
         cartResult.setGiftPromo(giftPromo);
+        cartResult.setBundlePromo(bundlePromo);
         return cartResult;
     }
 
@@ -408,78 +419,83 @@ public class CartService {
     }
 
     /**
-     * 计算购物车中送会员商品的聚合信息:原价合计、是否达标、折扣金额等。
-     * 购物车所有商品默认视为已选中(checked=true)。
+     * 送会员一口价已废弃(2026-10-30 设计:改为订单套餐自动定价)。
+     * 原"达标→一口价1980"结算逻辑不再产生;giftPromo 恒为空,供旧前端读 active=false。
      */
     private CartItemDTO.GiftPromo computeGiftPromo(List<Cart> cartList, Map<Long, Product> productMap) {
+        return buildEmptyGiftPromo();
+    }
+
+    /**
+     * 订单套餐自动定价优惠:扫描购物车商品,按下单结算口径(会员/SKU/linked 价)聚合,
+     * 匹配 product_bundle_items 中 bundle_sku_id 非空的定价套餐,贪心选省最多,返回 bundlePromo。
+     * 展示口径与下单一致(MemberPriceResolver),保证页面提示=实际扣款。
+     */
+    private CartItemDTO.BundlePromo computeBundlePromo(List<Cart> cartList, Map<Long, Product> productMap, Long userId) {
         if (cartList == null || cartList.isEmpty()) {
-            return buildEmptyGiftPromo();
+            return emptyBundlePromo();
         }
-
-        // 查询购物车中所有商品的送会员规则
-        List<Long> giftProductIds = new ArrayList<>();
+        List<LineItem> lines = new ArrayList<>();
         for (Cart cart : cartList) {
-            Long pid = cart.getProductId();
-            if (pid != null && !giftProductIds.contains(pid)) {
-                giftProductIds.add(pid);
+            if (cart == null || cart.getProductId() == null || cart.getQuantity() == null || cart.getQuantity() <= 0) {
+                continue;
             }
-        }
-        List<ProductGiftRule> giftRules = productGiftRuleService.listActiveByProductIds(giftProductIds);
-
-        // 按 productId 建立 rule 映射
-        Map<Long, ProductGiftRule> ruleMap = new HashMap<>();
-        for (ProductGiftRule rule : giftRules) {
-            if (rule.getProductId() != null) {
-                ruleMap.put(rule.getProductId(), rule);
+            Product product = productMap.get(cart.getProductId());
+            if (product == null) {
+                continue;
             }
-        }
-
-        // 计算送会员商品原价合计
-        int giftOriginalTotal = 0;
-        for (Cart cart : cartList) {
-            if (ruleMap.containsKey(cart.getProductId())) {
-                Product product = productMap.get(cart.getProductId());
-                if (product != null && product.getPrice() != null && cart.getQuantity() != null) {
-                    giftOriginalTotal += product.getPrice() * cart.getQuantity();
+            ProductSku sku = null;
+            if (cart.getSkuId() != null) {
+                sku = productSkuService.getById(cart.getSkuId());
+            }
+            // 解析后身份 + 下单口径单价(linked 转发商品、SKU 价、否则会员价)
+            Long resolvedProductId = product.getId();
+            int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, userId);
+            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));
         }
-
-        // 读取配置
-        String thresholdStr = sysConfigService.getValue("gift_membership_original_threshold");
-        int threshold;
-        try {
-            threshold = thresholdStr != null ? Integer.parseInt(thresholdStr) : 0;
-        } catch (NumberFormatException e) {
-            threshold = 0;
-        }
-
-        String payPriceStr = sysConfigService.getValue("gift_membership_pay_price");
-        int payPrice;
-        try {
-            payPrice = payPriceStr != null ? Integer.parseInt(payPriceStr) : 0;
-        } catch (NumberFormatException e) {
-            payPrice = 0;
+        if (lines.isEmpty()) {
+            return emptyBundlePromo();
         }
+        BundleDiscountResult result = bundlePricingService.compute(lines);
+        return toBundlePromo(result);
+    }
 
-        if (threshold <= 0) {
-            return buildEmptyGiftPromo();
+    private CartItemDTO.BundlePromo toBundlePromo(BundleDiscountResult r) {
+        CartItemDTO.BundlePromo promo = new CartItemDTO.BundlePromo();
+        promo.setActive(r.isActive());
+        promo.setOriginalTotal(r.getOriginalTotal() != null ? r.getOriginalTotal() : 0);
+        promo.setPayPrice(r.getPayPrice() != null ? r.getPayPrice() : 0);
+        promo.setDiscount(r.getDiscount() != null ? r.getDiscount() : 0);
+        List<CartItemDTO.BundleItemHit> hits = new ArrayList<>();
+        if (r.getBundleList() != null) {
+            for (BundleHit hit : r.getBundleList()) {
+                CartItemDTO.BundleItemHit h = new CartItemDTO.BundleItemHit();
+                h.setBundleSkuId(hit.getBundleSkuId());
+                h.setBundleName(hit.getBundleName());
+                h.setTimes(hit.getTimes());
+                h.setOriginalTotal(hit.getOriginalTotal());
+                h.setBundlePrice(hit.getBundlePrice());
+                h.setSaving(hit.getSaving());
+                hits.add(h);
+            }
         }
-
-        boolean active = giftOriginalTotal >= threshold;
-        int discount = active ? Math.max(0, giftOriginalTotal - payPrice) : 0;
-        int remaining = active ? 0 : Math.max(0, threshold - giftOriginalTotal);
-
-        CartItemDTO.GiftPromo promo = new CartItemDTO.GiftPromo();
-        promo.setGiftOriginalTotal(giftOriginalTotal);
-        promo.setThreshold(threshold);
-        promo.setPayPrice(active ? payPrice : 0);
-        promo.setDiscount(discount);
-        promo.setActive(active);
-        promo.setRemaining(remaining);
+        promo.setBundleList(hits);
         return promo;
     }
 
+    private CartItemDTO.BundlePromo emptyBundlePromo() {
+        return toBundlePromo(BundleDiscountResult.inactive());
+    }
+
     private CartItemDTO.GiftPromo buildEmptyGiftPromo() {
         CartItemDTO.GiftPromo promo = new CartItemDTO.GiftPromo();
         promo.setGiftOriginalTotal(0);

+ 137 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CartServiceTest.java

@@ -0,0 +1,137 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.etotem.cfc.dto.CartItemDTO;
+import com.etotem.cfc.dto.CartListResultDTO;
+import com.etotem.cfc.entity.Cart;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.CartMapper;
+import com.etotem.cfc.mapper.ProductBundleItemMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.pricing.BundleDiscountResult;
+import com.etotem.cfc.pricing.BundleHit;
+import com.etotem.cfc.pricing.LineItem;
+import com.etotem.cfc.service.BundlePricingService;
+import com.etotem.cfc.service.CartService;
+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.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+/**
+ * CartService.getCartList 单测:bundlePromo 绑定(委托 BundlePricingService)、giftPromo 置空(一口价废弃)。
+ */
+@ExtendWith(MockitoExtension.class)
+class CartServiceTest {
+
+    @Mock private CartMapper cartMapper;
+    @Mock private ProductMapper productMapper;
+    @Mock private ProductBundleItemMapper bundleItemMapper;
+    @Mock private ProductSkuService productSkuService;
+    @Mock private ProductGiftRuleService productGiftRuleService;
+    @Mock private SysConfigService sysConfigService;
+    @Mock private BundlePricingService bundlePricingService;
+    @Mock private MemberPriceResolver memberPriceResolver;
+    @InjectMocks private CartService cartService;
+
+    private Cart cart(Long productId, Long skuId, int qty) {
+        Cart c = new Cart();
+        c.setId(1L);
+        c.setUserId(1L);
+        c.setProductId(productId);
+        c.setSkuId(skuId);
+        c.setQuantity(qty);
+        return c;
+    }
+
+    private Product product(Long id, String name, int price) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        p.setPrice(price);
+        p.setProductType("assessment"); // 非 bundle,避免 buildBundleInfo
+        p.setStatus("on_shelf");
+        return p;
+    }
+
+    @Test
+    void getCartList_bindsBundlePromo_andGiftPromoEmpty() {
+        when(cartMapper.selectList(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(cart(10L, null, 2)));
+        when(productMapper.selectBatchIds(anyList()))
+                .thenReturn(Collections.singletonList(product(10L, "DAN测评", 69900)));
+        // attachBundleMatches:查询 bundle 商品 → 空,直接返回
+        when(productMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), any())).thenReturn(69900);
+        // 模拟定价命中:1 套套餐,省 11800
+        BundleDiscountResult r = new BundleDiscountResult();
+        r.setActive(true);
+        r.setOriginalTotal(139800);
+        r.setPayPrice(128000);
+        r.setDiscount(11800);
+        BundleHit hit = new BundleHit(3L, "套餐一", 1, 139800, 128000, 11800);
+        r.setBundleList(new ArrayList<>(Collections.singletonList(hit)));
+        when(bundlePricingService.compute(anyList())).thenReturn(r);
+
+        CartListResultDTO result = cartService.getCartList(1L);
+
+        assertNotNull(result.getBundlePromo());
+        assertTrue(result.getBundlePromo().getActive());
+        assertEquals(11800, result.getBundlePromo().getDiscount());
+        assertEquals(1, result.getBundlePromo().getBundleList().size());
+        assertEquals("套餐一", result.getBundlePromo().getBundleList().get(0).getBundleName());
+        // 一口价废弃:giftPromo 恒空
+        assertNotNull(result.getGiftPromo());
+        assertFalse(result.getGiftPromo().getActive());
+        assertEquals(0, result.getGiftPromo().getDiscount());
+    }
+
+    @Test
+    void getCartList_emptyCart_bundlePromoInactive() {
+        when(cartMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+
+        CartListResultDTO result = cartService.getCartList(1L);
+
+        assertNotNull(result.getBundlePromo());
+        assertFalse(result.getBundlePromo().getActive());
+        assertEquals(0, result.getBundlePromo().getDiscount());
+        assertFalse(result.getGiftPromo().getActive());
+    }
+
+    @Test
+    void getCartList_noBundleMatch_bundlePromoInactive() {
+        when(cartMapper.selectList(any(Wrapper.class)))
+                .thenReturn(Collections.singletonList(cart(10L, null, 1)));
+        when(productMapper.selectBatchIds(anyList()))
+                .thenReturn(Collections.singletonList(product(10L, "DAN测评", 69900)));
+        when(productMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
+        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), any())).thenReturn(69900);
+        // 定价服务判定不命中
+        when(bundlePricingService.compute(anyList())).thenReturn(BundleDiscountResult.inactive());
+
+        CartListResultDTO result = cartService.getCartList(1L);
+
+        assertNotNull(result.getBundlePromo());
+        assertFalse(result.getBundlePromo().getActive());
+        assertEquals(0, result.getBundlePromo().getDiscount());
+    }
+}