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

feat(backend): 套餐内容保存/列表接口(combo/items)含子SKU校验与详情清单挂载

Xiaogang Liao 1 неделя назад
Родитель
Сommit
e6c4a07d6d

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java

@@ -2,6 +2,8 @@ package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.ProductBundleItem;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.service.ComboService;
 import com.etotem.cfc.service.InventoryService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -21,6 +23,8 @@ public class AdminInventoryController {
 
     @Resource
     private InventoryService inventoryService;
+    @Resource
+    private ComboService comboService;
 
     // ========== 入库单 ==========
 
@@ -168,6 +172,33 @@ public class AdminInventoryController {
         return inventoryService.listBundleItems(bundleProductId);
     }
 
+    @Operation(summary = "保存套餐内容")
+    @PostMapping("/combo/items")
+    public Result<String> saveComboItems(@RequestBody Map<String, Object> params) {
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        if (comboSkuId == null) return Result.error("comboSkuId不能为空");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
+        try {
+            comboService.saveComboItems(comboSkuId, items);
+            return Result.success("ok");
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "查询套餐内容")
+    @PostMapping("/combo/items/list")
+    public Result<List<ProductComboItem>> listComboItems(@RequestBody Map<String, Object> params) {
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        if (comboSkuId == null) return Result.error("comboSkuId不能为空");
+        try {
+            return Result.success(comboService.listComboItems(comboSkuId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @Operation(summary = "检查套餐库存")
     @PostMapping("/bundle/check-stock")
     public Result<String> checkBundleStock(@RequestBody Map<String, Object> params) {

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -81,6 +81,9 @@ public class ProductDTO {
     // 套餐商品组成(仅 productType=bundle 时非空)
     private List<BundleItemVO> bundleItems;
 
+    /** 套餐内容(新模型:套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级) */
+    private List<ComboItemVO> comboItems;
+
     @Data
     public static class BundleItemVO {
         private Long childProductId;
@@ -91,6 +94,16 @@ public class ProductDTO {
         private Integer quantity;
     }
 
+    @Data
+    public static class ComboItemVO {
+        private Long childProductId;
+        private String childProductName;
+        private String coverImage;
+        private Long childSkuId;
+        private String specs;        // SKU 规格描述(无 SKU 时为空)
+        private Integer quantity;
+    }
+
     private Date createdAt;
     private Date updatedAt;
 

+ 127 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ComboService.java

@@ -0,0 +1,127 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductComboItemMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+public class ComboService {
+
+    @Resource
+    private ProductComboItemMapper comboItemMapper;
+    @Resource
+    private ProductSkuMapper productSkuMapper;
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 保存套餐内容(先删后插,combo_sku_id 键控)。
+     * 校验:套餐SKU存在;子商品SKU必填且存在(精确到规格);数量>0;childProductId 与 childSkuId 归属一致。
+     */
+    public void saveComboItems(Long comboSkuId, List<Map<String, Object>> items) {
+        if (comboSkuId == null) {
+            throw new IllegalArgumentException("comboSkuId不能为空");
+        }
+        ProductSku combo = productSkuMapper.selectById(comboSkuId);
+        if (combo == null) {
+            throw new IllegalArgumentException("套餐SKU不存在");
+        }
+        // 整体校验后再替换,避免部分写入
+        if (items != null) {
+            for (Map<String, Object> in : items) {
+                if (in == null) continue;
+                Long childSkuId = ParamUtils.getLong(in.get("childSkuId"));
+                Long childProductId = ParamUtils.getLong(in.get("childProductId"));
+                Integer quantity = in.get("quantity") != null ? Integer.valueOf(in.get("quantity").toString()) : null;
+                if (childSkuId == null) {
+                    throw new IllegalArgumentException("子商品SKU不能为空(套餐内容精确到规格)");
+                }
+                ProductSku childSku = productSkuMapper.selectById(childSkuId);
+                if (childSku == null) {
+                    throw new IllegalArgumentException("子商品SKU不存在: " + childSkuId);
+                }
+                if (quantity == null || quantity <= 0) {
+                    throw new IllegalArgumentException("子商品SKU " + childSkuId + " 的数量必须大于0");
+                }
+                if (childProductId == null) {
+                    childProductId = childSku.getProductId();
+                } else if (!childProductId.equals(childSku.getProductId())) {
+                    throw new IllegalArgumentException("childSkuId " + childSkuId + " 与 childProductId " + childProductId + " 不匹配");
+                }
+            }
+        }
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductComboItem>()
+                .eq(ProductComboItem::getComboSkuId, comboSkuId));
+        if (items == null) return;
+        for (Map<String, Object> in : items) {
+            if (in == null) continue;
+            Long childSkuId = ParamUtils.getLong(in.get("childSkuId"));
+            Long childProductId = ParamUtils.getLong(in.get("childProductId"));
+            Integer quantity = Integer.valueOf(in.get("quantity").toString());
+            if (childProductId == null) {
+                ProductSku childSku = productSkuMapper.selectById(childSkuId);
+                childProductId = childSku.getProductId();
+            }
+            ProductComboItem row = new ProductComboItem();
+            row.setComboSkuId(comboSkuId);
+            row.setChildProductId(childProductId);
+            row.setChildSkuId(childSkuId);
+            row.setQuantity(quantity);
+            comboItemMapper.insert(row);
+        }
+    }
+
+    /**
+     * 查询套餐内容(按 comboSkuId,回填子商品名/封面/价格/SKU规格展示字段)。
+     */
+    public List<ProductComboItem> listComboItems(Long comboSkuId) {
+        if (comboSkuId == null) {
+            throw new IllegalArgumentException("comboSkuId不能为空");
+        }
+        List<ProductComboItem> items = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductComboItem>()
+                        .eq(ProductComboItem::getComboSkuId, comboSkuId)
+                        .orderByAsc(ProductComboItem::getId));
+        if (items == null || items.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Set<Long> productIds = new HashSet<>();
+        Set<Long> skuIds = new HashSet<>();
+        for (ProductComboItem item : items) {
+            productIds.add(item.getChildProductId());
+            skuIds.add(item.getChildSkuId());
+        }
+        Map<Long, Product> productMap = productMapper.selectBatchIds(productIds).stream()
+                .filter(p -> p != null).collect(Collectors.toMap(Product::getId, p -> p));
+        Map<Long, ProductSku> skuMap = productSkuMapper.selectBatchIds(skuIds).stream()
+                .filter(s -> s != null).collect(Collectors.toMap(ProductSku::getId, s -> s));
+        for (ProductComboItem item : items) {
+            Product child = productMap.get(item.getChildProductId());
+            if (child != null) {
+                item.setChildProductName(child.getName());
+                item.setChildProductImage(child.getCoverImage());
+                item.setChildProductPrice(child.getPrice());
+            }
+            ProductSku childSku = skuMap.get(item.getChildSkuId());
+            if (childSku != null) {
+                item.setChildSkuSpecs(childSku.getSpecs());
+            }
+        }
+        return items;
+    }
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -16,6 +16,7 @@ import com.etotem.cfc.entity.GrowthArchiveProduct;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
+import com.etotem.cfc.entity.ProductComboItem;
 import com.etotem.cfc.entity.ProductSku;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.entity.User;
@@ -76,6 +77,8 @@ public class ProductService {
 
     @Resource
     private ProductSkuMapper productSkuMapper;
+    @Resource
+    private ComboService comboService;
 
     @Resource
     private FiveDimensionScoreService fiveDimensionScoreService;
@@ -317,6 +320,25 @@ wrapper.in(Product::getId, productIds)
                 bundleItems.add(vo);
             }
             dto.setBundleItems(bundleItems);
+            // 套餐内容(新模型:内容=子商品SKU×数量,挂套餐SKU级;下单只扣套餐SKU库存)
+            List<ProductDTO.ComboItemVO> comboItems = new ArrayList<>();
+            List<ProductSku> comboSkus = productSkuMapper.selectList(
+                    new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, id));
+            if (comboSkus != null) {
+                for (ProductSku comboSku : comboSkus) {
+                    for (ProductComboItem ci : comboService.listComboItems(comboSku.getId())) {
+                        ProductDTO.ComboItemVO vo = new ProductDTO.ComboItemVO();
+                        vo.setChildProductId(ci.getChildProductId());
+                        vo.setChildProductName(ci.getChildProductName());
+                        vo.setCoverImage(ci.getChildProductImage());
+                        vo.setChildSkuId(ci.getChildSkuId());
+                        vo.setSpecs(ci.getChildSkuSpecs());
+                        vo.setQuantity(ci.getQuantity());
+                        comboItems.add(vo);
+                    }
+                }
+            }
+            dto.setComboItems(comboItems);
         }
         ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(id);
         if (giftRule != null) {

+ 198 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ComboServiceTest.java

@@ -0,0 +1,198 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductComboItemMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ComboService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.InOrder;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 套餐内容管理服务单测(套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级)。
+ */
+class ComboServiceTest {
+
+    private ComboService service;
+    private ProductComboItemMapper comboItemMapper;
+    private ProductSkuMapper productSkuMapper;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaQueryWrapper 解析列名依赖 MyBatis-Plus TableInfo 缓存(与 ProductSkuComboServiceTest 同约定)
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, ProductComboItem.class);
+        TableInfoHelper.initTableInfo(assistant, ProductSku.class);
+        service = new ComboService();
+        comboItemMapper = mock(ProductComboItemMapper.class);
+        productSkuMapper = mock(ProductSkuMapper.class);
+        productMapper = mock(ProductMapper.class);
+        ReflectionTestUtils.setField(service, "comboItemMapper", comboItemMapper);
+        ReflectionTestUtils.setField(service, "productSkuMapper", productSkuMapper);
+        ReflectionTestUtils.setField(service, "productMapper", productMapper);
+    }
+
+    private ProductSku sku(Long id, Long productId) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setProductId(productId);
+        return s;
+    }
+
+    private Map<String, Object> item(Long childProductId, Long childSkuId, Object quantity) {
+        Map<String, Object> m = new HashMap<>();
+        if (childProductId != null) m.put("childProductId", childProductId);
+        if (childSkuId != null) m.put("childSkuId", childSkuId);
+        if (quantity != null) m.put("quantity", quantity);
+        return m;
+    }
+
+    @Test
+    void saveComboItems_deletesOldRowsThenInsertsNew() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(sku(100L, 10L));
+
+        service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, 2)));
+
+        InOrder inOrder = inOrder(comboItemMapper);
+        inOrder.verify(comboItemMapper).delete(any());
+        inOrder.verify(comboItemMapper).insert(any(ProductComboItem.class));
+        verify(comboItemMapper, times(1)).insert(any(ProductComboItem.class));
+    }
+
+    @Test
+    void saveComboItems_insertedRowCarriesComboSkuIdChildAndQty() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(sku(100L, 10L));
+
+        service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, 3)));
+
+        ArgumentCaptor<ProductComboItem> captor = ArgumentCaptor.forClass(ProductComboItem.class);
+        verify(comboItemMapper).insert(captor.capture());
+        ProductComboItem row = captor.getValue();
+        assertEquals(Long.valueOf(9L), row.getComboSkuId());
+        assertEquals(Long.valueOf(10L), row.getChildProductId());
+        assertEquals(Long.valueOf(100L), row.getChildSkuId());
+        assertEquals(Integer.valueOf(3), row.getQuantity());
+    }
+
+    @Test
+    void saveComboItems_quantityMissing_throwsAndInsertsNothing() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(sku(100L, 10L));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, null))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void saveComboItems_quantityNotPositive_throwsAndInsertsNothing() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(sku(100L, 10L));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, 0))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void saveComboItems_childSkuMissing_throwsAndInsertsNothing() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(10L, null, 1))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void saveComboItems_comboSkuNotFound_throwsAndInsertsNothing() {
+        when(productSkuMapper.selectById(9L)).thenReturn(null);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, 1))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void saveComboItems_childSkuNotFound_throwsAndInsertsNothing() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(null);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(10L, 100L, 1))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void saveComboItems_childProductMismatchWithChildSku_throws() {
+        when(productSkuMapper.selectById(9L)).thenReturn(sku(9L, 1L));
+        when(productSkuMapper.selectById(100L)).thenReturn(sku(100L, 10L));
+
+        assertThrows(IllegalArgumentException.class,
+                () -> service.saveComboItems(9L, Collections.singletonList(item(99L, 100L, 1))));
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void listComboItems_fillsChildDisplayFields() {
+        ProductComboItem row = new ProductComboItem();
+        row.setId(1L);
+        row.setComboSkuId(9L);
+        row.setChildProductId(10L);
+        row.setChildSkuId(100L);
+        row.setQuantity(2);
+        when(comboItemMapper.selectList(any())).thenReturn(Collections.singletonList(row));
+
+        Product child = new Product();
+        child.setId(10L);
+        child.setName("子商品A");
+        child.setCoverImage("http://img/a.png");
+        child.setPrice(5000);
+        when(productMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(child));
+
+        ProductSku childSku = sku(100L, 10L);
+        childSku.setSpecs("红色/L");
+        when(productSkuMapper.selectBatchIds(any())).thenReturn(Collections.singletonList(childSku));
+
+        List<ProductComboItem> items = service.listComboItems(9L);
+
+        assertEquals(1, items.size());
+        ProductComboItem got = items.get(0);
+        assertEquals("子商品A", got.getChildProductName());
+        assertEquals("http://img/a.png", got.getChildProductImage());
+        assertEquals(Integer.valueOf(5000), got.getChildProductPrice());
+        assertEquals("红色/L", got.getChildSkuSpecs());
+    }
+
+    @Test
+    void listComboItems_emptyResult_returnsEmptyList() {
+        when(comboItemMapper.selectList(any())).thenReturn(Collections.emptyList());
+
+        assertEquals(0, service.listComboItems(9L).size());
+    }
+}