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

feat(bundle): BundlePricingService 装载定价套餐定义并委托计算

- 从 product_bundle_items 取 bundle_sku_id 非空行按锚点 SKU 分组
- 锚点 SKU 缺失/无价时跳过该套餐(防御);展示名 label 优先回落到 specs
Xiaogang Liao 1 неделя назад
Родитель
Сommit
40a90931fd

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

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