Kaynağa Gözat

feat(sku-combo): Task5-7 组合SKU消费点 + 前端多选/组合模式

Task5 后端消费点:
- ProductOrderService.createMultiItem 支持组合SKU(父商品库存不拦截组合行、组成商品/SKU库存校验、手填售价、组合摘要写入skuSpecs)
- 支付成功按组合明细逐项扣减组成SKU/商品库存(deductComboStock),父商品库存不变
- 取消/退款/管理员关闭三处回补改为组合优先(restockComboOrder),不回补父商品
- CartService 组合分支:父商品名+组合摘要+手填售价
- ProductController 商品详情返回 isCombo=true 与 comboItems
- 新增 ProductOrderComboStockTest(4 用例)

Task6 前端 ProductPicker:
- 新增 utils/skuCombo.js(formatComboSummary/normalizeComboPayload)及单测
- 多选模式(selection列+每行SKU下拉+数量)、crossCategory参数、selected预填回勾、confirm校验与数组输出
- 修正 el-pagination 与 footer 误置于 el-table 内的问题

Task7 前端 ProductSkuEditor:
- SKU编辑器三模式(规格变体/关联商品/商品组合),组合模式支持多商品+数量+手填售价
- 组合回显、comboItems载荷下发、列表「规格/组合」列显示组合摘要
E2E Test Bot 1 hafta önce
ebeveyn
işleme
0465cc50d3

+ 105 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderComboStockTest.java

@@ -0,0 +1,105 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
+import com.etotem.cfc.service.InventoryService;
+import com.etotem.cfc.service.ProductOrderService;
+import com.etotem.cfc.service.ProductService;
+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.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 组合 SKU 支付扣减 / 退款回补单测(TDD)。
+ * 关键断言:子 SKU 与子商品分别扣减/回补,父商品库存不参与扣减与回补。
+ */
+class ProductOrderComboStockTest {
+
+    private ProductOrderService orderService;
+    private ProductSkuService skuService;
+    private ProductService productService;
+    private InventoryService inventoryService;
+
+    @BeforeEach
+    void setUp() {
+        orderService = new ProductOrderService();
+        skuService = mock(ProductSkuService.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        ReflectionTestUtils.setField(orderService, "productSkuService", skuService);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+    }
+
+    private ProductSku combo() {
+        ProductSkuComboItem a = new ProductSkuComboItem();
+        a.setChildProductId(10L); a.setChildSkuId(100L); a.setQuantity(2);
+        ProductSkuComboItem b = new ProductSkuComboItem();
+        b.setChildProductId(11L); b.setChildSkuId(null); b.setQuantity(1);
+        ProductSku sku = new ProductSku();
+        sku.setId(9L);
+        sku.setComboItems(Arrays.asList(a, b));
+        return sku;
+    }
+
+    private ProductOrder order() {
+        ProductOrder o = new ProductOrder();
+        o.setId(77L);
+        o.setOrderNo("PO1");
+        o.setSkuId(9L);
+        o.setQuantity(3);
+        return o;
+    }
+
+    @Test
+    void deductComboStock_childSku_decreasesSkuAndRecordsOutbound() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(true);
+        when(productService.decreaseStock(11L, 3)).thenReturn(true);
+
+        orderService.deductComboStock(combo(), order(), "PO1");
+
+        verify(skuService).decreaseStock(100L, 6);
+        verify(inventoryService).recordOutbound(10L, 100L, 6, "order", 77L, null, "组合出库:PO1");
+        verify(productService).decreaseStock(11L, 3);
+        verify(inventoryService).recordOutbound(11L, null, 3, "order", 77L, null, "组合出库:PO1");
+    }
+
+    @Test
+    void deductComboStock_insufficient_throws() {
+        when(skuService.decreaseStock(100L, 6)).thenReturn(false);
+
+        assertThrows(RuntimeException.class, () -> orderService.deductComboStock(combo(), order(), "PO1"));
+    }
+
+    @Test
+    void restockComboOrder_nonCombo_returnsFalse() {
+        when(skuService.getById(9L)).thenReturn(new ProductSku());
+        when(skuService.hasComboItems(any())).thenReturn(false);
+
+        assertFalse(orderService.restockComboOrder(order()));
+        verify(productService, never()).increaseStock(anyLong(), anyInt());
+    }
+
+    @Test
+    void restockComboOrder_combo_restocksChildrenOnly() {
+        ProductSku sku = combo();
+        when(skuService.getById(9L)).thenReturn(sku);
+        when(skuService.hasComboItems(sku)).thenReturn(true);
+
+        assertTrue(orderService.restockComboOrder(order()));
+
+        verify(skuService).increaseStock(100L, 6);
+        verify(inventoryService).recordRefundInbound(10L, 100L, 6, "order", 77L, null, "组合退款入库:PO1");
+        verify(productService).increaseStock(11L, 3);
+        // 父商品(order.productId)不得回补:本用例未设置 productId,仍断言未调用
+        verify(productService, never()).increaseStock(null, 3);
+    }
+}

+ 25 - 0
cfc-web/src/__tests__/utils/skuCombo.test.js

@@ -0,0 +1,25 @@
+import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'
+
+describe('skuCombo utils', () => {
+  test('formatComboSummary empty', () => {
+    expect(formatComboSummary([])).toBe('')
+    expect(formatComboSummary(null)).toBe('')
+  })
+
+  test('formatComboSummary names and quantities', () => {
+    expect(formatComboSummary([
+      { childProductName: '商品A', quantity: 2 },
+      { childProductName: '商品B', quantity: 1 }
+    ])).toBe('商品A×2;商品B×1')
+  })
+
+  test('formatComboSummary over max', () => {
+    const items = [1, 2, 3, 4].map(i => ({ childProductName: '商品' + i, quantity: 1 }))
+    expect(formatComboSummary(items, 3)).toBe('商品1×1;商品2×1;商品3×1 等4项')
+  })
+
+  test('normalizeComboPayload defaults', () => {
+    expect(normalizeComboPayload([{ id: 7, childSkuId: 8 }]))
+      .toEqual([{ childProductId: 7, childSkuId: 8, quantity: 1 }])
+  })
+})

+ 29 - 0
cfc-web/src/utils/skuCombo.js

@@ -0,0 +1,29 @@
+/**
+ * SKU 组合工具函数(管理端商品选择器 / SKU 编辑器共用)
+ */
+
+/**
+ * 组合摘要:'商品A×2;商品B×1',超出 max 项追加 ' 等N项'。
+ * items 为空/缺失时返回空串。
+ */
+export function formatComboSummary(items, max = 3) {
+  if (!items || !items.length) return ''
+  const shown = items.slice(0, max).map(it => {
+    const name = it.childProductName || it.name || ('商品#' + (it.childProductId || it.productId))
+    const qty = it.quantity == null ? 1 : it.quantity
+    return name + '×' + qty
+  })
+  return shown.join(';') + (items.length > max ? ' 等' + items.length + '项' : '')
+}
+
+/**
+ * 组合保存载荷:归一化为 [{ childProductId, childSkuId, quantity }]。
+ * childSkuId 为空时为 null(按互品扣减)。
+ */
+export function normalizeComboPayload(items) {
+  return (items || []).map(it => ({
+    childProductId: it.childProductId || it.id,
+    childSkuId: it.childSkuId || null,
+    quantity: it.quantity == null ? 1 : it.quantity
+  }))
+}