Przeglądaj źródła

feat(backend): 组合SKU服务层读写与校验

Xiaogang Liao 1 tydzień temu
rodzic
commit
ae7d9a6d61

+ 118 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java

@@ -3,7 +3,9 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuComboItemMapper;
 import com.etotem.cfc.mapper.ProductSkuMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -27,16 +29,27 @@ public class ProductSkuService {
     @Resource
     private ProductMapper productMapper;
 
+    @Resource
+    private ProductSkuComboItemMapper comboItemMapper;
+
     public List<ProductSku> listByProductId(Long productId) {
-        return skuMapper.findByProductId(productId);
+        List<ProductSku> list = skuMapper.findByProductId(productId);
+        fillComboItems(list);
+        return list;
     }
 
     public List<ProductSku> listByProductIdAll(Long productId) {
-        return skuMapper.findByProductIdAll(productId);
+        List<ProductSku> list = skuMapper.findByProductIdAll(productId);
+        fillComboItems(list);
+        return list;
     }
 
     public ProductSku getById(Long id) {
-        return skuMapper.findById(id);
+        ProductSku sku = skuMapper.findById(id);
+        if (sku != null) {
+            fillComboItems(java.util.Collections.singletonList(sku));
+        }
+        return sku;
     }
 
     @Transactional
@@ -56,12 +69,18 @@ public class ProductSkuService {
         if (sku.getSkuCode() == null || sku.getSkuCode().isEmpty()) {
             sku.setSkuCode("SKU-" + sku.getProductId() + "-" + System.currentTimeMillis());
         }
+        validateComboItems(sku.getComboItems());
         skuMapper.insert(sku);
+        saveComboItems(sku.getId(), sku.getComboItems());
         return sku;
     }
 
     @Transactional
     public void update(ProductSku sku) {
+        validateComboItems(sku.getComboItems());
+        if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+            sku.setLinkedProductId(null);
+        }
         if (sku.getLinkedProductId() == null) {
             // Legacy SKU: 需要 specs
             if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
@@ -70,10 +89,18 @@ public class ProductSkuService {
         }
         sku.setUpdateTime(new Date());
         skuMapper.updateById(sku);
+        saveComboItems(sku.getId(), sku.getComboItems());
+        if (sku.getLinkedProductId() == null) {
+            skuMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ProductSku>()
+                    .eq(ProductSku::getId, sku.getId())
+                    .set(ProductSku::getLinkedProductId, null));
+        }
     }
 
     @Transactional
     public void delete(Long id) {
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, id));
         skuMapper.deleteById(id);
     }
 
@@ -208,4 +235,91 @@ public class ProductSkuService {
         return s.replace("\\", "\\\\").replace("\"", "\\\"")
                 .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t");
     }
-}
+
+    private void saveComboItems(Long skuId, List<ProductSkuComboItem> items) {
+        if (items == null) return;
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, skuId));
+        if (items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            item.setId(null);
+            item.setSkuId(skuId);
+            if (item.getQuantity() == null || item.getQuantity() < 1) item.setQuantity(1);
+            comboItemMapper.insert(item);
+        }
+    }
+
+    private void validateComboItems(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return;
+        for (ProductSkuComboItem item : items) {
+            if (item.getChildProductId() == null) throw new IllegalArgumentException("组合商品不能为空");
+            List<ProductSku> childSkus = skuMapper.findByProductId(item.getChildProductId());
+            boolean hasEnabledSku = childSkus != null
+                    && childSkus.stream().anyMatch(s -> s.getEnabled() != null && s.getEnabled() == 1);
+            if (hasEnabledSku && item.getChildSkuId() == null) {
+                throw new IllegalArgumentException("组合商品存在 SKU,必须选择具体 SKU");
+            }
+        }
+    }
+
+    public void fillComboItems(List<ProductSku> skus) {
+        if (skus == null || skus.isEmpty()) return;
+        List<Long> skuIds = skus.stream().map(ProductSku::getId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toList());
+        if (skuIds.isEmpty()) return;
+        List<ProductSkuComboItem> all = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductSkuComboItem>().in(ProductSkuComboItem::getSkuId, skuIds));
+        if (all.isEmpty()) {
+            skus.forEach(s -> s.setComboItems(java.util.Collections.emptyList()));
+            return;
+        }
+        java.util.Set<Long> productIds = all.stream().map(ProductSkuComboItem::getChildProductId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, Product> productMap = productIds.isEmpty() ? java.util.Collections.emptyMap()
+                : productMapper.selectBatchIds(productIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(Product::getId, p -> p, (a, b) -> a));
+        java.util.Set<Long> childSkuIds = all.stream().map(ProductSkuComboItem::getChildSkuId)
+                .filter(java.util.Objects::nonNull).collect(java.util.stream.Collectors.toSet());
+        java.util.Map<Long, ProductSku> childSkuMap = childSkuIds.isEmpty() ? java.util.Collections.emptyMap()
+                : skuMapper.selectBatchIds(childSkuIds).stream()
+                    .collect(java.util.stream.Collectors.toMap(ProductSku::getId, s -> s, (a, b) -> a));
+        for (ProductSkuComboItem item : all) {
+            Product p = productMap.get(item.getChildProductId());
+            if (p != null) {
+                item.setChildProductName(p.getName());
+                item.setChildProductImage(p.getCoverImage());
+                item.setChildProductPrice(p.getPrice());
+            }
+            ProductSku cs = item.getChildSkuId() == null ? null : childSkuMap.get(item.getChildSkuId());
+            if (cs != null) item.setChildSkuSpecs(cs.getSpecs());
+        }
+        java.util.Map<Long, List<ProductSkuComboItem>> grouped = all.stream()
+                .collect(java.util.stream.Collectors.groupingBy(ProductSkuComboItem::getSkuId));
+        for (ProductSku sku : skus) {
+            sku.setComboItems(grouped.getOrDefault(sku.getId(), java.util.Collections.emptyList()));
+        }
+    }
+
+    public boolean hasComboItems(ProductSku sku) {
+        return sku != null && sku.getComboItems() != null && !sku.getComboItems().isEmpty();
+    }
+
+    public List<ProductSkuComboItem> listComboItems(Long skuId) {
+        ProductSku tmp = new ProductSku();
+        tmp.setId(skuId);
+        fillComboItems(java.util.Collections.singletonList(tmp));
+        return tmp.getComboItems() == null ? java.util.Collections.emptyList() : tmp.getComboItems();
+    }
+
+    public String buildComboSummary(List<ProductSkuComboItem> items) {
+        if (items == null || items.isEmpty()) return "";
+        StringBuilder sb = new StringBuilder();
+        for (ProductSkuComboItem item : items) {
+            if (sb.length() > 0) sb.append(";");
+            String name = item.getChildProductName() != null ? item.getChildProductName()
+                    : ("商品#" + item.getChildProductId());
+            sb.append(name).append("×").append(item.getQuantity() == null ? 1 : item.getQuantity());
+        }
+        return sb.toString();
+    }
+}

