|
@@ -0,0 +1,1312 @@
|
|
|
|
|
+# SKU 商品组合 Implementation Plan
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** 让商品 SKU 的「关联商品」升级为「商品组合」——一个 SKU 关联多个商品(各自数量、有 SKU 的商品必须选具体 SKU),手填组合售价,整体售卖,支付时按组成商品扣库存、退款时对称回补。
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** 新增子表 `product_sku_combo_items` 存组合明细;`ProductSku.comboItems` 为非表字段,由 Service 统一回填;判定优先级固定为 comboItems → linkedProductId → legacy specs。`product_orders` 增 `sku_id`,支付/退款据此识别组合订单,扣减/回补严格对称(child_sku_id 优先,否则 child_product_id)。
|
|
|
|
|
+
|
|
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8.0.34(JUnit 5 + Mockito);Vue 2 + Element UI + Jest。
|
|
|
|
|
+
|
|
|
|
|
+**Spec:** `docs/superpowers/specs/2026-10-31-sku-combo-design.md`
|
|
|
|
|
+
|
|
|
|
|
+## Global Constraints
|
|
|
|
|
+
|
|
|
|
|
+- 所有代码改动在远程 `/app/cfc`(SSH `root@111.228.6.214`)执行;本地 `/root/cfc` 只是壳。
|
|
|
|
|
+- 分支 `cfclub`;每任务完成即提交(`git fetch origin && git pull --rebase origin cfclub` 后提交)。
|
|
|
|
|
+- 迁移编号固定 **432**,并同步 `cfc-backend/src/main/resources/schema.sql`。
|
|
|
|
|
+- 不改动 `product_bundle_items`(套餐)逻辑。
|
|
|
|
|
+- 不改动现有非组合、非 linked、非 bundle 订单的任何路径。
|
|
|
|
|
+- 组合订单:**不扣父商品库存**;取消/退款/后台关闭 3 处必须回补组成商品。
|
|
|
|
|
+- 组成商品有启用 SKU 时,`child_sku_id` 必填;否则拒绝保存。
|
|
|
|
|
+- `ProductSku.update` 清除 `linked_product_id` 必须用 `LambdaUpdateWrapper.set(..., null)`(MyBatis-Plus `updateById` 忽略 null)。
|
|
|
|
|
+- 后端编译:`cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`。
|
|
|
|
|
+- 后端单测:`mvn -q test -Dtest=<Class>`。
|
|
|
|
|
+- 前端测试:`cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`。
|
|
|
|
|
+- 部署严格按 `AGENTS.md`:备份 → 部署 → 健康检查;禁止未确认改动 `/app/pro/cfc`。
|
|
|
|
|
+
|
|
|
|
|
+## Review Focus
|
|
|
|
|
+
|
|
|
|
|
+1. **不双扣**:组合订单支付成功时跳过父商品扣减,只扣组合明细。
|
|
|
|
|
+2. **对称回补**:`cancel` / `refund` / `adminClose` 3 处都走组合回补,且不补父商品。
|
|
|
|
|
+3. **旧路径零回归**:`linked_product_id` SKU 与 legacy SKU 的加购、下单、详情、退款行为不变。
|
|
|
|
|
+4. **有 SKU 必选 SKU**:组成商品存在启用 SKU 时 `childSkuId` 必须非空。
|
|
|
|
|
+5. **无可用 SKU 的商品不可选**:某商品的启用 SKU 全部是组合 SKU 时,不得被当作「无 SKU 商品」按 `product.stock` 扣减。
|
|
|
|
|
+6. **父商品库存不拦截组合下单**:`createMultiItem` 的父商品 `stock` 校验必须对组合行跳过。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 1: 迁移 432 + schema.sql
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(`runMigrations()` 末尾 + 新增 `migrateSkuCombo()`)
|
|
|
|
|
+- Modify: `cfc-backend/src/main/resources/schema.sql`(`product_orders` 加 `sku_id`;`product_bundle_items` 后新增组合表)
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Produces: 表 `product_sku_combo_items(id, sku_id, child_product_id, child_sku_id, quantity, created_at)`;列 `product_orders.sku_id`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 `runMigrations()` 末尾(`migrateFamilyNameBackfill();` 之后)追加调用**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 迁移432: SKU商品组合明细表 + 订单记录下单SKU(组合SKU识别)
|
|
|
|
|
+ migrateSkuCombo();
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 在 `createCouponGiftTransferTable()` 之后新增迁移方法(幂等;参照迁移 428 写法)**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // ==================== 迁移432:SKU 商品组合 ====================
|
|
|
|
|
+
|
|
|
|
|
+ void migrateSkuCombo() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_sku_combo_items` ("
|
|
|
|
|
+ + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
|
|
|
|
|
+ + "`sku_id` BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)', "
|
|
|
|
|
+ + "`child_product_id` BIGINT NOT NULL COMMENT '组成商品ID', "
|
|
|
|
|
+ + "`child_sku_id` BIGINT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)', "
|
|
|
|
|
+ + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量', "
|
|
|
|
|
+ + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
|
|
+ + "INDEX `idx_sku` (`sku_id`), "
|
|
|
|
|
+ + "INDEX `idx_child_product` (`child_product_id`) "
|
|
|
|
|
+ + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细'");
|
|
|
|
|
+ log.info("迁移432: 已创建 product_sku_combo_items 表");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("迁移432: 创建 product_sku_combo_items 表失败(可能已存在): {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ Integer colExists = jdbcTemplate.queryForObject(
|
|
|
|
|
+ "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'product_orders' AND COLUMN_NAME = 'sku_id'",
|
|
|
|
|
+ Integer.class);
|
|
|
|
|
+ if (colExists == null || colExists == 0) {
|
|
|
|
|
+ jdbcTemplate.execute("ALTER TABLE product_orders ADD COLUMN sku_id BIGINT NULL COMMENT '下单SKU ID(组合SKU识别用)' AFTER product_type");
|
|
|
|
|
+ log.info("迁移432: 已为 product_orders 增加 sku_id 列");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.info("迁移432: product_orders.sku_id 列已存在,跳过");
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("迁移432: 检查/添加 product_orders.sku_id 列失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `schema.sql` 中 `product_orders` 的 `product_type` 后加列**
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+ sku_id BIGINT COMMENT '下单SKU ID(组合SKU识别用)',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `schema.sql` 中 `product_bundle_items` 之后新增表**
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 商品SKU组合明细表(组合SKU = 多个商品/子SKU + 数量,整体手填售价售卖)
|
|
|
|
|
+CREATE TABLE IF NOT EXISTS product_sku_combo_items (
|
|
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
|
|
+ sku_id BIGINT NOT NULL COMMENT '组合SKU ID(product_skus.id)',
|
|
|
|
|
+ child_product_id BIGINT NOT NULL COMMENT '组成商品ID',
|
|
|
|
|
+ child_sku_id BIGINT DEFAULT NULL COMMENT '组成商品SKU ID(组成商品有SKU时必填)',
|
|
|
|
|
+ quantity INT NOT NULL DEFAULT 1 COMMENT '该组成商品数量',
|
|
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
+ INDEX idx_sku (sku_id),
|
|
|
|
|
+ INDEX idx_child_product (child_product_id)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品SKU组合明细';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
|
|
|
|
|
+Expected: BUILD SUCCESS(无输出错误)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
|
|
+git commit -m "feat(backend): 迁移432 SKU商品组合明细表与订单sku_id"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 2: 实体 / Mapper / DTO
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java`
|
|
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java`
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java`
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: 表结构(Task 1)。
|
|
|
|
|
+- Produces: `ProductSkuComboItem`(字段 `id, skuId, childProductId, childSkuId, quantity, createdAt` + 非表字段 `childProductName, childProductImage, childProductPrice, childSkuSpecs`);`ProductSku.getComboItems()/setComboItems(List)`;`ProductOrder.getSkuId()/setSkuId(Long)`;`ProductSkuDTO.getComboItems()` 与 `getLinkedProductId()`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建实体 `ProductSkuComboItem.java`**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableField;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.Serializable;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+
|
|
|
|
|
+@Data
|
|
|
|
|
+@TableName("product_sku_combo_items")
|
|
|
|
|
+public class ProductSkuComboItem implements Serializable {
|
|
|
|
|
+
|
|
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
|
|
+ private Long id;
|
|
|
|
|
+
|
|
|
|
|
+ private Long skuId;
|
|
|
|
|
+ private Long childProductId;
|
|
|
|
|
+ private Long childSkuId;
|
|
|
|
|
+ private Integer quantity;
|
|
|
|
|
+ private Date createdAt;
|
|
|
|
|
+
|
|
|
|
|
+ @TableField(exist = false)
|
|
|
|
|
+ private String childProductName;
|
|
|
|
|
+ @TableField(exist = false)
|
|
|
|
|
+ private String childProductImage;
|
|
|
|
|
+ @TableField(exist = false)
|
|
|
|
|
+ private Integer childProductPrice;
|
|
|
|
|
+ @TableField(exist = false)
|
|
|
|
|
+ private String childSkuSpecs;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 创建 Mapper**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
|
|
+import com.etotem.cfc.entity.ProductSkuComboItem;
|
|
|
|
|
+
|
|
|
|
|
+public interface ProductSkuComboItemMapper extends BaseMapper<ProductSkuComboItem> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `ProductSku.java` 末尾(`updateTime` 后)增加**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
|
|
|
|
+ private java.util.List<ProductSkuComboItem> comboItems;
|
|
|
|
|
+
|
|
|
|
|
+ @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
|
|
|
|
+ private String linkedProductName;
|
|
|
|
|
+ @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
|
|
|
|
+ private Integer linkedProductPrice;
|
|
|
|
|
+ @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
|
|
|
|
+ private String linkedProductImage;
|
|
|
|
|
+ @com.baomidou.mybatisplus.annotation.TableField(exist = false)
|
|
|
|
|
+ private Integer linkedProductStock;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `ProductOrder.java` 的 `productType` 后增加**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ private Long skuId;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: `ProductSkuDTO.java` 增加字段,并在 `from()` 的 `return dto;` 前拷贝**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ private Long linkedProductId;
|
|
|
|
|
+ private String linkedProductName;
|
|
|
|
|
+ private Integer linkedProductPrice;
|
|
|
|
|
+ private String linkedProductImage;
|
|
|
|
|
+ private Integer linkedProductStock;
|
|
|
|
|
+ private java.util.List<com.etotem.cfc.entity.ProductSkuComboItem> comboItems;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ dto.setLinkedProductId(sku.getLinkedProductId());
|
|
|
|
|
+ dto.setLinkedProductName(sku.getLinkedProductName());
|
|
|
|
|
+ dto.setLinkedProductPrice(sku.getLinkedProductPrice());
|
|
|
|
|
+ dto.setLinkedProductImage(sku.getLinkedProductImage());
|
|
|
|
|
+ dto.setLinkedProductStock(sku.getLinkedProductStock());
|
|
|
|
|
+ dto.setComboItems(sku.getComboItems());
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 编译 + 提交**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java
|
|
|
|
|
+git commit -m "feat(backend): 组合SKU实体/DTO与Mapper"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 3: ProductSkuService 组合读写 + 单测(TDD)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java`
|
|
|
|
|
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `ProductSkuComboItemMapper`、`ProductSku.getComboItems()`、`ProductMapper.selectBatchIds`、`ProductSkuMapper.selectBatchIds`。
|
|
|
|
|
+- Produces:
|
|
|
|
|
+ - `boolean hasComboItems(ProductSku sku)`
|
|
|
|
|
+ - `List<ProductSkuComboItem> listComboItems(Long skuId)`
|
|
|
|
|
+ - `void fillComboItems(List<ProductSku> skus)`
|
|
|
|
|
+ - `String buildComboSummary(List<ProductSkuComboItem> items)`(格式 `商品A×2;商品B×1`)
|
|
|
|
|
+ - `create/update` 支持 `comboItems`(null=不改,空数组=清除,非空=整表替换)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写失败测试 `ProductSkuComboServiceTest`**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+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 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() {
|
|
|
|
|
+ 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)));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
|
|
|
|
|
+Expected: 编译失败 / 方法不存在(`hasComboItems` 等未定义)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 Service**
|
|
|
|
|
+
|
|
|
|
|
+注入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProductSkuComboItemMapper comboItemMapper;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`create`:在 `skuMapper.insert(sku);` 前调用 `validateComboItems(sku.getComboItems());`,insert 之后调用 `saveComboItems(sku.getId(), sku.getComboItems());`。
|
|
|
|
|
+
|
|
|
|
|
+`update`:入口 `validateComboItems(sku.getComboItems());`;随后:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
|
|
|
|
|
+ sku.setLinkedProductId(null);
|
|
|
|
|
+ }
|
|
|
|
|
+ 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));
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`delete`:先
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
|
|
|
|
|
+ .eq(ProductSkuComboItem::getSkuId, id));
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`getById`:查询后 `fillComboItems(java.util.Collections.singletonList(sku));`;`listByProductId`/`listByProductIdAll`:查询后 `fillComboItems(list);`。
|
|
|
|
|
+
|
|
|
|
|
+私有/公开方法按 Task 3 Interfaces 的签名实现:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`fillComboItems`(批量回填子商品名/图/价、子 SKU 规格):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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();
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductSkuComboServiceTest`
|
|
|
|
|
+Expected: Tests run: 5, Failures: 0, Errors: 0
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java
|
|
|
|
|
+git commit -m "feat(backend): 组合SKU服务层读写与校验"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 4: 管理端选择器接口(跨分类 + 子 SKU + hasSkus)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `ProductSkuMapper.findByProductIdAll`、`ProductSkuComboItemMapper`。
|
|
|
|
|
+- Produces: `POST /api/admin/product/linked/list` 新增参数 `crossCategory`;`data.list[]` 在 `crossCategory=true` 时增加 `skus`(已启用且非组合 SKU)与 `hasSkus`(该商品是否存在任何启用 SKU)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 注入依赖**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private com.etotem.cfc.mapper.ProductSkuMapper productSkuMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private com.etotem.cfc.mapper.ProductSkuComboItemMapper comboItemMapper;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 参数解析与查询条件**
|
|
|
|
|
+
|
|
|
|
|
+在方法开头追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ boolean crossCategory = Boolean.parseBoolean(String.valueOf(params.getOrDefault("crossCategory", "false")));
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+把现有 wrapper 条件替换为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
|
|
|
|
|
+ wrapper.eq(Product::getStatus, "on_shelf");
|
|
|
|
|
+ if (!crossCategory) {
|
|
|
|
|
+ if (categoryId == null && productId != null) {
|
|
|
|
|
+ Product product = productMapper.selectById(productId);
|
|
|
|
|
+ if (product != null) categoryId = product.getCategoryId();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (categoryId != null) wrapper.eq(Product::getCategoryId, categoryId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (productId != null) wrapper.ne(Product::getId, productId);
|
|
|
|
|
+ wrapper.ne(Product::getProductType, "bundle");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (keyword != null && !keyword.trim().isEmpty()) wrapper.like(Product::getName, keyword.trim());
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 记录转 Map,并在跨分类时附加 `skus` / `hasSkus`**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ java.util.List<java.util.Map<String, Object>> list = new java.util.ArrayList<>();
|
|
|
|
|
+ for (Product p : pageResult.getRecords()) {
|
|
|
|
|
+ java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
|
|
|
|
|
+ m.put("id", p.getId());
|
|
|
|
|
+ m.put("name", p.getName());
|
|
|
|
|
+ m.put("coverImage", p.getCoverImage());
|
|
|
|
|
+ m.put("price", p.getPrice());
|
|
|
|
|
+ m.put("stock", p.getStock());
|
|
|
|
|
+ m.put("categoryId", p.getCategoryId());
|
|
|
|
|
+ m.put("productType", p.getProductType());
|
|
|
|
|
+ if (crossCategory) {
|
|
|
|
|
+ java.util.List<ProductSku> allSkus = productSkuMapper.findByProductIdAll(p.getId()).stream()
|
|
|
|
|
+ .filter(s -> s.getEnabled() != null && s.getEnabled() == 1)
|
|
|
|
|
+ .collect(java.util.stream.Collectors.toList());
|
|
|
|
|
+ m.put("hasSkus", !allSkus.isEmpty());
|
|
|
|
|
+ java.util.Set<Long> comboSkuIds = java.util.Collections.emptySet();
|
|
|
|
|
+ if (!allSkus.isEmpty()) {
|
|
|
|
|
+ java.util.List<Long> ids = allSkus.stream().map(ProductSku::getId)
|
|
|
|
|
+ .collect(java.util.stream.Collectors.toList());
|
|
|
|
|
+ comboSkuIds = comboItemMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<ProductSkuComboItem>()
|
|
|
|
|
+ .in(ProductSkuComboItem::getSkuId, ids)).stream()
|
|
|
|
|
+ .map(ProductSkuComboItem::getSkuId)
|
|
|
|
|
+ .collect(java.util.stream.Collectors.toSet());
|
|
|
|
|
+ }
|
|
|
|
|
+ java.util.List<java.util.Map<String, Object>> skuList = new java.util.ArrayList<>();
|
|
|
|
|
+ for (ProductSku s : allSkus) {
|
|
|
|
|
+ if (comboSkuIds.contains(s.getId())) continue;
|
|
|
|
|
+ java.util.Map<String, Object> sm = new java.util.LinkedHashMap<>();
|
|
|
|
|
+ sm.put("id", s.getId());
|
|
|
|
|
+ sm.put("specs", s.getSpecs());
|
|
|
|
|
+ sm.put("price", s.getPrice());
|
|
|
|
|
+ sm.put("stock", s.getStock());
|
|
|
|
|
+ skuList.add(sm);
|
|
|
|
|
+ }
|
|
|
|
|
+ m.put("skus", skuList);
|
|
|
|
|
+ }
|
|
|
|
|
+ list.add(m);
|
|
|
|
|
+ }
|
|
|
|
|
+ result.put("list", list);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+补 import:`com.etotem.cfc.entity.ProductSku`、`com.etotem.cfc.entity.ProductSkuComboItem`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 编译 + 提交**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q -DskipTests compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java
|
|
|
|
|
+git commit -m "feat(backend): 商品选择器支持跨分类与子SKU列表"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 5: 消费点(购物车 / 下单 / 支付扣减 / 退款回补 / 商品详情)+ 单测
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java`(约 350 行「Linked SKU」分支)
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java`(`createMultiItem` 约 740-870;`doHandlePaymentSuccess` 约 1030;回补 3 处约 1266/1714/1932)
|
|
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java`(约 120「Linked SKU」分支前)
|
|
|
|
|
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderComboStockTest.java`(与 `ProductOrderService` 同包,才能访问包级方法)
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `ProductSkuService.hasComboItems/getById/buildComboSummary`、`ProductSkuComboItem`、`ProductOrder.getSkuId()`。
|
|
|
|
|
+- Produces: `ProductOrderService.deductComboStock(ProductSku, ProductOrder, String)`、`ProductOrderService.restockComboOrder(ProductOrder) -> boolean`(包级,供单测直接调用)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写失败测试 `ProductOrderComboStockTest`**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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.*;
|
|
|
|
|
+
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
|
|
|
|
|
+Expected: 编译失败(`deductComboStock` / `restockComboOrder` 不存在)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: CartService 组合分支**
|
|
|
|
|
+
|
|
|
|
|
+在 `if (sku.getLinkedProductId() != null)` 之前插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
|
|
|
|
|
+ if (sku.getPrice() != null) dto.setUnitPrice(sku.getPrice());
|
|
|
|
|
+ dto.setSpecDesc(productSkuService.buildComboSummary(sku.getComboItems()));
|
|
|
|
|
+ } else if (sku.getLinkedProductId() != null) {
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `createMultiItem` 组合支持**
|
|
|
|
|
+
|
|
|
|
|
+循环开头:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ ProductSku itemSku = item.getSkuId() != null ? productSkuService.getById(item.getSkuId()) : null;
|
|
|
|
|
+ boolean comboItem = productSkuService.hasComboItems(itemSku);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+父商品库存校验加 `!comboItem`:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (!comboItem && "physical".equals(product.getProductType())
|
|
|
|
|
+ && product.getStock() != null
|
|
|
|
|
+ && product.getStock() < item.getQuantity()) {
|
|
|
|
|
+ return Result.error("库存不足: " + product.getName());
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+bundle 校验后追加组合库存校验:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (comboItem) {
|
|
|
|
|
+ for (ProductSkuComboItem ci : itemSku.getComboItems()) {
|
|
|
|
|
+ int need = (ci.getQuantity() == null ? 1 : ci.getQuantity()) * item.getQuantity();
|
|
|
|
|
+ if (ci.getChildSkuId() != null) {
|
|
|
|
|
+ if (!productSkuService.hasStock(ci.getChildSkuId(), need)) {
|
|
|
|
|
+ return Result.error("组合商品库存不足: " + ci.getChildProductName());
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Product cp = productMapper.selectById(ci.getChildProductId());
|
|
|
|
|
+ if (cp == null || cp.getStock() == null || cp.getStock() < need) {
|
|
|
|
|
+ return Result.error("组合商品库存不足: "
|
|
|
|
|
+ + (cp == null ? ci.getChildProductId() : cp.getName()));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+SKU 价格分支改为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (itemSku != null) {
|
|
|
|
|
+ if (comboItem) {
|
|
|
|
|
+ if (itemSku.getPrice() != null) unitPrice = itemSku.getPrice();
|
|
|
|
|
+ if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
|
|
|
|
|
+ return Result.error(product.getName() + " 该组合最低需购买" + itemSku.getMinQuantity() + "件");
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (itemSku.getLinkedProductId() != null) {
|
|
|
|
|
+ // ... 原有 linked 逻辑保持不变 ...
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // ... 原有 legacy 逻辑保持不变 ...
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+明细行:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ itemRecord.setSkuSpecs(comboItem
|
|
|
|
|
+ ? productSkuService.buildComboSummary(itemSku.getComboItems())
|
|
|
|
|
+ : item.getSpecOptionIds());
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+订单头(`order.setProductType(...)` 后):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ order.setSkuId(firstItem.getSkuId());
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 支付成功扣减**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ Product paidProduct = productMapper.selectById(order.getProductId());
|
|
|
|
|
+ ProductSku paidSku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
|
|
|
|
|
+ if (productSkuService.hasComboItems(paidSku)) {
|
|
|
|
|
+ deductComboStock(paidSku, order, orderNo);
|
|
|
|
|
+ } else if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
|
|
|
|
|
+ // ... 原有 physical 逻辑保持不变 ...
|
|
|
|
|
+ } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType())) {
|
|
|
|
|
+ // ... 原有 bundle 逻辑保持不变 ...
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+新增方法:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ void deductComboStock(ProductSku combo, ProductOrder order, String orderNo) {
|
|
|
|
|
+ for (ProductSkuComboItem ci : combo.getComboItems()) {
|
|
|
|
|
+ int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
|
|
|
|
|
+ int need = unit * order.getQuantity();
|
|
|
|
|
+ if (ci.getChildSkuId() != null) {
|
|
|
|
|
+ if (!productSkuService.decreaseStock(ci.getChildSkuId(), need)) {
|
|
|
|
|
+ throw new RuntimeException("组合商品库存不足: skuId=" + ci.getChildSkuId());
|
|
|
|
|
+ }
|
|
|
|
|
+ inventoryService.recordOutbound(ci.getChildProductId(), ci.getChildSkuId(), need,
|
|
|
|
|
+ "order", order.getId(), null, "组合出库:" + orderNo);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (!productService.decreaseStock(ci.getChildProductId(), need)) {
|
|
|
|
|
+ throw new RuntimeException("组合商品库存不足: productId=" + ci.getChildProductId());
|
|
|
|
|
+ }
|
|
|
|
|
+ inventoryService.recordOutbound(ci.getChildProductId(), null, need,
|
|
|
|
|
+ "order", order.getId(), null, "组合出库:" + orderNo);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 3 处回补改为组合优先**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ boolean restockComboOrder(ProductOrder order) {
|
|
|
|
|
+ ProductSku sku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
|
|
|
|
|
+ if (!productSkuService.hasComboItems(sku)) return false;
|
|
|
|
|
+ for (ProductSkuComboItem ci : sku.getComboItems()) {
|
|
|
|
|
+ int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
|
|
|
|
|
+ int need = unit * order.getQuantity();
|
|
|
|
|
+ if (ci.getChildSkuId() != null) {
|
|
|
|
|
+ productSkuService.increaseStock(ci.getChildSkuId(), need);
|
|
|
|
|
+ inventoryService.recordRefundInbound(ci.getChildProductId(), ci.getChildSkuId(), need,
|
|
|
|
|
+ "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ productService.increaseStock(ci.getChildProductId(), need);
|
|
|
|
|
+ inventoryService.recordRefundInbound(ci.getChildProductId(), null, need,
|
|
|
|
|
+ "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+三处原逻辑包一层:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (!restockComboOrder(order)) {
|
|
|
|
|
+ productService.increaseStock(order.getProductId(), order.getQuantity());
|
|
|
|
|
+ inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
|
|
|
|
|
+ "order", order.getId(), null, "<原 remark>");
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+(`<原 remark>` 分别沿用 `"取消订单:" + orderNo`、退款处、关闭处原文案。)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: ProductController 商品详情增加组合选项**
|
|
|
|
|
+
|
|
|
|
|
+在 `boolean isLinked = sku.getLinkedProductId() != null;` 之前插入组合分支(`skus` 已由 Service 回填):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
|
|
|
|
|
+ String optionName = (sku.getLabel() != null && !sku.getLabel().trim().isEmpty())
|
|
|
|
|
+ ? sku.getLabel() : "商品组合";
|
|
|
|
|
+ String groupName = "商品组合";
|
|
|
|
|
+ if (!nameToGroupId.containsKey(groupName)) {
|
|
|
|
|
+ nameToGroupId.put(groupName, groupId++);
|
|
|
|
|
+ Map<String, Object> grp = new HashMap<>();
|
|
|
|
|
+ grp.put("groupId", nameToGroupId.get(groupName));
|
|
|
|
|
+ grp.put("groupName", groupName);
|
|
|
|
|
+ grp.put("options", new ArrayList<Map<String, Object>>());
|
|
|
|
|
+ groups.add(grp);
|
|
|
|
|
+ }
|
|
|
|
|
+ List<Map<String, Object>> comboList = new ArrayList<>();
|
|
|
|
|
+ for (ProductSkuComboItem ci : sku.getComboItems()) {
|
|
|
|
|
+ Map<String, Object> cm = new HashMap<>();
|
|
|
|
|
+ cm.put("productId", ci.getChildProductId());
|
|
|
|
|
+ cm.put("skuId", ci.getChildSkuId());
|
|
|
|
|
+ cm.put("name", ci.getChildProductName());
|
|
|
|
|
+ cm.put("image", ci.getChildProductImage());
|
|
|
|
|
+ cm.put("price", ci.getChildProductPrice());
|
|
|
|
|
+ cm.put("quantity", ci.getQuantity());
|
|
|
|
|
+ comboList.add(cm);
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> option = new HashMap<>();
|
|
|
|
|
+ option.put("id", sku.getId());
|
|
|
|
|
+ option.put("groupId", nameToGroupId.get(groupName));
|
|
|
|
|
+ option.put("name", optionName);
|
|
|
|
|
+ option.put("skuId", sku.getId());
|
|
|
|
|
+ option.put("isCombo", true);
|
|
|
|
|
+ option.put("price", sku.getPrice());
|
|
|
|
|
+ option.put("comboItems", comboList);
|
|
|
|
|
+ for (Map<String, Object> g : groups) {
|
|
|
|
|
+ if (g.get("groupId").equals(nameToGroupId.get(groupName))) {
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
|
|
|
|
|
+ opts.add(option);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q test -Dtest=ProductOrderComboStockTest`
|
|
|
|
|
+Expected: Tests run: 4, Failures: 0, Errors: 0
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 9: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/test/java/com/etotem/cfc/unit/ProductOrderComboStockTest.java
|
|
|
|
|
+git commit -m "feat(backend): 组合SKU购物车/下单/扣库/回补与详情展示"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 6: 前端 ProductPicker 多选 + 数量 + SKU + 跨分类(TDD 工具函数)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-web/src/utils/skuCombo.js`
|
|
|
|
|
+- Create: `cfc-web/src/__tests__/utils/skuCombo.test.js`
|
|
|
|
|
+- Rewrite: `cfc-web/src/views/admin/components/ProductPicker.vue`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `linkedProductList(params)`(`@/api/admin`),参数新增 `crossCategory`;响应 `list[].skus` / `hasSkus`(Task 4)。
|
|
|
|
|
+- Produces:
|
|
|
|
|
+ - `formatComboSummary(items, max=3) -> string`
|
|
|
|
|
+ - `normalizeComboPayload(items) -> [{childProductId, childSkuId, quantity}]`
|
|
|
|
|
+ - 组件 props `{ productId, crossCategory, multiple, selected }`;events `selected`(单选对象)、`confirm`(多选数组)。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 先写工具函数测试**
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+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 }])
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
|
|
|
|
|
+Expected: FAIL(Cannot find module '@/utils/skuCombo')
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 实现 `src/utils/skuCombo.js`**
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+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 + '项' : '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function normalizeComboPayload(items) {
|
|
|
|
|
+ return (items || []).map(it => ({
|
|
|
|
|
+ childProductId: it.childProductId || it.id,
|
|
|
|
|
+ childSkuId: it.childSkuId || null,
|
|
|
|
|
+ quantity: it.quantity == null ? 1 : it.quantity
|
|
|
|
|
+ }))
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__/utils/skuCombo.test.js --no-coverage`
|
|
|
|
|
+Expected: PASS(4 个用例)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 重写 `ProductPicker.vue`**
|
|
|
|
|
+
|
|
|
|
|
+要求:
|
|
|
|
|
+- 模板结构修正:`el-pagination` 与 footer 放在 `el-table` 之外。
|
|
|
|
|
+- 单选模式(`multiple=false`)保留 radio + `@selected` 旧行为。
|
|
|
|
|
+- 多选模式(`multiple=true`):`type="selection"`,每行 SKU 下拉与数量;`row.hasSkus && !row.skus.length` 时禁用该行(`:selectable` 返回 false)并显示「无可用SKU」。
|
|
|
|
|
+- `open()` 用 `selected` 预填 `picked`,`load()` 后对已选行 `toggleRowSelection(row, true)`。
|
|
|
|
|
+- `confirm()` 校验:多选时每个已选且有 `skus` 的行必须有 `childSkuId`;输出 `[{id, childProductId, childSkuId, name, price, coverImage, stock, quantity}]`,`$emit('confirm', result)`。
|
|
|
|
|
+- 组件根加 `ref="table"` 到 `el-table`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 运行前端相关测试 + 构建**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage`
|
|
|
|
|
+Expected: 全部 PASS
|
|
|
|
|
+
|
|
|
|
|
+Run: `npm run build`
|
|
|
|
|
+Expected: 构建成功
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-web/src/utils/skuCombo.js cfc-web/src/__tests__/utils/skuCombo.test.js cfc-web/src/views/admin/components/ProductPicker.vue
|
|
|
|
|
+git commit -m "feat(web): 商品选择器支持多选/数量/SKU/跨分类"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 7: 前端 ProductSkuEditor 组合模式
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`
|
|
|
|
|
+
|
|
|
|
|
+**Interfaces:**
|
|
|
|
|
+- Consumes: `formatComboSummary`、`normalizeComboPayload`(Task 6);`ProductPicker` 的 `multiple/crossCategory/selected` 与 `@confirm`;未选 SKU 商品的 `hasSkus`。
|
|
|
|
|
+- Produces: SKU create/update payload 增加 `comboItems`(`[]` 表示非组合);组合模式 payload 为 `{productId, comboItems, price, stock, label, enabled, linkedProductId: null}`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 三模式改造**
|
|
|
|
|
+
|
|
|
|
|
+`data`:`isLinkedMode` → `skuMode: 'spec'`,新增 `comboItems: []`。
|
|
|
|
|
+
|
|
|
|
|
+模板 radio:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <el-radio-group v-model="skuMode" size="small" class="mb-16">
|
|
|
|
|
+ <el-radio-button label="spec">规格变体</el-radio-button>
|
|
|
|
|
+ <el-radio-button label="linked">关联商品</el-radio-button>
|
|
|
|
|
+ <el-radio-button label="combo">商品组合</el-radio-button>
|
|
|
|
|
+ </el-radio-group>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+把 `v-if="isLinkedMode"` 改为 `v-if="skuMode === 'linked'"`,原有规格区 `v-else` 改为 `v-else-if="skuMode === 'spec'"`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 新增组合区模板**
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <template v-else-if="skuMode === 'combo'">
|
|
|
|
|
+ <el-form-item label="显示标签">
|
|
|
|
|
+ <el-input v-model="skuForm.label" placeholder="默认显示“商品组合”" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="组合商品">
|
|
|
|
|
+ <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openProductPicker">选择商品</el-button>
|
|
|
|
|
+ <div v-if="comboItems.length" class="combo-list">
|
|
|
|
|
+ <div v-for="(it, idx) in comboItems" :key="(it.childProductId || it.id) + '-' + (it.childSkuId || '')" class="combo-row">
|
|
|
|
|
+ <img v-if="it.coverImage" :src="it.coverImage" class="combo-thumb" />
|
|
|
|
|
+ <span v-else class="combo-thumb combo-thumb-placeholder"></span>
|
|
|
|
|
+ <div class="combo-main">
|
|
|
|
|
+ <div>{{ it.name }}</div>
|
|
|
|
|
+ <div class="combo-meta">
|
|
|
|
|
+ <span v-if="it.childSkuSpecs">规格:{{ parseSpecs(it.childSkuSpecs) }}</span>
|
|
|
|
|
+ <span>单价 ¥{{ ((it.price || 0)/100).toFixed(2) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <el-input-number v-model="it.quantity" :min="1" :precision="0" size="mini" class="combo-qty" />
|
|
|
|
|
+ <el-button type="text" class="combo-remove" @click="comboItems.splice(idx, 1)">删除</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else class="combo-empty">尚未添加组合商品</div>
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="组合售价(元)" prop="price">
|
|
|
|
|
+ <el-input-number v-model="skuForm.price" :min="0" :precision="2" class="full-width" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ <el-form-item label="展示库存">
|
|
|
|
|
+ <el-input-number v-model="skuForm.stock" :min="0" :precision="0" class="full-width" />
|
|
|
|
|
+ </el-form-item>
|
|
|
|
|
+ </template>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: ProductPicker 挂载更新**
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <ProductPicker
|
|
|
|
|
+ ref="productPicker"
|
|
|
|
|
+ :product-id="productId"
|
|
|
|
|
+ :cross-category="skuMode === 'combo'"
|
|
|
|
|
+ :multiple="skuMode === 'combo'"
|
|
|
|
|
+ :selected="comboItems.map(i => ({ childProductId: i.childProductId || i.id, childSkuId: i.childSkuId, quantity: i.quantity }))"
|
|
|
|
|
+ @selected="onProductSelected"
|
|
|
|
|
+ @confirm="onComboConfirm"
|
|
|
|
|
+ />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 回显与确认**
|
|
|
|
|
+
|
|
|
|
|
+`showDialog(sku)`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ const comboRaw = sku.comboItems || []
|
|
|
|
|
+ this.comboItems = comboRaw.map(it => ({
|
|
|
|
|
+ childProductId: it.childProductId,
|
|
|
|
|
+ childSkuId: it.childSkuId,
|
|
|
|
|
+ quantity: it.quantity || 1,
|
|
|
|
|
+ name: it.childProductName || ('商品#' + it.childProductId),
|
|
|
|
|
+ coverImage: it.childProductImage,
|
|
|
|
|
+ price: it.childProductPrice || 0,
|
|
|
|
|
+ childSkuSpecs: it.childSkuSpecs
|
|
|
|
|
+ }))
|
|
|
|
|
+ this.skuMode = comboRaw.length ? 'combo' : (sku.linkedProductId != null ? 'linked' : 'spec')
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`onComboConfirm(items)`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ onComboConfirm(items) {
|
|
|
|
|
+ this.comboItems = (items || []).map(it => ({
|
|
|
|
|
+ childProductId: it.childProductId || it.id,
|
|
|
|
|
+ childSkuId: it.childSkuId || null,
|
|
|
|
|
+ quantity: it.quantity || 1,
|
|
|
|
|
+ name: it.name,
|
|
|
|
|
+ coverImage: it.coverImage,
|
|
|
|
|
+ price: it.price || 0,
|
|
|
|
|
+ childSkuSpecs: it.childSkuSpecs || null
|
|
|
|
|
+ }))
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: `handleSave` 增加组合分支,并给另外两分支补 `comboItems`**
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ if (this.skuMode === 'combo') {
|
|
|
|
|
+ if (!this.comboItems.length) { this.$message.warning('请至少添加一个组合商品'); return }
|
|
|
|
|
+ if (!(this.skuForm.price > 0)) { this.$message.warning('请填写组合售价'); return }
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ productId: this.productId,
|
|
|
|
|
+ comboItems: normalizeComboPayload(this.comboItems),
|
|
|
|
|
+ price: Math.round(parseFloat(this.skuForm.price) * 100),
|
|
|
|
|
+ stock: this.skuForm.stock || 0,
|
|
|
|
|
+ label: this.skuForm.label || null,
|
|
|
|
|
+ enabled: this.skuForm.enabled,
|
|
|
|
|
+ linkedProductId: null
|
|
|
|
|
+ }
|
|
|
|
|
+ // 与 linked 分支相同的「新建暂存 / update / create + 刷新列表」流程
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- linked 分支 payload 追加 `comboItems: []`;
|
|
|
|
|
+- spec 分支 payload 追加 `comboItems: []`、`linkedProductId: null`;
|
|
|
|
|
+- `resetForm()` 重置 `skuMode='spec'`、`comboItems=[]`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: 列表规格列显示组合摘要**
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <el-table-column label="规格/组合" min-width="160">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <span v-if="row.comboItems && row.comboItems.length">组合:{{ formatComboSummary(row.comboItems) }}</span>
|
|
|
|
|
+ <span v-else>{{ parseSpecs(row.specs) }}</span>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </el-table-column>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并 `import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'`;补充组合相关样式。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: 测试 + 构建**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
|
|
|
|
|
+Expected: 测试全 PASS,构建成功
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git add cfc-web/src/views/admin/components/ProductSkuEditor.vue
|
|
|
|
|
+git commit -m "feat(web): SKU编辑器支持商品组合(多商品+数量+手填售价)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 8: 全量回归
|
|
|
|
|
+
|
|
|
|
|
+**Files:** 无新增;运行全部测试与手工回归清单。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 后端全量单测**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q test`
|
|
|
|
|
+Expected: BUILD SUCCESS,0 failures
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 前端全量测试 + 构建**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest src/__tests__ --no-coverage && npm run build`
|
|
|
|
|
+Expected: 全 PASS,构建成功
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 手工回归(本地/预发,逐条记录结果)**
|
|
|
|
|
+
|
|
|
|
|
+- 旧 linked SKU:加购显示关联商品名/价;下单金额 = 关联商品价。
|
|
|
|
|
+- legacy 规格 SKU:加购/下单金额 = SKU 价。
|
|
|
|
|
+- 组合 SKU:加购显示父商品 + 组合摘要 + 手填价;下单总额 = 手填价 × 数量。
|
|
|
|
|
+- 组合 SKU:支付成功(可用 testMode)后各组成 SKU/商品按 `quantity × 订单数量` 扣减,**父商品库存不变**。
|
|
|
|
|
+- 组合 SKU:取消/退款后各组成库存回补,父商品库存不变。
|
|
|
|
|
+- 商品详情:组合 SKU 返回 `isCombo=true` 与 `comboItems`。
|
|
|
|
|
+- 管理端:有 SKU 的商品作为组成项时强制选 SKU;无可用 SKU 的商品不可勾选。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 提交回归记录(如无代码变更可跳过)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git status
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 9: 部署(需用户 go/no-go)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** 无代码变更;运行部署脚本。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 同步与备份**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+ssh root@111.228.6.214
|
|
|
|
|
+cd /app/cfc && git status && git fetch origin && git pull --rebase origin cfclub
|
|
|
|
|
+tar -czf /app/pro/cfc_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /app pro/cfc
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 后端部署**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc/cfc-backend
|
|
|
|
|
+mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true
|
|
|
|
|
+cp target/cfc-backend-1.0.0.jar /app/pro/cfc/backend/
|
|
|
|
|
+systemctl restart cfc-backend
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 前端部署**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc/cfc-web && npm run build
|
|
|
|
|
+rsync -a --delete dist/ /app/pro/cfc/frontend/
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 验证**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+systemctl status cfc-backend --no-pager | head -5
|
|
|
|
|
+ss -tlnp | grep 9082
|
|
|
|
|
+curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:9082/api/cart/list
|
|
|
|
|
+curl -s -o /dev/null -w "%{http_code}\n" -k https://cfc.etotem.com.cn/admin/
|
|
|
|
|
+grep "迁移432" /app/pro/cfc/backend/cfc-backend.log | tail -5
|
|
|
|
|
+curl -s -X POST http://localhost:9082/api/admin/product/linked/list -H 'Content-Type: application/json' -d '{"crossCategory":true,"page":1,"pageSize":3}'
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 服务 active;端口 9082 监听;两个 URL 200;日志含「迁移432」;选择器返回 `code=200` 且含 `skus`/`hasSkus`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 失败回滚**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+tar -xzf /app/pro/cfc_backup_<ts>.tar.gz -C /app && systemctl restart cfc-backend
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: git 收尾**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /app/cfc && git status
|
|
|
|
|
+# npm run build 可能改动 package.json / .last_build_commit / CHANGELOG*,按 AGENTS.md 提交推送(冲突时取远程版本号)
|
|
|
|
|
+```
|