|
|
@@ -0,0 +1,414 @@
|
|
|
+# 商品套餐独立模型重写 实施计划
|
|
|
+
|
|
|
+> **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:** 用全新的「套餐=独立商品」模型替换现有 combo(SKU级)与 bundle(product级)两套机制:套餐商品自带 SKU 售价/库存、内容清单(子商品 SKU×数量)按套餐 SKU 挂载、购物车结算命中套餐且套餐价<子商品总和时提示「一键替换为套餐」,否则按原价。
|
|
|
+
|
|
|
+**Architecture:** 后端新增 `product_combo_items` 表(迁移437)+ `ProductComboItem` 实体/Mapper;新增纯计算 `ComboOfferCalculator`(覆盖检测+节省额>0 规则)与购物车替换接口;删除旧 combo/bundle 实体、定价计算器与全部扣子商品库存逻辑(迁移438/439 删旧表)。前端 cfc-web 管理端重写套餐内容编辑、移除 SKU 编辑器组合模式;cfc-frontend 消费端购物车以「一键替换」提示替代自动优惠。
|
|
|
+
|
|
|
+**Tech Stack:** cfc-backend(Spring Boot 2.7.18 / MyBatis-Plus / MySQL 8.0.34 / JUnit)、cfc-web(Vue2 + Element UI + jest)、cfc-frontend(uni-app + jest)。代码位于远程 `/app/cfc`(monorepo,git 分支 `cfclub`),本地 `/root/cfc` 为空壳。所有编辑经 ssh 至远程执行(本会话已验证 workflow:本地 write 文件 → scp → 远程应用)。
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-10-31-bundle-redesign-design.md`
|
|
|
+
|
|
|
+## Global Constraints
|
|
|
+
|
|
|
+- 新迁移编号从 **437** 起(436 已被「products 营养数据字段」占用)。
|
|
|
+- 线上 `product_sku_combo_items`、`product_bundle_items` 均 **0 数据**——旧表可安全 DROP(幂等迁移)。
|
|
|
+- 套餐价格/子商品价格均为「分」单位;比较以**下单口径结算单价**为准。
|
|
|
+- 替换接口数量/金额**必须后端重算**(防篡改),前端仅展示后端下发的 offer。
|
|
|
+- 迁移走 `DatabaseInitializer.runMigrations()` 幂等模式,同步 `schema.sql`。
|
|
|
+- 每次任务结束保持 `mvn compile`/`npm run build` 可通过。
|
|
|
+- MySQL 8.0:索引/唯一键不得含函数表达式。
|
|
|
+
|
|
|
+## Review Focus
|
|
|
+
|
|
|
+1. **套餐价 ≥ 子商品单买总和** → 不提示、按原价结算(不能出现「越组越贵」)。→ Task 5 计算器测试。
|
|
|
+2. **覆盖不足**(购物车某子商品数量 < 组成数量)→ 无 offer,不能部分替换。→ Task 5 计算器测试。
|
|
|
+3. **超量覆盖**(如可组 2 份)→ offer 替换 2 份,购物车余量保留。→ Task 5 计算器+服务测试。
|
|
|
+4. **套餐商品已被直接加购** → 不应再对该套装重复提示/替换套餐自身。→ Task 5 检测去重。
|
|
|
+5. **替换防篡改**:前端传组数不信任,后端按购物车实际重算并事务提交。→ Task 5 服务测试。
|
|
|
+6. **子商品 SKU 已失效/不存在** → 套餐内容保存时校验拒绝;检测时跳过脏行。→ Task 2 保存校验 + Task 5 检测防御。
|
|
|
+7. **多套餐候选** → 取节省额最大者;同额取先出现(确定性)。→ Task 5 计算器测试。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 迁移437 + ProductComboItem 实体/Mapper
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(新增 `migrate437`)
|
|
|
+- Modify: `cfc-backend/src/main/resources/schema.sql`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductComboItem.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductComboItemMapper.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: `ProductComboItem`(字段:`Long id`、`Long comboSkuId`、`Long childProductId`、`Long childSkuId`、`Integer quantity`、`Date createdAt`,`@TableName("product_combo_items")`);`ProductComboItemMapper extends BaseMapper<ProductComboItem>`;表 `product_combo_items`(UK `(combo_sku_id, child_sku_id)`)。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写实体与 Mapper**
|
|
|
+
|
|
|
+`ProductComboItem.java` 字段与上表一致(MyBatis-Plus 注解风格参照 `ProductSkuComboItem.java` 删除前的写法);`ProductComboItemMapper.java` 空接口继承 `BaseMapper<ProductComboItem>`。
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 DatabaseInitializer 新增 `migrate437`**
|
|
|
+
|
|
|
+```java
|
|
|
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `product_combo_items` ("
|
|
|
+ + "`id` BIGINT NOT NULL AUTO_INCREMENT, "
|
|
|
+ + "`combo_sku_id` BIGINT NOT NULL COMMENT '套餐SKU id(内容挂SKU级)', "
|
|
|
+ + "`child_product_id` BIGINT NOT NULL, "
|
|
|
+ + "`child_sku_id` BIGINT NOT NULL COMMENT '子商品SKU(精确到规格)', "
|
|
|
+ + "`quantity` INT NOT NULL DEFAULT 1, "
|
|
|
+ + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
|
|
+ + "PRIMARY KEY (`id`), "
|
|
|
+ + "UNIQUE KEY `uk_combo_sku_child` (`combo_sku_id`, `child_sku_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
|
|
+```
|
|
|
+在迁移入口(`runMigrations` 中)注册调用,与现有迁移同样的 try/catch + log 幂等模式。
|
|
|
+
|
|
|
+- [ ] **Step 3: schema.sql 同步 `product_combo_items` 建表语句**
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q compile`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /app/cfc && git add cfc-backend/src && git commit -m "feat(backend): 新增套餐内容表 product_combo_items(迁移437)与实体/Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 套餐内容管理接口 save/listComboItems(含校验)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ComboService.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java`(新增 `POST /api/admin/inventory/combo/items`、`POST /api/admin/inventory/combo/items/list`)
|
|
|
+- Create: `cfc-backend/src/test/java/.../ComboServiceTest.java`(放实际测试目录)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 1 的 `ProductComboItemMapper`、`ProductSku` 实体与 `product_skus` 查询。
|
|
|
+- Produces: `ComboService.saveComboItems(Long comboSkuId, List<ComboItemInput> items)`、`ComboService.listComboItems(Long comboSkuId)`;`ComboItemInput` = `{Long childProductId, Long childSkuId, Integer quantity}`。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试 `ComboServiceTest`**
|
|
|
+
|
|
|
+用 mock 的 `ProductComboItemMapper`/`ProductSkuMapper`(复用项目现有单测 mock 风格,参照 `ProductSkuComboServiceTest` 删除前的写法)断言:
|
|
|
+- `saveComboItems` 先删后插:对同一 `comboSkuId` 先 `delete` 再逐条 `insert`;
|
|
|
+- `quantity == null || quantity <= 0` → 抛 `IllegalArgumentException`;
|
|
|
+- `childSkuId == null` → 抛 `IllegalArgumentException`(SKU 级必填);
|
|
|
+- `comboSkuId` 对应 SKU 不存在 → 抛 `IllegalArgumentException`;
|
|
|
+- `listComboItems` 按 comboSkuId 查询并回填 `childProductName`/`childProductImage`/`childSkuSpecs`(按 `child_product_id`+`child_sku_id` 联查 product_skus/products,mock 返回值)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行确认失败**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboServiceTest`
|
|
|
+Expected: FAIL(ComboService 不存在/方法未定义)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `ComboService`**
|
|
|
+
|
|
|
+先校验:`comboSkuId` 非空且 `product_skus` 存在;逐条校验 `childSkuId != null`、`quantity > 0`、`child_sku_id` 存在且属于 `child_product_id`。通过后 `delete` 旧行 + 批量 `insert`。`listComboItems` 回填展示字段。
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 AdminInventoryController 新增两个端点**
|
|
|
+
|
|
|
+`POST /api/admin/inventory/combo/items`(body: `{comboSkuId, items:[]}`)、`POST /api/admin/inventory/combo/items/list`(body: `{comboSkuId}`),返回 `Result`。沿用现有 `ParamUtils`/`Result` 风格。
|
|
|
+
|
|
|
+- [ ] **Step 5: 商品详情挂载套餐清单**
|
|
|
+
|
|
|
+`ProductService.detail`:当商品 `product_type='bundle'` 时,按该商品 SKU 列表调 `ComboService.listComboItems` 附带 `comboItems` 清单(含子商品名/图/规格/数量)到详情响应,供消费端「内含清单」展示。
|
|
|
+
|
|
|
+- [ ] **Step 6: 运行通过**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboServiceTest`
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 7: 编译 + Commit**
|
|
|
+
|
|
|
+Run: `mvn -q compile`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+Commit: `feat(backend): 套餐内容保存/列表接口(combo/items)含子SKU校验与详情清单挂载`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 删除 SKU 级 combo(后端代码 + 迁移438 删表)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Delete: `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java`、`mapper/ProductSkuComboItemMapper.java`、`ProductSkuComboServiceTest`、`ProductOrderComboStockTest`(如存在)
|
|
|
+- Modify: `entity/ProductSku.java`(删 `comboItems` 字段)、`service/ProductSkuService.java`(删 combo 方法)、`service/CartService.java`(删 combo 摘要)、`service/ProductOrderService.java`(删 combo 行处理/deductComboStock/restockComboOrder 及其调用)、`controller/product/ProductController.java`(删 detail 中 comboItems 组装)、`controller/admin/AdminProductPickerController.java`(删 combo 相关)、`config/DatabaseInitializer.java`(新增 `migrate438` drop `product_sku_combo_items`)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: 无残留 SKU 级 combo 引用(`mvn compile` 通过);旧表被 DROP。
|
|
|
+
|
|
|
+- [ ] **Step 1: 删除 combo 实体/Mapper/旧测试**
|
|
|
+
|
|
|
+`git rm` 上述 Delete 文件。
|
|
|
+
|
|
|
+- [ ] **Step 2: 清除引用**
|
|
|
+
|
|
|
+按编译错误逐个清除:`ProductSku.comboItems`、`ProductSkuService` 的 `fillComboItems/validateComboItems/saveComboItems/buildComboSummary/hasComboItems`、`CartService` specDesc 组合摘要、`ProductOrderService` combo 行特殊处理与 `deductComboStock`/`restockComboOrder`(含第 1082/1380/1830/2050 行附近调用)、`ProductController.detail` comboItems 组装、`AdminProductPickerController` combo 引用。
|
|
|
+
|
|
|
+- [ ] **Step 3: 新增 `migrate438` 删旧表**
|
|
|
+
|
|
|
+`DROP TABLE IF EXISTS product_sku_combo_items`,注册进 `runMigrations`(幂等)。
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q compile`
|
|
|
+Expected: BUILD SUCCESS(无 combo 残留引用)
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+Commit: `refactor(backend): 移除SKU级组合机制(实体/库存扣减回补/迁移438删表)`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 删除 bundle 机制(后端代码 + 迁移439 删表)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Delete: `pricing/BundlePricingCalculator.java`、`pricing/BundleDef.java`、`pricing/BundleChild.java`、`pricing/BundleHit.java`、`pricing/BundleDiscountResult.java`、`pricing/BundlePricingService.java`、`entity/ProductBundleItem.java`、`mapper/ProductBundleItemMapper.java`(**保留** `pricing/LineItem.java` 供 Task 5 复用)
|
|
|
+- Modify: `dto/CartListResultDTO.java`(删 `bundlePromo`,后续 Task 5 加 `comboOffer`)、`dto/CartItemDTO.java`(删 bundleMatch/bundleInfo 相关)、`service/CartService.java`(删 `attachBundleMatches/computeBundlePromo/toBundlePromo/buildBundleInfo` 及 import)、`service/ProductOrderService.java`(删 `checkBundleStock` 3 处调用与 `bundleDiscount` 应用)、`service/InventoryService.java`(删 `saveBundleItems/listBundleItems/checkBundleStock` 及 bundle 详情组装)、`controller/admin/AdminInventoryController.java`(删 `/bundle/items`、`/bundle/items/list`、`/bundle/check-stock` 端点)、`controller/MarketController.java`/`controller/PackagePaymentController.java`(删 bundle 引用)、`config/DatabaseInitializer.java`(新增 `migrate439` drop `product_bundle_items`)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: 无 bundle 残留(`mvn compile` 通过);`pricing/LineItem` 保留。
|
|
|
+
|
|
|
+- [ ] **Step 1: 删除 bundle 实体/定价包/旧端点**
|
|
|
+
|
|
|
+按上表 `git rm`/删除文件与控制器端点。
|
|
|
+
|
|
|
+- [ ] **Step 2: 清除引用至编译通过**
|
|
|
+
|
|
|
+按编译错误逐个清除:CartService/ProductOrderService/InventoryService/控制器/DTO 中所有 bundle 引用(`checkBundleStock`、`bundleDiscount`、`bundlePromo`、`bundleMatch`、`bundleInfo`、`BundlePricingService` 注入等)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 新增 `migrate439` 删旧表**
|
|
|
+
|
|
|
+`DROP TABLE IF EXISTS product_bundle_items`,注册进 `runMigrations`。
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn -q compile`
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+Commit: `refactor(backend): 移除旧bundle自动计价机制(定价计算器/库存联动/迁移439删表),保留LineItem`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 购物车套餐 offer 检测 + 一键替换接口
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferDef.java`、`pricing/ComboOfferResult.java`、`pricing/ComboOfferCalculator.java`
|
|
|
+- Create: `cfc-backend/src/test/java/.../ComboOfferCalculatorTest.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ComboOfferService.java`(装载定义 + 购物车接入)
|
|
|
+- Modify: `service/CartService.java`(新增 `computeComboOffer`/`replaceCartWithCombo`,移除 Task 4 后残留的 bundle 引用)、`controller/cart/CartController.java`(新增 `POST /api/cart/combo-offer` 与 `POST /api/cart/replace-combo`)
|
|
|
+- Create: `cfc-backend/src/test/java/.../CartReplaceComboTest.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 1 `ProductComboItemMapper`、Task 4 保留的 `pricing/LineItem`。
|
|
|
+- Produces: `ComboOfferDef{Long comboSkuId, String comboName, Integer comboPrice, List<ComboChild> children}`;`ComboChild{Long childProductId, Long childSkuId, Integer quantity}`;`ComboOfferResult{boolean active, Long comboSkuId, String comboName, int groups, int originalTotal, int comboPrice, int saving}`;`ComboOfferCalculator.compute(List<LineItem> lines, List<ComboOfferDef> defs) -> ComboOfferResult`;`CartService.computeComboOffer(Long userId, List<Long> checkedCartItemIds) -> ComboOfferResult`;`CartService.replaceCartWithCombo(Long userId, List<Long> checkedCartItemIds, Long comboSkuId)`;`POST /api/cart/combo-offer {checkedCartItemIds:Long[]} -> Result<ComboOfferResult>`;`POST /api/cart/replace-combo {checkedCartItemIds:Long[], comboSkuId} -> Result<Void>`。
|
|
|
+
|
|
|
+> **勾选态感知**:购物车行无服务端 checked 字段(勾选是前端本地态,见 cart.vue `item.checked`),而 offer 必须按**勾选结算子集**判定——因此检测以独立端点 `combo-offer` 承载:前端把勾选记录 id(`CartItemDTO.id`=购物车主键)传给后端,后端按该子集重算(服务端权威),不再烘焙进 `CartListResultDTO`。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试 `ComboOfferCalculatorTest`(纯计算,覆盖 Review Focus 1/2/3/7)**
|
|
|
+
|
|
|
+断言(lines 用 `(productId, skuId, quantity, unitPrice 分)` 构造):
|
|
|
+1. 恰好覆盖:A×2+B×1,套餐X(comboPrice=15000)组成 A×2+B×1,A 单价5000、B 单价6000 → 命中,`saving=5000`(原价16000-套餐15000),`groups=1`;
|
|
|
+2. 套餐价≥总和:comboPrice=16000 → `active=false`(原价结算,Review Focus 1);
|
|
|
+3. 覆盖不足:A×1+B×1 → `active=false`(Review Focus 2);
|
|
|
+4. 超量覆盖:A×4+B×2 → `groups=2`,`originalTotal=32000`,套餐价 15000×2 → `saving=10000`,原价=16000×2(Review Focus 3);
|
|
|
+5. 多套餐候选:同时命中套餐X(省5000)与套餐Y(省3000) → 返回 X(节省最大);两套餐同额 → 返回先出现者(Review Focus 7);
|
|
|
+6. 脏行防御:child 的 childSkuId 不在 lines 中 → 不命中该套餐。
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行确认失败**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboOfferCalculatorTest`
|
|
|
+Expected: FAIL(类不存在)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `ComboOfferCalculator`**
|
|
|
+
|
|
|
+签名:`public ComboOfferResult compute(List<LineItem> lines, List<ComboOfferDef> defs)`。算法:按 `(productId, skuId)` 聚合 lines 为池;对每个 def 计算可覆盖组数 `groups = min over children(floor(haveQty/needQty))`(haveQty 严格按 child_sku_id 匹配,缺失即 0);`originalTotal = sum(组内 child 实际消耗数量 × 对应 line 单价 × groups)`;`saving = originalTotal - comboPrice×groups`;`saving > 0` 且 `groups > 0` 才候选;取 saving 最大(同额取 defs 先出现);返回单条 offer(多套餐只报最优一个,符合设计)。同一套餐重复(comboSkuId 相同)只报一次。
|
|
|
+
|
|
|
+- [ ] **Step 4: 运行通过**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboOfferCalculatorTest`
|
|
|
+Expected: PASS(6 项全绿)
|
|
|
+
|
|
|
+- [ ] **Step 5: 实现 `ComboOfferService` 装载与购物车接入**
|
|
|
+
|
|
|
+`ComboOfferService.computeForLines(List<LineItem> lines)`:查询 `product_combo_items` 按 `combo_sku_id` 聚合 → 取套餐 SKU(`product_skus.price`/`label`)组装 `ComboOfferDef`(脏行跳过)→ 调 Calculator。`CartService.computeComboOffer(userId, checkedCartItemIds)`:按 id 加载购物车记录(仅限该 userId,越权 id 忽略/报错)→ 构建 lines(按 `(productId, skuId)` 聚合、单位分单价)→ 调 computeForLines。`getCartList` **不再返回任何套餐 offer**(列表与勾选态解耦,避免「按全车算的优惠」误导)。
|
|
|
+
|
|
|
+- [ ] **Step 6: 写失败测试 `CartReplaceComboTest`(服务层,mock mapper,Review Focus 4/5)**
|
|
|
+
|
|
|
+断言 `CartService.computeComboOffer(userId, checkedCartItemIds)` 仅按勾选记录计算(未勾选行不参与、saving>0 才 active);`replaceCartWithCombo(userId, checkedCartItemIds, comboSkuId)`:
|
|
|
+1. 后端按 checked 行的 `(productId, skuId)` 重算组数(忽略前端传入组数)——购物车 A×4+B×2 传 99 份 → 只替换 2 组;
|
|
|
+2. 替换后:子商品行数量相应扣减(A 剩 0、B 剩 0),新增套餐 SKU 行 quantity=2;
|
|
|
+3. 余量保留:A×5+B×2 → A 余 1,B 余 0,套餐行 2;
|
|
|
+4. 套餐商品已在购物车(同 product 已在库)→ 返回错误或合并数量而非重复套装(设计决策:与现有 add 行为一致,合并数量);
|
|
|
+5. 事务性:任一步失败整体回滚(mock mapper 抛异常断言无部分写入)。
|
|
|
+第一次运行确认 FAIL(方法未定义)。
|
|
|
+
|
|
|
+- [ ] **Step 7: 实现 `CartController POST /api/cart/replace-combo` 与 `CartService.replaceCartWithCombo`**
|
|
|
+
|
|
|
+事务(`@Transactional`):后端重算组数 → 逐行扣减购物车子商品数量(不足则整体失败回滚)→ 删除数量为 0 的行 → upsert 套餐 SKU 行(存在则加数量)。
|
|
|
+
|
|
|
+- [ ] **Step 8: 运行通过**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=CartReplaceComboTest`
|
|
|
+Expected: PASS(5 项全绿)
|
|
|
+
|
|
|
+- [ ] **Step 9: 全量后端测试 + 编译 + Commit**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn test -Dmaven.test.skip=false 2>&1 | tail -20`
|
|
|
+Expected: 全绿(旧 combo/bundle 测试已删,其余回归通过)
|
|
|
+Commit: `feat(backend): 购物车套餐offer检测(勾选子集/节省额>0)与一键替换接口`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 套餐商品下单只扣套餐 SKU 库存
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `service/ProductOrderService.java`(确认套餐商品走普通 SKU 下单路径;清理 Task 4 后遗留的 bundle/combo 分支)
|
|
|
+- Create: `cfc-backend/src/test/java/.../ComboOrderStockTest.java`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 3/4 移除后 ProductOrderService 已无 combo/bundle 库存逻辑。
|
|
|
+- Produces: 套餐商品(product_type='bundle')下单按普通商品:校验套餐 SKU 库存 → 支付扣减 → 取消/失败回补,均只作用于套餐 SKU。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试 `ComboOrderStockTest`**
|
|
|
+
|
|
|
+断言(mock):
|
|
|
+1. 下单套餐商品(product_type='bundle')→ 库存校验走套餐 SKU `product_skus.stock`;
|
|
|
+2. 支付成功后套餐 SKU 库存扣减 quantity,**子商品 SKU 库存不变**(Review 关键);
|
|
|
+3. 取消/失败回补只加套餐 SKU 库存。
|
|
|
+若 ProductOrderService 仍有 bundle/combo 分支(如还调 checkBundleStock 或子商品扣减)→ 测试失败。
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行确认失败**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboOrderStockTest`
|
|
|
+Expected: FAIL
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现**
|
|
|
+
|
|
|
+确保 ProductOrderService 对 `product_type='bundle'` 无特殊库存分支(走普通 SKU 流);删除任何残留 `checkBundleStock`/子商品扣减。
|
|
|
+
|
|
|
+- [ ] **Step 4: 运行通过 + 编译 + Commit**
|
|
|
+
|
|
|
+Run: `mvn test -Dtest=ComboOrderStockTest` → PASS;`mvn -q compile` → SUCCESS
|
|
|
+Commit: `feat(backend): 套餐下单仅扣套餐SKU库存(校验/扣减/回补)`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: cfc-web 管理端(套餐内容编辑 + 移除 SKU 组合模式)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/api/admin.js`(`saveBundleItems`/`getBundleItems` → `saveComboItems`/`listComboItems` 调用新端点)
|
|
|
+- Modify: `cfc-web/src/views/admin/ProductEdit.vue`(bundle 区块改为「套餐内容编辑」:选子商品 SKU 规格 + 数量;产品当前 SKU id 作 comboSkuId)
|
|
|
+- Modify: `cfc-web/src/views/admin/components/ProductSkuEditor.vue`(**移除「商品组合」模式**:radio 只留「规格变体」,删 comboItems/ProductPicker 组合块相关逻辑与样式)
|
|
|
+- Modify: `cfc-web/src/__tests__/components/ProductSkuEditor.mode.test.js`(既有 mode 测试追加断言:radio 仅有「规格变体」一项)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 2 后端端点 `POST /api/admin/inventory/combo/items`、`/combo/items/list`。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试(ProductSkuEditor 无组合模式)**
|
|
|
+
|
|
|
+沿用既有 `src/__tests__/components/ProductSkuEditor.mode.test.js`,追加断言:挂载后打开对话框,`.el-radio-button` 文本**不含「商品组合」且不含「关联商品」**,仅含「规格变体」。运行确认 FAIL(当前仍有组合)。
|
|
|
+
|
|
|
+- [ ] **Step 2: api/admin.js 替换接口**
|
|
|
+
|
|
|
+`saveComboItems(comboSkuId, items)` → `POST /api/admin/inventory/combo/items`;`listComboItems(comboSkuId)` → `POST /api/admin/inventory/combo/items/list`。
|
|
|
+
|
|
|
+- [ ] **Step 3: ProductSkuEditor 移除组合模式**
|
|
|
+
|
|
|
+删除「商品组合」radio-button、combo 模板块、`comboItems`/`openProductPicker`/组合保存分支、相关样式(保留「规格变体」)。`ProductPicker` 组件保留(Task 7 套餐内容编辑复用其子商品/SKU 选择能力)。
|
|
|
+
|
|
|
+- [ ] **Step 4: ProductEdit.vue 套餐内容编辑**
|
|
|
+
|
|
|
+`productType==='bundle'` 时:加载套餐 SKU 列表(`getProductSkuList`),取首个/指定 SKU 的 id 作 `comboSkuId`;「套餐内容编辑」用 ProductPicker 多选子商品 SKU + 数量;保存调 `saveComboItems(list[0].id, items)`,加载调 `listComboItems`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行测试**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest`
|
|
|
+Expected: 全绿(既有 90 → 更新后全过,含新断言)
|
|
|
+
|
|
|
+- [ ] **Step 6: 构建 + Commit**
|
|
|
+
|
|
|
+Run: `npm run build`
|
|
|
+Expected: DONE Build complete
|
|
|
+Commit: `feat(admin): 套餐内容编辑改挂商品级、SKU编辑器移除组合模式`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: cfc-frontend 消费端(购物车「一键替换为套餐」)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/utils/bundle-pricing.js`(重写为 comboOffer 展示辅助:`comboOfferOf(offer)`/`formatComboOfferText(offer)` 纯展示,删除自动扣减逻辑)
|
|
|
+- Modify: `cfc-frontend/pages/shop/cart/cart.vue`(删「已享套餐价」自动区块与 `applyBundleDiscount` 扣减;勾选态变化→防抖调 `/api/cart/combo-offer` 取勾选子集 offer;有 offer 时提示「这 N 件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)」+「一键替换为套餐」按钮 → `replace-combo` → 刷新购物车)
|
|
|
+- Modify: `cfc-frontend/pages/shop/checkout/checkout.vue`(移除 bundlePromo 自动优惠处理)
|
|
|
+- Modify: `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue`(套餐商品详情展示「内含清单」:用详情接口 `comboItems` 渲染子商品名/规格/数量)
|
|
|
+- Modify: `cfc-frontend/utils/api.js`(新增 `queryComboOffer`、`replaceCartWithCombo`)
|
|
|
+- Modify: `cfc-frontend/__tests__/utils/bundle-pricing.test.js`(适配 comboOffer)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 5 的 `POST /api/cart/combo-offer`(勾选子集 offer)与 `POST /api/cart/replace-combo`;Task 2 详情接口的 `comboItems` 清单。
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败测试(bundle-pricing → comboOffer 辅助)**
|
|
|
+
|
|
|
+改写 `__tests__/utils/bundle-pricing.test.js`:`comboOfferOf({active:true,...})` 返回 offer;`active=false/缺失` 返回 null;总价**不**再被自动扣减(纯展示)。运行确认 FAIL(旧逻辑仍在)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现 utils 与 api**
|
|
|
+
|
|
|
+`utils/api.js` 加 `queryComboOffer(payload)`(POST /cart/combo-offer)与 `replaceCartWithCombo(payload)`(POST /cart/replace-combo);`utils/bundle-pricing.js` 重写为 comboOffer 展示辅助。
|
|
|
+
|
|
|
+- [ ] **Step 3: cart.vue 改造**
|
|
|
+
|
|
|
+删自动优惠区块与扣减;勾选态变化(checked 集合变更)时防抖调 `queryComboOffer({checkedCartItemIds})`(ids 取勾选行 `item.id`,空集不调);返回 `active` 时渲染提示「这 N 件可换成套餐「XX」(省 ¥Z)」+「一键替换为套餐」按钮 → `replaceCartWithCombo({checkedCartItemIds, comboSkuId})` → 成功后刷新购物车列表与勾选态(替换后重查 offer 应为 inactive)。
|
|
|
+
|
|
|
+- [ ] **Step 4: checkout.vue 移除 bundlePromo 处理**
|
|
|
+
|
|
|
+- [ ] **Step 5: 运行测试 + 构建 + Commit**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-frontend && npx jest`(如项目脚本)→ 全绿;`npm run build`(或项目构建脚本)→ 成功
|
|
|
+Commit: `feat(consumer): 购物车套餐「一键替换为套餐」提示(替代自动优惠)`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 全量回归、构建与部署
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: 无(部署流程操作)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: Task 1-8 全部产物。
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端全量回归**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-backend && mvn test 2>&1 | tail -20`
|
|
|
+Expected: 全绿;`mvn clean package -Dmaven.test.skip=true -Dmaven.compile.fork=true` 打包成功。
|
|
|
+
|
|
|
+- [ ] **Step 2: 前端双端回归与构建**
|
|
|
+
|
|
|
+Run: `cd /app/cfc/cfc-web && npx jest && npm run build`;`cd /app/cfc/cfc-frontend && npx jest && npm run build`(按项目实际脚本)
|
|
|
+Expected: 全绿 + 构建成功。
|
|
|
+
|
|
|
+- [ ] **Step 3: 部署**
|
|
|
+
|
|
|
+按 AGENTS.md:git 状态确认干净 → 备份 `/app/pro/cfc` → 后端 `cp jar + systemctl restart cfc-backend` → 前端 `cfc-web/dist` rsync 至 `/app/pro/cfc/frontend/` → 消费端 cfc-frontend 构建产物按本项目部署方式(如 miniprogram/H5)部署 → 验证健康端点与页面。
|
|
|
+
|
|
|
+- [ ] **Step 4: 生产验证**
|
|
|
+
|
|
|
+后端 `systemctl status` active;`curl POST /api/cart/list` 200 且无 `bundlePromo`/`bundleMatch`;`curl POST /api/cart/combo-offer`(空 ids)返回正常结构;日志无 ERROR。管理端商品编辑套餐商品可见「套餐内容编辑」。数据库核对 `product_combo_items` 表存在、旧表已删。
|
|
|
+
|
|
|
+- [ ] **Step 5: git 收尾**
|
|
|
+
|
|
|
+提交部署相关变更(版本号冲突取远程),推送 `cfclub`,更新台账 `.superpowers/sdd/…`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Review Focus 归属自查
|
|
|
+
|
|
|
+| 关注点 | 测试归属 |
|
|
|
+|---|---|
|
|
|
+| 套餐价≥总和→取原价 | Task 5.1 用例2 |
|
|
|
+| 覆盖不足→无offer | Task 5.1 用例3 |
|
|
|
+| 超量→替换N份余量保留 | Task 5.1 用例4 + Task 5.6 用例3 |
|
|
|
+| 套餐已加购不重复提示 | Task 5 检测去重(计算器单 def 唯一 + 服务去重) |
|
|
|
+| 替换防篡改 | Task 5.6 用例1/5 |
|
|
|
+| 子SKU失效 | Task 2 保存校验 + Task 5.1 用例6 |
|
|
|
+| 多套餐取最大 | Task 5.1 用例5 |
|