+ 117 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java

@@ -0,0 +1,117 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuComboItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ProductSkuService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class ProductSkuComboServiceTest {
+
+    private ProductSkuService service;
+    private ProductSkuMapper skuMapper;
+    private ProductMapper productMapper;
+    private ProductSkuComboItemMapper comboItemMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaUpdateWrapper/LambdaQueryWrapper 解析列名依赖 MyBatis-Plus TableInfo 缓存(与 ProductUpdateClearTest 同约定)
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, ProductSku.class);
+        service = new ProductSkuService();
+        skuMapper = mock(ProductSkuMapper.class);
+        productMapper = mock(ProductMapper.class);
+        comboItemMapper = mock(ProductSkuComboItemMapper.class);
+        ReflectionTestUtils.setField(service, "skuMapper", skuMapper);
+        ReflectionTestUtils.setField(service, "productMapper", productMapper);
+        ReflectionTestUtils.setField(service, "comboItemMapper", comboItemMapper);
+    }
+
+    private ProductSkuComboItem item(Long productId, Long skuId, int qty) {
+        ProductSkuComboItem it = new ProductSkuComboItem();
+        it.setChildProductId(productId);
+        it.setChildSkuId(skuId);
+        it.setQuantity(qty);
+        return it;
+    }
+
+    @Test
+    void create_withComboItems_persistsRowsWithSkuId() {
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Arrays.asList(item(10L, 100L, 2), item(11L, null, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+        when(skuMapper.findByProductId(11L)).thenReturn(Collections.emptyList());
+        doAnswer(inv -> { sku.setId(55L); return 1; }).when(skuMapper).insert(any(ProductSku.class));
+
+        service.create(sku);
+
+        verify(comboItemMapper, times(2)).insert(any(ProductSkuComboItem.class));
+        assertEquals(55L, sku.getComboItems().get(0).getSkuId());
+    }
+
+    @Test
+    void update_emptyComboItems_deletesRows() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(comboItemMapper).delete(any());
+        verify(comboItemMapper, never()).insert(any());
+    }
+
+    @Test
+    void update_comboItems_clearsLinkedProductId() {
+        ProductSku sku = new ProductSku();
+        sku.setId(55L);
+        sku.setComboItems(Collections.singletonList(item(10L, 100L, 1)));
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.emptyList());
+
+        service.update(sku);
+
+        verify(skuMapper).update(eq(null), any());
+    }
+
+    @Test
+    void create_childProductHasEnabledSku_withoutChildSkuId_throws() {
+        ProductSku childSku = new ProductSku();
+        childSku.setId(100L);
+        childSku.setEnabled(1);
+        when(skuMapper.findByProductId(10L)).thenReturn(Collections.singletonList(childSku));
+
+        ProductSku sku = new ProductSku();
+        sku.setProductId(1L);
+        sku.setComboItems(Collections.singletonList(item(10L, null, 1)));
+
+        assertThrows(IllegalArgumentException.class, () -> service.create(sku));
+        verify(skuMapper, never()).insert(any(ProductSku.class));
+    }
+
+    @Test
+    void buildComboSummary_formatsNameAndQuantity() {
+        ProductSkuComboItem a = item(10L, null, 2);
+        a.setChildProductName("商品A");
+        ProductSkuComboItem b = item(11L, null, 1);
+        b.setChildProductName("商品B");
+
+        assertEquals("商品A×2;商品B×1", service.buildComboSummary(Arrays.asList(a, b)));
+    }
+}