Jelajahi Sumber

Revert "test(interface): 接口冒烟闭环 2026-11-01 16:59 — 通过率 44% (fix=0 reg=0); 测试用例 360/387 完成(文档化202)"

This reverts commit af4a4448bfa532ee589030dac42d89f2f6a45a3d.
Xiaogang Liao 1 Minggu lalu
induk
melakukan
f6c2a76972
78 mengubah file dengan 2199 tambahan dan 4410 penghapusan
  1. 1 1
      INTERFACE_TEST_REPORT.md
  2. 52 69
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 25 23
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java
  4. 0 15
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java
  5. 0 91
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/SupplyTeamController.java
  6. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java
  7. 0 42
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
  8. 0 63
      cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java
  9. 0 3
      cfc-backend/src/main/java/com/etotem/cfc/dto/CartListResultDTO.java
  10. 22 3
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  11. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java
  12. 0 2
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java
  13. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  14. 0 23
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductBundleItem.java
  15. 7 16
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductComboItem.java
  16. 0 3
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java
  17. 0 33
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java
  18. 0 62
      cfc-backend/src/main/java/com/etotem/cfc/entity/SupplyServiceAssignment.java
  19. 0 9
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductBundleItemMapper.java
  20. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductComboItemMapper.java
  21. 0 7
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java
  22. 0 9
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SupplyServiceAssignmentMapper.java
  23. 0 22
      cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleChild.java
  24. 0 31
      cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDef.java
  25. 0 32
      cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDiscountResult.java
  26. 0 33
      cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleHit.java
  27. 0 216
      cfc-backend/src/main/java/com/etotem/cfc/pricing/BundlePricingCalculator.java
  28. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboChild.java
  29. 86 0
      cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferCalculator.java
  30. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferDef.java
  31. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferResult.java
  32. 0 8
      cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java
  33. 0 85
      cfc-backend/src/main/java/com/etotem/cfc/service/BundlePricingService.java
  34. 132 219
      cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java
  35. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ComboOfferService.java
  36. 127 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ComboService.java
  37. 0 76
      cfc-backend/src/main/java/com/etotem/cfc/service/InventoryService.java
  38. 40 176
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  39. 64 25
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  40. 0 106
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java
  41. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java
  42. 2 163
      cfc-backend/src/main/java/com/etotem/cfc/service/SupplyCustomerRelationService.java
  43. 0 376
      cfc-backend/src/main/java/com/etotem/cfc/service/SupplyServiceAssignmentService.java
  44. 15 44
      cfc-backend/src/main/resources/schema.sql
  45. 0 129
      cfc-backend/src/test/java/com/etotem/cfc/service/ProductOrderComboStockTest.java
  46. 0 168
      cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingCalculatorTest.java
  47. 0 126
      cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingServiceTest.java
  48. 205 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/CartReplaceComboTest.java
  49. 0 137
      cfc-backend/src/test/java/com/etotem/cfc/unit/CartServiceTest.java
  50. 140 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOfferCalculatorTest.java
  51. 149 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOrderStockTest.java
  52. 198 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ComboServiceTest.java
  53. 40 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductDTOMappingTest.java
  54. 88 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductListNutritionTagFilterTest.java
  55. 0 140
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductOrderServiceTest.java
  56. 0 117
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductSkuComboServiceTest.java
  57. 89 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ProductUpdateClearTest.java
  58. 79 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationNutritionTagTest.java
  59. 30 44
      cfc-frontend/__tests__/utils/bundle-pricing.test.js
  60. 0 6
      cfc-frontend/pages.json
  61. 92 3
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  62. 86 25
      cfc-frontend/pages/shop/cart/cart.vue
  63. 0 27
      cfc-frontend/pages/shop/checkout/checkout.vue
  64. 20 221
      cfc-frontend/pages/vendor/team-customers/team-customers.vue
  65. 0 658
      cfc-frontend/pages/vendor/team-service/team-service.vue
  66. 7 132
      cfc-frontend/pages/vendor/team-system/team-system.vue
  67. 8 23
      cfc-frontend/utils/api.js
  68. 27 33
      cfc-frontend/utils/bundle-pricing.js
  69. 2 2
      cfc-web/package-lock.json
  70. 3 2
      cfc-web/src/__tests__/components/ProductSkuEditor.mode.test.js
  71. 6 14
      cfc-web/src/api/admin.js
  72. 9 4
      cfc-web/src/views/Dashboard.vue
  73. 125 62
      cfc-web/src/views/admin/ProductEdit.vue
  74. 17 188
      cfc-web/src/views/admin/components/ProductSkuEditor.vue
  75. 0 0
      docs/superpowers/PROJECT-OVERVIEW.md
  76. 0 55
      docs/superpowers/api/API_REFERENCE.md
  77. 2 4
      docs/superpowers/specs/2026-11-01-team-product-performance-design.md
  78. 2 2
      playwright_smoke.log

+ 1 - 1
INTERFACE_TEST_REPORT.md

@@ -1,4 +1,4 @@
-# CFC 接口冒烟测试报告 (20261101_165652)
+# CFC 接口冒烟测试报告 (20261101_142252)
 
 - 目标: cfc.iwintrue.com  用例总数: **545**
 - 业务成功率(BIZ_OK): **245/545 = 44%**

+ 52 - 69
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3406,9 +3406,14 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移437: T35 活动管理团队 —— activities.team_id + activity_team/activity_team_member/service_assignment 表
         migrateActivityTeam();
+        // 迁移440: 套餐内容明细表(套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级)
+        migrateProductComboItems();
 
-        // 迁移438: 创建 supply_service_assignment 表(团队服务指派,规格 2026-11-01-team-system-design.md §3.4 / T34)
-        migrateSupplyServiceAssignment();
+        // 迁移441: 删除旧 SKU 组合明细表(新模型:套餐内容在 product_combo_items,0数据安全删)
+        dropTableSkuCombo();
+
+        // 迁移442: 删除旧 bundle 机制明细表(新模型:套餐=独立商品,内容在 product_combo_items,0数据安全删)
+        dropTableBundleItems();
     }
 
     /**
@@ -3615,62 +3620,52 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
     }
 
     /**
-     * 迁移438:创建 supply_service_assignment 表(团队服务指派)。
-     *
-     * <p>规格:docs/superpowers/specs/2026-11-01-team-system-design.md §3.4(T34)。</p>
-     * <p>幂等:CREATE TABLE IF NOT EXISTS + ensureColumn 补列,可重复执行。</p>
+     * 迁移440:套餐内容明细表 product_combo_items。
+     * <p>套餐=独立商品(product_type='bundle'),内容=套餐包含哪些子商品(精确到子商品SKU)×数量;
+     * 内容挂套餐SKU级(combo_sku_id)。下单/支付只动套餐SKU自身库存,不扣子商品库存。
+     * <p>幂等:CREATE TABLE IF NOT EXISTS + 异常兜底。
+     */
+    private void migrateProductComboItems() {
+        try {
+            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(product_skus.id),内容挂SKU级', "
+                    + "`child_product_id` BIGINT NOT NULL COMMENT '子商品ID', "
+                    + "`child_sku_id` BIGINT NOT NULL COMMENT '子商品SKU ID(精确到规格,必填)', "
+                    + "`quantity` INT NOT NULL DEFAULT 1 COMMENT '该子商品数量', "
+                    + "`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 COMMENT='套餐内容明细(套餐商品包含的子商品SKU×数量)'");
+            log.info("迁移440: 已创建 product_combo_items 表");
+        } catch (Exception e) {
+            log.warn("迁移440: 创建 product_combo_items 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移441:删除旧 SKU 商品组合明细表 product_sku_combo_items。
+     * <p>新模型:组合机制移除,套餐=独立商品,内容在 product_combo_items(迁移437)。线上该表 0 数据,安全 DROP。
      */
-    private void migrateSupplyServiceAssignment() {
+    private void dropTableSkuCombo() {
         try {
-            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS supply_service_assignment (" +
-                    "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
-                    "system_id BIGINT NOT NULL COMMENT '团队体系ID(supply_system.id)', " +
-                    "customer_relation_id BIGINT DEFAULT NULL COMMENT '客户归属关系ID(supply_customer_relation.id)', " +
-                    "assignee_user_id BIGINT DEFAULT NULL COMMENT '被指派成员用户ID(users.id),NULL=待指派', " +
-                    "service_type VARCHAR(32) NOT NULL COMMENT '服务类型: AFTER_SALE/CONSULT/ACTIVITY_SUPPORT', " +
-                    "ref_order_id BIGINT DEFAULT NULL COMMENT '关联订单ID(product_orders.id)', " +
-                    "ref_request_id BIGINT DEFAULT NULL COMMENT '关联请求ID(after_sales_requests.id)', " +
-                    "status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/PROCESSING/DONE', " +
-                    "assigned_by BIGINT DEFAULT NULL COMMENT '指派人用户ID', " +
-                    "assigned_at DATETIME DEFAULT NULL COMMENT '指派时间', " +
-                    "done_at DATETIME DEFAULT NULL COMMENT '完成时间', " +
-                    "note VARCHAR(500) DEFAULT NULL COMMENT '备注', " +
-                    "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
-                    "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
-                    "INDEX idx_system_status (system_id, status), " +
-                    "INDEX idx_assignee_status (assignee_user_id, status), " +
-                    "INDEX idx_customer_relation (customer_relation_id), " +
-                    "INDEX idx_ref_request (ref_request_id)" +
-                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队服务指派表'");
-            log.info("迁移438: 已创建 supply_service_assignment 表");
-        } catch (Exception e) {
-            log.warn("迁移438: 创建 supply_service_assignment 表失败(可能已存在): {}", e.getMessage());
-        }
-        // 幂等补列:兼容历史库中已存在但列不全的表
-        try {
-            ensureColumn("supply_service_assignment", "customer_relation_id",
-                    "BIGINT DEFAULT NULL COMMENT '客户归属关系ID(supply_customer_relation.id)'");
-            ensureColumn("supply_service_assignment", "assignee_user_id",
-                    "BIGINT DEFAULT NULL COMMENT '被指派成员用户ID(users.id),NULL=待指派'");
-            ensureColumn("supply_service_assignment", "service_type",
-                    "VARCHAR(32) NOT NULL DEFAULT 'AFTER_SALE' COMMENT '服务类型: AFTER_SALE/CONSULT/ACTIVITY_SUPPORT'");
-            ensureColumn("supply_service_assignment", "ref_order_id",
-                    "BIGINT DEFAULT NULL COMMENT '关联订单ID(product_orders.id)'");
-            ensureColumn("supply_service_assignment", "ref_request_id",
-                    "BIGINT DEFAULT NULL COMMENT '关联请求ID(after_sales_requests.id)'");
-            ensureColumn("supply_service_assignment", "status",
-                    "VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/PROCESSING/DONE'");
-            ensureColumn("supply_service_assignment", "assigned_by",
-                    "BIGINT DEFAULT NULL COMMENT '指派人用户ID'");
-            ensureColumn("supply_service_assignment", "assigned_at",
-                    "DATETIME DEFAULT NULL COMMENT '指派时间'");
-            ensureColumn("supply_service_assignment", "done_at",
-                    "DATETIME DEFAULT NULL COMMENT '完成时间'");
-            ensureColumn("supply_service_assignment", "note",
-                    "VARCHAR(500) DEFAULT NULL COMMENT '备注'");
-            log.info("迁移438: supply_service_assignment 表字段已就绪");
-        } catch (Exception e) {
-            log.warn("迁移438: supply_service_assignment 表补列失败: {}", e.getMessage());
+            jdbcTemplate.execute("DROP TABLE IF EXISTS product_sku_combo_items");
+            log.info("迁移441: 已删除 product_sku_combo_items 表");
+        } catch (Exception e) {
+            log.warn("迁移441: 删除 product_sku_combo_items 表失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移442:删除旧 bundle 商品组成明细表 product_bundle_items。
+     * <p>新模型:bundle 机制移除,套餐=独立商品,内容在 product_combo_items(迁移437)。线上该表 0 数据,安全 DROP。
+     */
+    private void dropTableBundleItems() {
+        try {
+            jdbcTemplate.execute("DROP TABLE IF EXISTS product_bundle_items");
+            log.info("迁移442: 已删除 product_bundle_items 表");
+        } catch (Exception e) {
+            log.warn("迁移442: 删除 product_bundle_items 表失败: {}", e.getMessage());
         }
     }
 
@@ -15043,19 +15038,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移426: 创建 attribution_reports 表失败(可能已存在): {}", e.getMessage());
         }
 
-        // 迁移435: product_bundle_items 增加套餐SKU锚点列(订单套餐自动定价)
-        // bundle_sku_id 非空 = 定价套餐行(套餐价取该SKU.price,CartService/ProductOrderService 匹配用);
-        // 为空 = 仅出库联动行(InventoryService 语义,不参与定价)。
-        ensureColumn("product_bundle_items", "bundle_sku_id",
-                "BIGINT NULL COMMENT '套餐SKU锚点:非空=订单套餐自动定价(套餐价取该SKU.price);空=仅出库联动'");
-        // 同时放开 bundle_product_id 可空:定价套餐行置 NULL,避免 InventoryService
-        // (按 bundle_product_id 关联、仅 productType=bundle 商品)误触发出库联动。
-        try {
-            jdbcTemplate.execute("ALTER TABLE product_bundle_items MODIFY COLUMN bundle_product_id BIGINT NULL COMMENT '套餐商品ID(出库联动行=套餐商品ID;定价套餐行置NULL)'");
-            log.info("迁移435: product_bundle_items.bundle_product_id 已放开可空");
-        } catch (Exception e) {
-            log.warn("迁移427: 放开 bundle_product_id 可空失败(可能已放开): {}", e.getMessage());
-        }
+
     }
 
     private static String text(JsonNode row, String field) {

+ 25 - 23
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java

@@ -1,7 +1,8 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.ProductBundleItem;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.service.ComboService;
 import com.etotem.cfc.service.InventoryService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -21,6 +22,8 @@ public class AdminInventoryController {
 
     @Resource
     private InventoryService inventoryService;
+    @Resource
+    private ComboService comboService;
 
     // ========== 入库单 ==========
 
@@ -150,32 +153,31 @@ public class AdminInventoryController {
 
     // ========== 套餐商品 ==========
 
-    @Operation(summary = "配置套餐商品组成")
-    @PostMapping("/bundle/items")
-    public Result<String> saveBundleItems(@RequestBody Map<String, Object> params) {
-        Long bundleProductId = ParamUtils.getLong(params.get("bundleProductId"));
-        if (bundleProductId == null) return Result.error("bundleProductId不能为空");
+    @Operation(summary = "保存套餐内容")
+    @PostMapping("/combo/items")
+    public Result<String> saveComboItems(@RequestBody Map<String, Object> params) {
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        if (comboSkuId == null) return Result.error("comboSkuId不能为空");
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
-        return inventoryService.saveBundleItems(bundleProductId, items);
-    }
-
-    @Operation(summary = "查询套餐商品组成")
-    @PostMapping("/bundle/items/list")
-    public Result<List<ProductBundleItem>> listBundleItems(@RequestBody Map<String, Object> params) {
-        Long bundleProductId = ParamUtils.getLong(params.get("bundleProductId"));
-        if (bundleProductId == null) return Result.error("bundleProductId不能为空");
-        return inventoryService.listBundleItems(bundleProductId);
+        try {
+            comboService.saveComboItems(comboSkuId, items);
+            return Result.success("ok");
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
     }
 
-    @Operation(summary = "检查套餐库存")
-    @PostMapping("/bundle/check-stock")
-    public Result<String> checkBundleStock(@RequestBody Map<String, Object> params) {
-        Long bundleProductId = ParamUtils.getLong(params.get("bundleProductId"));
-        Integer quantity = params.get("quantity") != null
-                ? Integer.valueOf(params.get("quantity").toString()) : 1;
-        if (bundleProductId == null) return Result.error("bundleProductId不能为空");
-        return inventoryService.checkBundleStock(bundleProductId, quantity);
+    @Operation(summary = "查询套餐内容")
+    @PostMapping("/combo/items/list")
+    public Result<List<ProductComboItem>> listComboItems(@RequestBody Map<String, Object> params) {
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        if (comboSkuId == null) return Result.error("comboSkuId不能为空");
+        try {
+            return Result.success(comboService.listComboItems(comboSkuId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
     }
 
     // ========== 手动出库 ==========

+ 0 - 15
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java

@@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 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 org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -26,9 +25,6 @@ public class AdminProductPickerController {
 
     @Resource
     private com.etotem.cfc.mapper.ProductSkuMapper productSkuMapper;
-    @Resource
-    private com.etotem.cfc.mapper.ProductSkuComboItemMapper comboItemMapper;
-
     @PostMapping("/linked/list")
     public Result<Map<String, Object>> linkedList(@RequestBody Map<String, Object> params) {
         Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
@@ -70,19 +66,8 @@ public class AdminProductPickerController {
                         .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());

+ 0 - 91
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/SupplyTeamController.java

@@ -1,9 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.SupplyServiceAssignment;
 import com.etotem.cfc.service.SupplyCustomerRelationService;
-import com.etotem.cfc.service.SupplyServiceAssignmentService;
 import com.etotem.cfc.service.SupplySystemService;
 import org.springframework.web.bind.annotation.*;
 
@@ -22,9 +20,6 @@ public class SupplyTeamController {
     @Resource
     private SupplySystemService supplySystemService;
 
-    @Resource
-    private SupplyServiceAssignmentService supplyServiceAssignmentService;
-
     /**
      * 查询上级供应商下的客户列表
      */
@@ -96,90 +91,4 @@ public class SupplyTeamController {
         Object relation = supplyCustomerRelationService.getRelationByFamily(systemId, familyId);
         return Result.success(relation);
     }
-
-    // ==================== T34 团队服务指派 ====================
-
-    /**
-     * 服务指派:把某客户(售后/咨询/活动支持)指派给团队成员跟进。
-     */
-    @PostMapping("/service-assign")
-    public Result<SupplyServiceAssignment> serviceAssign(
-            @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
-        Long systemId = ParamUtils.getLong(params.get("systemId"));
-        if (systemId == null) {
-            return Result.error("systemId不能为空");
-        }
-        // 越权防护:当前用户必须为体系的 admin/成员
-        if (!supplySystemService.isMemberOrAdmin(systemId, userId)) {
-            return Result.error("无权访问该团队");
-        }
-        Long customerRelationId = ParamUtils.getLong(params.get("customerRelationId"));
-        Long assigneeUserId = ParamUtils.getLong(params.get("assigneeUserId"));
-        String serviceType = (String) params.get("serviceType");
-        Long refOrderId = ParamUtils.getLong(params.get("refOrderId"));
-        Long refRequestId = ParamUtils.getLong(params.get("refRequestId"));
-        String note = (String) params.get("note");
-        return supplyServiceAssignmentService.assign(systemId, customerRelationId, assigneeUserId,
-                serviceType, refOrderId, refRequestId, note, userId);
-    }
-
-    /**
-     * 服务任务列表:scope=mine(默认,派给我的)/ scope=team(团队全部,需团队成员身份)。
-     */
-    @PostMapping("/service-tasks")
-    public Result<List<Map<String, Object>>> serviceTasks(
-            @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
-        Long systemId = ParamUtils.getLong(params.get("systemId"));
-        String scope = (String) params.get("scope");
-        String status = (String) params.get("status");
-        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
-        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        List<Map<String, Object>> tasks = supplyServiceAssignmentService.listTasks(
-                systemId, scope, status, userId, page, size);
-        return Result.success(tasks);
-    }
-
-    /**
-     * 某客户的服务记录(按客户归属关系ID)。
-     */
-    @PostMapping("/customer/{id}/services")
-    public Result<List<Map<String, Object>>> customerServices(
-            @PathVariable("id") Long id,
-            @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
-        List<Map<String, Object>> services = supplyServiceAssignmentService.listCustomerServices(id, userId);
-        return Result.success(services);
-    }
-
-    /**
-     * 更新服务任务状态(PENDING/PROCESSING/DONE)。
-     */
-    @PostMapping("/service-task/update")
-    public Result<String> updateServiceTask(
-            @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
-        Long id = ParamUtils.getLong(params.get("id"));
-        String status = (String) params.get("status");
-        String note = (String) params.get("note");
-        return supplyServiceAssignmentService.updateStatus(id, status, note, userId);
-    }
-
-    /**
-     * 指派候选人列表(团队成员 + 商品服务人)。
-     */
-    @PostMapping("/service-candidates")
-    public Result<List<Map<String, Object>>> serviceCandidates(
-            @RequestBody Map<String, Object> params,
-            @RequestAttribute("userId") Long userId) {
-        Long systemId = ParamUtils.getLong(params.get("systemId"));
-        if (systemId == null) {
-            return Result.error("systemId不能为空");
-        }
-        if (!supplySystemService.isMemberOrAdmin(systemId, userId)) {
-            return Result.error("无权访问该团队");
-        }
-        return Result.success(supplyServiceAssignmentService.listCandidates(systemId, userId));
-    }
 }

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller.cart;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.dto.CartListResultDTO;
+import com.etotem.cfc.pricing.ComboOfferResult;
 import com.etotem.cfc.service.CartService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
@@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
@@ -66,4 +68,32 @@ public class CartController {
         int count = cartService.getCartCount(userId);
         return Result.success(count);
     }
+
+    @PostMapping("/combo-offer")
+    public Result<ComboOfferResult> comboOffer(@RequestBody Map<String, Object> params,
+                                               @RequestAttribute("userId") Long userId) {
+        List<Long> checkedCartItemIds = parseIdList(params.get("checkedCartItemIds"));
+        ComboOfferResult result = cartService.computeComboOffer(userId, checkedCartItemIds);
+        return Result.success(result);
+    }
+
+    @PostMapping("/replace-combo")
+    public Result<Void> replaceCombo(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("userId") Long userId) {
+        List<Long> checkedCartItemIds = parseIdList(params.get("checkedCartItemIds"));
+        Long comboSkuId = ParamUtils.getLong(params.get("comboSkuId"));
+        return cartService.replaceCartWithCombo(userId, checkedCartItemIds, comboSkuId);
+    }
+
+    private List<Long> parseIdList(Object obj) {
+        List<Long> ids = new ArrayList<>();
+        if (obj instanceof List) {
+            for (Object o : (List<?>) obj) {
+                if (o == null) continue;
+                if (o instanceof Number) ids.add(((Number) o).longValue());
+                else ids.add(Long.valueOf(o.toString()));
+            }
+        }
+        return ids;
+    }
 }

+ 0 - 42
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -5,7 +5,6 @@ import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.entity.ProductSkuComboItem;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PpointService;
@@ -120,47 +119,6 @@ public class ProductController {
             int groupId = 1;
 
             for (ProductSku sku : skus) {
-                // 组合 SKU:单独成组(groupName=商品组合),每个选项附带子项列表;不进入 isLinked / legacy 分支
-                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;
-                }
                 boolean isLinked = sku.getLinkedProductId() != null;
 
                 if (isLinked) {

+ 0 - 63
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -20,40 +20,6 @@ public class CartItemDTO {
     private String giftLevelCode;
     private String giftLevelName;
 
-    // 购物车项本身就是套餐商品(productType=bundle)时非空:展示套餐组成
-    private BundleInfo bundleInfo;
-
-    // 购物车项为子商品、且与其它子商品恰好凑成某套餐时非空:自动套餐模式
-    private BundleMatch bundleMatch;
-
-    @Data
-    public static class BundleInfo {
-        private Long bundleProductId;
-        private List<Long> childProductIds;
-        private List<String> childProductNames;
-        private Integer childQuantityTotal;
-        private Long bundlePrice;
-        private Integer singleTotalPrice;
-        private Integer saving;
-    }
-
-    @Data
-    public static class BundleMatch {
-        private Long bundleProductId;
-        private String bundleProductName;
-        private Integer matchedGroups;
-        private Long bundlePrice;
-        private Integer singleTotalPrice;
-        private Integer saving;
-        private List<BundleChild> children;
-    }
-
-    @Data
-    public static class BundleChild {
-        private Long childProductId;
-        private Integer quantity;
-    }
-
     @Data
     public static class GiftPromo {
         /** 送会员商品原价合计(分) */
@@ -70,33 +36,4 @@ public class CartItemDTO {
         private Integer remaining;
     }
 
-    @Data
-    public static class BundlePromo {
-        /** 是否命中至少一套套餐(节省额>0) */
-        private Boolean active;
-        /** 命中套餐部分的基础款原价合计(分) */
-        private Integer originalTotal;
-        /** 命中套餐价合计(分) */
-        private Integer payPrice;
-        /** 套餐节省额合计(分) */
-        private Integer discount;
-        /** 命中套餐明细 */
-        private List<BundleItemHit> bundleList;
-    }
-
-    @Data
-    public static class BundleItemHit {
-        /** 套餐锚点 SKU id */
-        private Long bundleSkuId;
-        /** 套餐展示名 */
-        private String bundleName;
-        /** 命中套数 */
-        private Integer times;
-        /** 命中部分原价合计(分) */
-        private Integer originalTotal;
-        /** 套餐价合计(分) */
-        private Integer bundlePrice;
-        /** 节省额(分) */
-        private Integer saving;
-    }
 }

+ 0 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/CartListResultDTO.java

@@ -9,7 +9,4 @@ public class CartListResultDTO {
     private List<CartItemDTO> items;
 
     private CartItemDTO.GiftPromo giftPromo;
-
-    /** 订单套餐自动定价优惠(product_bundle_items 定价行 bundle_sku_id 匹配) */
-    private CartItemDTO.BundlePromo bundlePromo;
 }

+ 22 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.etotem.cfc.entity.Product;
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
@@ -32,6 +33,18 @@ public class ProductDTO {
     private String dimensionWeights;
     private String memberEligible;
     private String rejectReason;
+    /** 热量(kcal/100g),NULL=未配置(小程序详情页无数据时隐藏整块营养信息) */
+    private BigDecimal caloriesKcal;
+    /** 蛋白质(g/100g),NULL=未配置 */
+    private BigDecimal proteinG;
+    /** 脂肪(g/100g),NULL=未配置 */
+    private BigDecimal fatG;
+    /** 碳水化合物(g/100g),NULL=未配置 */
+    private BigDecimal carbG;
+    /** 膳食纤维(g/100g),NULL=未配置 */
+    private BigDecimal fiberG;
+    /** 营养标签,逗号分隔(如"低糖,高蛋白"),NULL=无标签 */
+    private String nutritionTags;
     private Integer deliveryMethod;        // 配送方式: 1=快递 2=自提 3=两者皆可
     private List<String> visibleRoles;     // 可见角色列表,NULL=全部角色可见(已废弃,保留兼容)
     private String minVisibleLevel;        // 最低可见会员级别编码,NULL=全部可见
@@ -65,11 +78,11 @@ public class ProductDTO {
     // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
     private Map<String, Object> deliveryConfig;
 
-    // 套餐商品组成(仅 productType=bundle 时非空)
-    private List<BundleItemVO> bundleItems;
+    /** 套餐内容(新模型:套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级) */
+    private List<ComboItemVO> comboItems;
 
     @Data
-    public static class BundleItemVO {
+    public static class ComboItemVO {
         private Long childProductId;
         private String childProductName;
         private String coverImage;
@@ -121,6 +134,12 @@ public class ProductDTO {
         d.dimensionWeights = p.getDimensionWeights();
         d.memberEligible = p.getMemberEligible();
         d.rejectReason = p.getRejectReason();
+        d.caloriesKcal = p.getCaloriesKcal();
+        d.proteinG = p.getProteinG();
+        d.fatG = p.getFatG();
+        d.carbG = p.getCarbG();
+        d.fiberG = p.getFiberG();
+        d.nutritionTags = p.getNutritionTags();
         d.deliveryMethod = p.getDeliveryMethod();
         d.sortOrder = p.getSortOrder();
         d.categoryId = p.getCategoryId();

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java

@@ -13,4 +13,6 @@ public class ProductListQueryDTO {
     private String keyword;
     private Long categoryId;
     private Boolean growthRelated;   // true=仅返回成长档案关联商品
+    /** 营养标签筛选(逗号分隔标签中任一命中即返回,如"低糖"),NULL/空=不限 */
+    private String nutritionTag;
 }

+ 0 - 2
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductSkuDTO.java

@@ -22,7 +22,6 @@ public class ProductSkuDTO {
     private Integer linkedProductPrice;
     private String linkedProductImage;
     private Integer linkedProductStock;
-    private java.util.List<com.etotem.cfc.entity.ProductSkuComboItem> comboItems;
 
     public static ProductSkuDTO from(ProductSku sku) {
         if (sku == null) return null;
@@ -76,7 +75,6 @@ public class ProductSkuDTO {
         dto.setLinkedProductPrice(sku.getLinkedProductPrice());
         dto.setLinkedProductImage(sku.getLinkedProductImage());
         dto.setLinkedProductStock(sku.getLinkedProductStock());
-        dto.setComboItems(sku.getComboItems());
         return dto;
     }
 }

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -45,6 +45,24 @@ public class Product implements Serializable {
     /** JSON: {"body":0,"mind":0,"wisdom":0,"action":0,"wealth":0} */
     private String dimensionWeights;
 
+    /** 热量(kcal/100g),NULL=未配置 */
+    private java.math.BigDecimal caloriesKcal;
+
+    /** 蛋白质(g/100g),NULL=未配置 */
+    private java.math.BigDecimal proteinG;
+
+    /** 脂肪(g/100g),NULL=未配置 */
+    private java.math.BigDecimal fatG;
+
+    /** 碳水化合物(g/100g),NULL=未配置 */
+    private java.math.BigDecimal carbG;
+
+    /** 膳食纤维(g/100g),NULL=未配置 */
+    private java.math.BigDecimal fiberG;
+
+    /** 营养标签,逗号分隔(如"低糖,高蛋白,无添加"),NULL=无标签 */
+    private String nutritionTags;
+
     /** 禁忌基础疾病,逗号分隔(如"慢性肾病,肝病"),NULL=无禁忌 */
     private String contraindicatedDiseases;
 

+ 0 - 23
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductBundleItem.java

@@ -1,23 +0,0 @@
-package com.etotem.cfc.entity;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-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_bundle_items")
-public class ProductBundleItem implements Serializable {
-    @TableId(type = IdType.AUTO)
-    private Long id;
-    private Long bundleProductId;
-    /** 套餐SKU锚点:非空=定价套餐行(套餐价取该SKU.price,用于订单套餐自动定价匹配);为空=仅出库联动行 */
-    private Long bundleSkuId;
-    private Long childProductId;
-    private Long childSkuId;
-    private Integer quantity;
-    private Date createdAt;
-}

+ 7 - 16
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductComboItem.java

@@ -9,12 +9,6 @@ import lombok.Data;
 import java.io.Serializable;
 import java.util.Date;
 
-/**
- * 商品套餐内容(商品级定义、SKU 级键控):
- * 一个套餐商品包含哪些子商品(子商品 SKU × 数量)。
- * 归属列 combo_sku_id = 套餐 SKU(套餐商品通常单规格,即套餐唯一 SKU)。
- * 不引用旧命名(product_sku_combo_items / product_bundle_items),避免与旧机制混淆。
- */
 @Data
 @TableName("product_combo_items")
 public class ProductComboItem implements Serializable {
@@ -22,25 +16,22 @@ public class ProductComboItem implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
-    /** 套餐 SKU id(内容归属) */
+    /** 套餐SKU ID(内容挂套餐SKU级) */
     private Long comboSkuId;
-
-    /** 子商品 */
+    /** 子商品ID */
     private Long childProductId;
-
-    /** 子商品 SKU(必填,精确到规格) */
+    /** 子商品SKU ID(精确到规格,必填) */
     private Long childSkuId;
-
-    /** 数量(>0) */
+    /** 该子商品数量 */
     private Integer quantity;
-
     private Date createdAt;
 
-    /* ---- 展示字段(不落库,listComboItems 回填) ---- */
     @TableField(exist = false)
     private String childProductName;
     @TableField(exist = false)
     private String childProductImage;
     @TableField(exist = false)
+    private Integer childProductPrice;
+    @TableField(exist = false)
     private String childSkuSpecs;
-}
+}

+ 0 - 3
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java

@@ -31,9 +31,6 @@ public class ProductSku implements Serializable {
     private Date createTime;
     private Date updateTime;
 
-    @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)

+ 0 - 33
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSkuComboItem.java

@@ -1,33 +0,0 @@
-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;
-}

+ 0 - 62
cfc-backend/src/main/java/com/etotem/cfc/entity/SupplyServiceAssignment.java

@@ -1,62 +0,0 @@
-package com.etotem.cfc.entity;
-
-import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableId;
-import com.baomidou.mybatisplus.annotation.TableName;
-import lombok.Data;
-
-import java.io.Serializable;
-import java.util.Date;
-
-/**
- * 团队服务指派(T34)。
- *
- * <p>把服务性内容(售后/咨询/活动支持)指派给团队成员跟进,是「团队管理」核心能力。
- * 指派对象可为团队成员或商品服务人(ProductServicePerson),见 {@code serviceType} 与 {@code assigneeUserId}。</p>
- *
- * <p>状态机:PENDING(待处理)→ PROCESSING(处理中)→ DONE(已完成)。</p>
- */
-@Data
-@TableName("supply_service_assignment")
-public class SupplyServiceAssignment implements Serializable {
-
-    @TableId(type = IdType.AUTO)
-    private Long id;
-
-    /** 团队体系ID(supply_system.id),即 teamId */
-    private Long systemId;
-
-    /** 客户归属关系ID(supply_customer_relation.id),可为空(自动生成的售后任务可能尚无归属关系) */
-    private Long customerRelationId;
-
-    /** 被指派成员用户ID(users.id);NULL=待指派 */
-    private Long assigneeUserId;
-
-    /** 服务类型:AFTER_SALE(售后)/ CONSULT(咨询)/ ACTIVITY_SUPPORT(活动支持) */
-    private String serviceType;
-
-    /** 关联订单ID(product_orders.id),可为空 */
-    private Long refOrderId;
-
-    /** 关联请求ID(after_sales_requests.id / 咨询请求ID),可为空 */
-    private Long refRequestId;
-
-    /** 状态:PENDING / PROCESSING / DONE */
-    private String status;
-
-    /** 指派人用户ID */
-    private Long assignedBy;
-
-    /** 指派时间 */
-    private Date assignedAt;
-
-    /** 完成时间 */
-    private Date doneAt;
-
-    /** 备注 */
-    private String note;
-
-    private Date createdAt;
-
-    private Date updatedAt;
-}

+ 0 - 9
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductBundleItemMapper.java

@@ -1,9 +0,0 @@
-package com.etotem.cfc.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.etotem.cfc.entity.ProductBundleItem;
-import org.apache.ibatis.annotations.Mapper;
-
-@Mapper
-public interface ProductBundleItemMapper extends BaseMapper<ProductBundleItem> {
-}

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductComboItemMapper.java

@@ -6,4 +6,4 @@ import org.apache.ibatis.annotations.Mapper;
 
 @Mapper
 public interface ProductComboItemMapper extends BaseMapper<ProductComboItem> {
-}
+}

+ 0 - 7
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductSkuComboItemMapper.java

@@ -1,7 +0,0 @@
-package com.etotem.cfc.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.etotem.cfc.entity.ProductSkuComboItem;
-
-public interface ProductSkuComboItemMapper extends BaseMapper<ProductSkuComboItem> {
-}

+ 0 - 9
cfc-backend/src/main/java/com/etotem/cfc/mapper/SupplyServiceAssignmentMapper.java

@@ -1,9 +0,0 @@
-package com.etotem.cfc.mapper;
-
-import com.baomidou.mybatisplus.core.mapper.BaseMapper;
-import com.etotem.cfc.entity.SupplyServiceAssignment;
-import org.apache.ibatis.annotations.Mapper;
-
-@Mapper
-public interface SupplyServiceAssignmentMapper extends BaseMapper<SupplyServiceAssignment> {
-}

+ 0 - 22
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleChild.java

@@ -1,22 +0,0 @@
-package com.etotem.cfc.pricing;
-
-import lombok.Data;
-
-/**
- * 套餐基础款组成:child_product_id 必填;child_sku_id 可空(空=按商品聚合所有 SKU 行);quantity=组成数量。
- */
-@Data
-public class BundleChild {
-    private Long childProductId;
-    private Long childSkuId;
-    private Integer quantity;
-
-    public BundleChild() {
-    }
-
-    public BundleChild(Long childProductId, Long childSkuId, Integer quantity) {
-        this.childProductId = childProductId;
-        this.childSkuId = childSkuId;
-        this.quantity = quantity;
-    }
-}

+ 0 - 31
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDef.java

@@ -1,31 +0,0 @@
-package com.etotem.cfc.pricing;
-
-import lombok.Data;
-
-import java.util.List;
-
-/**
- * 定价套餐定义(来自 product_bundle_items 中 bundle_sku_id 非空的行,按套餐锚点 SKU 聚合)。
- * bundlePrice 取锚点 SKU 的 price;children 为该套餐的基础款组成。
- */
-@Data
-public class BundleDef {
-    /** 套餐锚点 SKU id(product_skus.id) */
-    private Long bundleSkuId;
-    /** 套餐展示名(取锚点 SKU 的 label,缺省回落 specs) */
-    private String bundleName;
-    /** 套餐价(分) */
-    private Integer bundlePrice;
-    /** 基础款组成(每行: child_product_id + 可选 child_sku_id + quantity) */
-    private List<BundleChild> children;
-
-    public BundleDef() {
-    }
-
-    public BundleDef(Long bundleSkuId, String bundleName, Integer bundlePrice, List<BundleChild> children) {
-        this.bundleSkuId = bundleSkuId;
-        this.bundleName = bundleName;
-        this.bundlePrice = bundlePrice;
-        this.children = children;
-    }
-}

+ 0 - 32
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleDiscountResult.java

@@ -1,32 +0,0 @@
-package com.etotem.cfc.pricing;
-
-import lombok.Data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * 订单套餐定价结果:active=至少命中一套(节省额>0)。
- * originalTotal=命中套餐部分的基础款原价合计;payPrice=命中套餐价合计;discount=节省额合计。
- * bundleList=命中套餐明细(含套数/原价/套餐价/节省)。
- */
-@Data
-public class BundleDiscountResult {
-    private boolean active;
-    private Integer originalTotal;
-    private Integer payPrice;
-    private Integer discount;
-    private List<BundleHit> bundleList;
-
-    public BundleDiscountResult() {
-        this.active = false;
-        this.originalTotal = 0;
-        this.payPrice = 0;
-        this.discount = 0;
-        this.bundleList = new ArrayList<>();
-    }
-
-    public static BundleDiscountResult inactive() {
-        return new BundleDiscountResult();
-    }
-}

+ 0 - 33
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundleHit.java

@@ -1,33 +0,0 @@
-package com.etotem.cfc.pricing;
-
-import lombok.Data;
-
-/**
- * 命中的套餐(一次套餐 = 该套餐按组成完整消费 1 套)。
- */
-@Data
-public class BundleHit {
-    private Long bundleSkuId;
-    private String bundleName;
-    /** 命中套数 */
-    private Integer times;
-    /** 命中部分的基础款原价合计(消费的行 unitPrice×qty) */
-    private Integer originalTotal;
-    /** 套餐总价(bundlePrice × times) */
-    private Integer bundlePrice;
-    /** 节省额(originalTotal − bundlePrice) */
-    private Integer saving;
-
-    public BundleHit() {
-    }
-
-    public BundleHit(Long bundleSkuId, String bundleName, Integer times,
-                     Integer originalTotal, Integer bundlePrice, Integer saving) {
-        this.bundleSkuId = bundleSkuId;
-        this.bundleName = bundleName;
-        this.times = times;
-        this.originalTotal = originalTotal;
-        this.bundlePrice = bundlePrice;
-        this.saving = saving;
-    }
-}

+ 0 - 216
cfc-backend/src/main/java/com/etotem/cfc/pricing/BundlePricingCalculator.java

@@ -1,216 +0,0 @@
-package com.etotem.cfc.pricing;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 订单套餐自动定价核心算法(纯计算,无外部依赖,便于单测)。
- *
- * 规则:
- * 1. 商品行按 (productId, skuId) 聚合为「池」;
- * 2. 多套餐可凑时贪心取「节省额最大」者,每轮命中 1 套并扣减组成数量;
- * 3. 节省额 = 该套消费的基础款单价合计 − 套餐价,仅 >0 才触发(避免会员价低于套餐价反而更贵);
- * 4. 超出套餐组成数量的数量留给调用方按单品兜底,本算法不参与兜底计价。
- */
-public class BundlePricingCalculator {
-
-    /** 池内一行:某 (productId, skuId) 剩余数量与下单口径单价 */
-    private static class PoolEntry {
-        final Long productId;
-        final Long skuId;
-        int quantity;
-        final int unitPrice;
-
-        PoolEntry(Long productId, Long skuId, int quantity, int unitPrice) {
-            this.productId = productId;
-            this.skuId = skuId;
-            this.quantity = quantity;
-            this.unitPrice = unitPrice;
-        }
-    }
-
-    /** 某套餐的消费方案:从池中扣哪些行、各扣多少 */
-    private static class ConsumePlan {
-        final BundleDef def;
-        final List<PoolEntry> entries = new ArrayList<>();
-        final List<Integer> quantities = new ArrayList<>();
-        int originalTotal; // 该套消费的基础款单价合计
-
-        ConsumePlan(BundleDef def) {
-            this.def = def;
-        }
-
-        int saving() {
-            return originalTotal - safePrice(def.getBundlePrice());
-        }
-
-        int consumedOf(PoolEntry entry) {
-            int total = 0;
-            for (int i = 0; i < entries.size(); i++) {
-                if (entries.get(i) == entry) {
-                    total += quantities.get(i);
-                }
-            }
-            return total;
-        }
-    }
-
-    /**
-     * 计算订单内套餐优惠。
-     *
-     * @param lines 下单/结算口径商品行(productId/skuId 为解析后身份,unitPrice 为结算单价,单位分)
-     * @param defs  定价套餐定义(product_bundle_items 中 bundle_sku_id 非空的行按锚点聚合)
-     */
-    public BundleDiscountResult compute(List<LineItem> lines, List<BundleDef> defs) {
-        BundleDiscountResult result = new BundleDiscountResult();
-        if (lines == null || lines.isEmpty() || defs == null || defs.isEmpty()) {
-            return result;
-        }
-        Map<String, PoolEntry> pool = aggregate(lines);
-
-        // 同一套餐锚点命中多套时合并为一条 BundleHit(times>1)
-        Map<Long, BundleHit> hitByBundle = new LinkedHashMap<>();
-        while (true) {
-            ConsumePlan best = bestBundle(pool, defs);
-            if (best == null) {
-                break;
-            }
-            for (int i = 0; i < best.entries.size(); i++) {
-                PoolEntry entry = best.entries.get(i);
-                entry.quantity -= best.quantities.get(i);
-                if (entry.quantity < 0) {
-                    entry.quantity = 0;
-                }
-            }
-            pool.values().removeIf(e -> e.quantity <= 0);
-
-            BundleHit hit = hitByBundle.get(best.def.getBundleSkuId());
-            if (hit == null) {
-                hit = new BundleHit();
-                hit.setBundleSkuId(best.def.getBundleSkuId());
-                hit.setBundleName(best.def.getBundleName());
-                hit.setTimes(0);
-                hit.setOriginalTotal(0);
-                hit.setBundlePrice(0);
-                hit.setSaving(0);
-                hitByBundle.put(best.def.getBundleSkuId(), hit);
-            }
-            hit.setTimes(hit.getTimes() + 1);
-            hit.setOriginalTotal(hit.getOriginalTotal() + best.originalTotal);
-            hit.setBundlePrice(hit.getBundlePrice() + safePrice(best.def.getBundlePrice()));
-            hit.setSaving(hit.getSaving() + best.saving());
-        }
-
-        if (!hitByBundle.isEmpty()) {
-            result.setActive(true);
-            result.setBundleList(new ArrayList<>(hitByBundle.values()));
-            for (BundleHit hit : result.getBundleList()) {
-                result.setOriginalTotal(result.getOriginalTotal() + hit.getOriginalTotal());
-                result.setPayPrice(result.getPayPrice() + hit.getBundlePrice());
-                result.setDiscount(result.getDiscount() + hit.getSaving());
-            }
-        }
-        return result;
-    }
-
-    /** 聚合:同 (productId, skuId) 归并数量;单价取该聚合内首个非零值 */
-    private Map<String, PoolEntry> aggregate(List<LineItem> lines) {
-        Map<String, PoolEntry> pool = new LinkedHashMap<>();
-        for (LineItem line : lines) {
-            if (line == null || line.getProductId() == null
-                    || line.getQuantity() == null || line.getQuantity() <= 0) {
-                continue;
-            }
-            String key = line.getProductId() + "|" + (line.getSkuId() == null ? "" : line.getSkuId());
-            int unitPrice = safePrice(line.getUnitPrice());
-            PoolEntry entry = pool.get(key);
-            if (entry == null) {
-                pool.put(key, new PoolEntry(line.getProductId(), line.getSkuId(), line.getQuantity(), unitPrice));
-            } else {
-                entry.quantity += line.getQuantity();
-            }
-        }
-        return pool;
-    }
-
-    /** 在池上贪心找当前可凑且节省额最大的套餐(每轮只命中 1 套) */
-    private ConsumePlan bestBundle(Map<String, PoolEntry> pool, List<BundleDef> defs) {
-        ConsumePlan best = null;
-        int bestSaving = 0;
-        for (BundleDef def : defs) {
-            if (def == null || def.getBundlePrice() == null || def.getBundlePrice() <= 0) {
-                continue;
-            }
-            if (def.getChildren() == null || def.getChildren().isEmpty()) {
-                continue; // 无组成定义,防御脏数据
-            }
-            ConsumePlan trial = trialConsume(pool, def);
-            if (trial == null || trial.saving() <= 0) {
-                continue;
-            }
-            // 严格大于:节省额相同时取先出现的套餐(确定性)
-            if (trial.saving() > bestSaving) {
-                bestSaving = trial.saving();
-                best = trial;
-            }
-        }
-        return best;
-    }
-
-    /**
-     * 试凑一套套餐:不修改池,返回消费方案;任一基础款不足则返回 null。
-     * 同一 PoolEntry 可能被不同 child 消费(跨 child 共行),用 drawn 防重复扣。
-     */
-    private ConsumePlan trialConsume(Map<String, PoolEntry> pool, BundleDef def) {
-        ConsumePlan plan = new ConsumePlan(def);
-        Map<PoolEntry, Integer> drawn = new HashMap<>();
-        for (BundleChild child : def.getChildren()) {
-            if (child == null || child.getChildProductId() == null) {
-                return null;
-            }
-            int needQty = child.getQuantity() == null || child.getQuantity() <= 0 ? 1 : child.getQuantity();
-            boolean skuSpecified = child.getChildSkuId() != null;
-            int remaining = needQty;
-            for (PoolEntry entry : pool.values()) {
-                if (remaining <= 0) {
-                    break;
-                }
-                if (!matches(entry, child, skuSpecified)) {
-                    continue;
-                }
-                int already = drawn.getOrDefault(entry, 0);
-                int available = entry.quantity - already;
-                if (available <= 0) {
-                    continue;
-                }
-                int take = Math.min(available, remaining);
-                plan.entries.add(entry);
-                plan.quantities.add(take);
-                drawn.put(entry, already + take);
-                plan.originalTotal += entry.unitPrice * take;
-                remaining -= take;
-            }
-            if (remaining > 0) {
-                return null; // 该基础款数量不足
-            }
-        }
-        return plan;
-    }
-
-    private boolean matches(PoolEntry entry, BundleChild child, boolean skuSpecified) {
-        if (!entry.productId.equals(child.getChildProductId())) {
-            return false;
-        }
-        if (skuSpecified) {
-            return entry.skuId != null && entry.skuId.equals(child.getChildSkuId());
-        }
-        return true; // 未指定 childSkuId:按商品聚合,同商品任意 SKU 行都算
-    }
-
-    private static int safePrice(Integer price) {
-        return price == null || price < 0 ? 0 : price;
-    }
-}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboChild.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/** 套餐内容子项:子商品 + 精确到子SKU + 数量 */
+@Data
+public class ComboChild {
+    private Long childProductId;
+    private Long childSkuId;
+    private Integer quantity;
+}

+ 86 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferCalculator.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.pricing;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 购物车套餐 offer 纯计算器(无 Spring 依赖,单测友好)。
+ *
+ * <p>算法:按 {@code (productId, skuId)} 聚合 {@link LineItem} 为池;对每个套餐定义计算
+ * 可覆盖组数 {@code groups = min over children(floor(haveQty/needQty))}(haveQty 严格按
+ * child_sku_id 匹配,缺失即 0);{@code originalTotal = Σ(组内 child 消耗数量×单价)×groups};
+ * {@code saving = originalTotal - comboPrice×groups};仅当 {@code saving>0 && groups>0} 才候选;
+ * 取 saving 最大(同额取先出现者),同一套餐重复只报一次;无命中返回 inactive。
+ */
+public class ComboOfferCalculator {
+
+    public ComboOfferResult compute(List<LineItem> lines, List<ComboOfferDef> defs) {
+        if (lines == null || lines.isEmpty() || defs == null || defs.isEmpty()) {
+            return ComboOfferResult.inactive();
+        }
+        // 池:key=(productId, skuId) -> {qty, unitPrice}
+        Map<String, PoolEntry> pool = new HashMap<>();
+        for (LineItem line : lines) {
+            if (line == null || line.getProductId() == null || line.getQuantity() == null) continue;
+            String key = key(line.getProductId(), line.getSkuId());
+            PoolEntry e = pool.get(key);
+            if (e == null) {
+                e = new PoolEntry();
+                e.qty = line.getQuantity();
+                e.unitPrice = line.getUnitPrice() != null ? line.getUnitPrice() : 0;
+                pool.put(key, e);
+            } else {
+                e.qty += line.getQuantity();
+            }
+        }
+
+        ComboOfferResult best = null;
+        Set<Long> seen = new HashSet<>();
+        for (ComboOfferDef def : defs) {
+            if (def == null || def.getComboSkuId() == null
+                    || def.getChildren() == null || def.getChildren().isEmpty()) continue;
+            if (!seen.add(def.getComboSkuId())) continue;   // 同一套餐重复只报一次
+            int groups = Integer.MAX_VALUE;
+            int perGroupOriginal = 0;
+            boolean valid = true;
+            for (ComboChild child : def.getChildren()) {
+                if (child == null || child.getChildProductId() == null) { valid = false; break; }
+                PoolEntry e = pool.get(key(child.getChildProductId(), child.getChildSkuId()));
+                int need = child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1;
+                int have = e != null ? e.qty : 0;
+                groups = Math.min(groups, have / need);
+                perGroupOriginal += need * (e != null ? e.unitPrice : 0);
+            }
+            if (!valid || groups == Integer.MAX_VALUE || groups <= 0) continue;
+            int comboPrice = def.getComboPrice() != null ? def.getComboPrice() : 0;
+            int originalTotal = perGroupOriginal * groups;
+            int saving = originalTotal - comboPrice * groups;
+            if (saving <= 0) continue;   // 套餐价>=总和:不提示(原价结算)
+            ComboOfferResult cand = new ComboOfferResult();
+            cand.setActive(true);
+            cand.setComboSkuId(def.getComboSkuId());
+            cand.setComboName(def.getComboName());
+            cand.setGroups(groups);
+            cand.setOriginalTotal(originalTotal);
+            cand.setComboPrice(comboPrice);
+            cand.setSaving(saving);
+            if (best == null || cand.getSaving() > best.getSaving()) {
+                best = cand;   // 同额不替换 → 保留先出现者
+            }
+        }
+        return best != null ? best : ComboOfferResult.inactive();
+    }
+
+    private String key(Long productId, Long skuId) {
+        return productId + "_" + (skuId == null ? "" : skuId);
+    }
+
+    private static class PoolEntry {
+        int qty;
+        int unitPrice;
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferDef.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+import java.util.List;
+
+/** 购物车套餐 offer 定义:套餐SKU锚点 + 展示名 + 套餐价(分) + 内容清单 */
+@Data
+public class ComboOfferDef {
+    private Long comboSkuId;
+    private String comboName;
+    private Integer comboPrice;
+    private List<ComboChild> children;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/pricing/ComboOfferResult.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.pricing;
+
+import lombok.Data;
+
+/**
+ * 购物车套餐 offer 检测结果(只报最优一个):命中(节省额>0)时 active=true,
+ * 携带套餐SKU/展示名/可替换组数/原价合计/套餐价合计/节省额。
+ */
+@Data
+public class ComboOfferResult {
+    private boolean active;
+    private Long comboSkuId;
+    private String comboName;
+    private int groups;
+    private int originalTotal;
+    private int comboPrice;
+    private int saving;
+
+    public static ComboOfferResult inactive() {
+        ComboOfferResult r = new ComboOfferResult();
+        r.setActive(false);
+        return r;
+    }
+}

+ 0 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java

@@ -6,7 +6,6 @@ import com.etotem.cfc.entity.AfterSalesRequest;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
-import com.etotem.cfc.service.SupplyServiceAssignmentService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Service;
@@ -25,9 +24,6 @@ public class AfterSalesService {
     @Resource
     private ProductOrderMapper productOrderMapper;
 
-    @Resource
-    private SupplyServiceAssignmentService supplyServiceAssignmentService;
-
     private static final Logger log = LoggerFactory.getLogger(AfterSalesService.class);
 
     public Result<AfterSalesRequest> create(AfterSalesRequest request, Long userId) {
@@ -64,10 +60,6 @@ public class AfterSalesService {
         productOrderMapper.updateById(order);
 
         log.info("售后申请已创建,同步更新订单状态: orderNo={}, requestId={}", order.getOrderNo(), request.getId());
-
-        // T34:售后建单联动团队服务指派 —— 自动生成一条 PENDING 服务任务(refRequestId 关联售后单)
-        supplyServiceAssignmentService.createFromAfterSales(order, request.getId());
-
         return Result.success(request);
     }
 

+ 0 - 85
cfc-backend/src/main/java/com/etotem/cfc/service/BundlePricingService.java

@@ -1,85 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.etotem.cfc.entity.ProductBundleItem;
-import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
-import com.etotem.cfc.pricing.BundleChild;
-import com.etotem.cfc.pricing.BundleDef;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.BundlePricingCalculator;
-import com.etotem.cfc.pricing.LineItem;
-import org.springframework.stereotype.Service;
-
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 订单套餐自动定价:装载 product_bundle_items 中 bundle_sku_id 非空的定价套餐定义,
- * 组装为 {@link BundleDef} 后委托 {@link BundlePricingCalculator} 计算。
- */
-@Service
-public class BundlePricingService {
-
-    private final ProductBundleItemMapper productBundleItemMapper;
-    private final ProductSkuService productSkuService;
-    private final BundlePricingCalculator calculator = new BundlePricingCalculator();
-
-    public BundlePricingService(ProductBundleItemMapper productBundleItemMapper,
-                                ProductSkuService productSkuService) {
-        this.productBundleItemMapper = productBundleItemMapper;
-        this.productSkuService = productSkuService;
-    }
-
-    /**
-     * 计算订单内套餐优惠。
-     *
-     * @param lines 下单/结算口径商品行(productId/skuId 为解析后身份,unitPrice 为结算单价)
-     */
-    public BundleDiscountResult compute(List<LineItem> lines) {
-        List<ProductBundleItem> rows = productBundleItemMapper.selectList(
-                new QueryWrapper<ProductBundleItem>().isNotNull("bundle_sku_id"));
-        if (rows == null || rows.isEmpty()) {
-            return BundleDiscountResult.inactive();
-        }
-        // 按套餐锚点 SKU 分组组装定义
-        Map<Long, BundleDef> defMap = new LinkedHashMap<>();
-        for (ProductBundleItem row : rows) {
-            if (row == null || row.getBundleSkuId() == null || row.getChildProductId() == null) {
-                continue; // 脏数据防御
-            }
-            BundleDef def = defMap.get(row.getBundleSkuId());
-            if (def == null) {
-                ProductSku sku = productSkuService.getById(row.getBundleSkuId());
-                if (sku == null || sku.getPrice() == null || sku.getPrice() <= 0) {
-                    continue; // 锚点 SKU 不存在或无价,跳过该套餐(防御)
-                }
-                def = new BundleDef();
-                def.setBundleSkuId(row.getBundleSkuId());
-                def.setBundleName(chooseName(sku));
-                def.setBundlePrice(sku.getPrice());
-                def.setChildren(new ArrayList<>());
-                defMap.put(row.getBundleSkuId(), def);
-            }
-            BundleChild child = new BundleChild();
-            child.setChildProductId(row.getChildProductId());
-            child.setChildSkuId(row.getChildSkuId());
-            child.setQuantity(row.getQuantity() == null || row.getQuantity() <= 0 ? 1 : row.getQuantity());
-            def.getChildren().add(child);
-        }
-        if (defMap.isEmpty()) {
-            return BundleDiscountResult.inactive();
-        }
-        return calculator.compute(lines, new ArrayList<>(defMap.values()));
-    }
-
-    /** 套餐展示名:锚点 SKU 的 label 优先,缺省回落 specs */
-    private String chooseName(ProductSku sku) {
-        if (sku.getLabel() != null && !sku.getLabel().trim().isEmpty()) {
-            return sku.getLabel();
-        }
-        return sku.getSpecs();
-    }
-}

+ 132 - 219
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -1,19 +1,19 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.dto.CartListResultDTO;
 import com.etotem.cfc.entity.Cart;
 import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductSku;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.mapper.CartMapper;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.mapper.ProductMapper;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.BundleHit;
-import com.etotem.cfc.pricing.LineItem;
 import com.etotem.cfc.service.PpointService;
 import com.etotem.cfc.service.ProductGiftRuleService;
 import com.etotem.cfc.service.ProductSkuService;
@@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -41,9 +42,6 @@ public class CartService {
     @Resource
     private ProductMapper productMapper;
 
-    @Resource
-    private ProductBundleItemMapper bundleItemMapper;
-
     @Resource
     private ProductSkuService productSkuService;
 
@@ -54,10 +52,10 @@ public class CartService {
     private SysConfigService sysConfigService;
 
     @Resource
-    private BundlePricingService bundlePricingService;
+    private MemberPriceResolver memberPriceResolver;
 
     @Resource
-    private MemberPriceResolver memberPriceResolver;
+    private ComboOfferService comboOfferService;
 
     @Transactional
     public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
@@ -126,24 +124,17 @@ public class CartService {
             CartItemDTO dto = buildCartItemDTO(item);
             if (dto != null) {
                 Product product = productMap.get(item.getProductId());
-                if (product != null && "bundle".equals(product.getProductType())) {
-                    dto.setBundleInfo(buildBundleInfo(product, dto));
-                }
                 result.add(dto);
             }
         }
 
-        // 自动套餐模式:检测购物车子商品组合是否凑成某套餐,命中则挂 bundleMatch 提示
-        attachBundleMatches(cartList, result);
 
         // 订单套餐自动定价优惠(替换原"送会员一口价"结算价逻辑;giftPromo 置空保持旧前端兼容)
         CartItemDTO.GiftPromo giftPromo = computeGiftPromo(cartList, productMap);
-        CartItemDTO.BundlePromo bundlePromo = computeBundlePromo(cartList, productMap, userId);
 
         CartListResultDTO cartResult = new CartListResultDTO();
         cartResult.setItems(result);
         cartResult.setGiftPromo(giftPromo);
-        cartResult.setBundlePromo(bundlePromo);
         return cartResult;
     }
 
@@ -152,144 +143,6 @@ public class CartService {
      * (每个子商品数量 ≥ 组成数量),则在该子商品项上挂 bundleMatch(含套餐价/节省金额/组成明细)。
      * 命中多个套餐时,参与项上只挂第一个命中结果,供前端提示「可享套餐价」。
      */
-    private void attachBundleMatches(List<Cart> cartList, List<CartItemDTO> result) {
-        if (cartList == null || cartList.isEmpty() || result == null || result.isEmpty()) {
-            return;
-        }
-
-        // 1. 聚合购物车数量(按 productId / productId+skuId 两级)
-        Map<Long, Integer> productQtyMap = new HashMap<>();
-        Map<String, Integer> productSkuQtyMap = new HashMap<>();
-        for (Cart c : cartList) {
-            productQtyMap.put(c.getProductId(), productQtyMap.getOrDefault(c.getProductId(), 0) + c.getQuantity());
-            if (c.getSkuId() != null) {
-                String key = c.getProductId() + "_" + c.getSkuId();
-                productSkuQtyMap.put(key, productSkuQtyMap.getOrDefault(key, 0) + c.getQuantity());
-            }
-        }
-
-        // 2. 查询所有 bundle 商品
-        List<Product> bundles = productMapper.selectList(
-            new LambdaQueryWrapper<Product>().eq(Product::getProductType, "bundle"));
-        if (bundles == null || bundles.isEmpty()) {
-            return;
-        }
-
-        // 3. 对每个 bundle 检测购物车覆盖情况
-        for (Product bundle : bundles) {
-            if (bundle.getId() == null) {
-                continue;
-            }
-            List<ProductBundleItem> items = bundleItemMapper.selectList(
-                new LambdaQueryWrapper<ProductBundleItem>()
-                    .eq(ProductBundleItem::getBundleProductId, bundle.getId()));
-            if (items == null || items.isEmpty()) {
-                continue;
-            }
-
-            // 计算最大可匹配组数 + 1 组子商品单品总价
-            int maxGroups = Integer.MAX_VALUE;
-            int singleTotalPrice = 0;
-            boolean matchable = true;
-            for (ProductBundleItem bi : items) {
-                int needQty = bi.getQuantity() != null ? bi.getQuantity() : 1;
-                Integer haveQty;
-                if (bi.getChildSkuId() != null) {
-                    haveQty = productSkuQtyMap.get(bi.getChildProductId() + "_" + bi.getChildSkuId());
-                } else {
-                    haveQty = productQtyMap.get(bi.getChildProductId());
-                }
-                if (haveQty == null || haveQty < needQty) {
-                    matchable = false;
-                    break;
-                }
-                maxGroups = Math.min(maxGroups, haveQty / needQty);
-                Product child = productMapper.selectById(bi.getChildProductId());
-                if (child != null && child.getPrice() != null) {
-                    singleTotalPrice += child.getPrice() * needQty;
-                }
-            }
-            if (!matchable || maxGroups <= 0) {
-                continue;
-            }
-
-            Long bundlePrice = bundle.getPrice() != null ? bundle.getPrice().longValue() : 0L;
-            int saving = bundlePrice < singleTotalPrice ? singleTotalPrice - bundlePrice.intValue() : 0;
-            if (saving <= 0) {
-                continue; // 无优惠不提示
-            }
-
-            // 构造 BundleMatch
-            CartItemDTO.BundleMatch match = new CartItemDTO.BundleMatch();
-            match.setBundleProductId(bundle.getId());
-            match.setBundleProductName(bundle.getName());
-            match.setMatchedGroups(maxGroups);
-            match.setBundlePrice(bundlePrice);
-            match.setSingleTotalPrice(singleTotalPrice);
-            match.setSaving(saving);
-            List<CartItemDTO.BundleChild> children = new ArrayList<>();
-            for (ProductBundleItem bi : items) {
-                CartItemDTO.BundleChild bc = new CartItemDTO.BundleChild();
-                bc.setChildProductId(bi.getChildProductId());
-                bc.setQuantity(bi.getQuantity() != null ? bi.getQuantity() : 1);
-                children.add(bc);
-            }
-            match.setChildren(children);
-
-            // 4. 挂到参与组成的子商品项上(每项只挂第一个命中,供前端提示横幅)
-            for (ProductBundleItem bi : items) {
-                for (CartItemDTO dto : result) {
-                    if (dto.getProductId() != null && dto.getProductId().equals(bi.getChildProductId())
-                            && dto.getBundleMatch() == null) {
-                        dto.setBundleMatch(match);
-                        break;
-                    }
-                }
-            }
-        }
-    }
-
-    private CartItemDTO.BundleInfo buildBundleInfo(Product bundleProduct, CartItemDTO dto) {
-        List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
-            new LambdaQueryWrapper<ProductBundleItem>()
-                .eq(ProductBundleItem::getBundleProductId, bundleProduct.getId())
-        );
-        if (bundleItems == null || bundleItems.isEmpty()) {
-            return null;
-        }
-
-        CartItemDTO.BundleInfo info = new CartItemDTO.BundleInfo();
-        info.setBundleProductId(bundleProduct.getId());
-
-        List<Long> memberIds = new ArrayList<>();
-        List<String> childNames = new ArrayList<>();
-        int childQtyPerBundle = 0;
-        for (ProductBundleItem bi : bundleItems) {
-            memberIds.add(bi.getChildProductId());
-            if (bi.getQuantity() != null) {
-                childQtyPerBundle += bi.getQuantity();
-            }
-            Product child = productMapper.selectById(bi.getChildProductId());
-            childNames.add(child != null ? child.getName() : "");
-        }
-        int cartQty = dto.getQuantity() != null ? dto.getQuantity() : 1;
-        info.setChildProductIds(memberIds);
-        info.setChildProductNames(childNames);
-        info.setChildQuantityTotal(childQtyPerBundle * cartQty);
-
-        Integer unitPrice = dto.getUnitPrice() != null ? dto.getUnitPrice() : 0;
-        Integer quantity = dto.getQuantity() != null ? dto.getQuantity() : 1;
-        Integer singleTotal = unitPrice * quantity;
-        Long bundlePrice = bundleProduct.getPrice() != null ? bundleProduct.getPrice().longValue() : 0L;
-        info.setBundlePrice(bundlePrice);
-        info.setSingleTotalPrice(singleTotal);
-
-        int saving = bundlePrice < singleTotal ? singleTotal - bundlePrice.intValue() : 0;
-        info.setSaving(saving);
-        return info;
-    }
-
-    @Transactional
     public void updateQuantity(Long userId, Long productId, Integer quantity) {
         Cart cart = cartMapper.selectOne(
             new LambdaQueryWrapper<Cart>()
@@ -358,11 +211,7 @@ public class CartService {
         if (cart.getSkuId() != null) {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
             if (sku != null) {
-                // 组合 SKU:展示组合摘要,不转发、不解析 legacy specs
-                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) {
+                if (sku.getLinkedProductId() != null) {
                     Product linkedProduct = productMapper.selectById(sku.getLinkedProductId());
                     if (linkedProduct != null) {
                         dto.setProductId(linkedProduct.getId());
@@ -429,31 +278,132 @@ public class CartService {
         return buildEmptyGiftPromo();
     }
 
+    private CartItemDTO.GiftPromo buildEmptyGiftPromo() {
+        CartItemDTO.GiftPromo promo = new CartItemDTO.GiftPromo();
+        promo.setGiftOriginalTotal(0);
+        promo.setThreshold(0);
+        promo.setPayPrice(0);
+        promo.setDiscount(0);
+        promo.setActive(false);
+        promo.setRemaining(0);
+        return promo;
+    }
+
+    // ==================== 套餐 offer:勾选子集检测 + 一键替换 ====================
+
+    /**
+     * 购物车套餐 offer 检测:仅按勾选结算子集(checkedCartItemIds=购物车主键)计算,
+     * 节省额>0 才 active。列表接口不再烘焙 offer(勾选态是前端本地态,见 cart.vue item.checked)。
+     */
+    public ComboOfferResult computeComboOffer(Long userId, List<Long> checkedCartItemIds) {
+        List<Cart> carts = loadCheckedCarts(userId, checkedCartItemIds);
+        if (carts.isEmpty()) return ComboOfferResult.inactive();
+        List<LineItem> lines = buildOfferLines(carts, userId);
+        return comboOfferService.computeForLines(lines);
+    }
+
     /**
-     * 订单套餐自动定价优惠:扫描购物车商品,按下单结算口径(会员/SKU/linked 价)聚合,
-     * 匹配 product_bundle_items 中 bundle_sku_id 非空的定价套餐,贪心选省最多,返回 bundlePromo。
-     * 展示口径与下单一致(MemberPriceResolver),保证页面提示=实际扣款。
+     * 一键替换为套餐:后端按勾选行 (productId, skuId) 重算组数(忽略前端传入),
+     * 逐行扣减子商品数量(不足整体失败回滚),删除数量为0的行,upsert 套餐 SKU 行(存在则合并数量)。
      */
-    private CartItemDTO.BundlePromo computeBundlePromo(List<Cart> cartList, Map<Long, Product> productMap, Long userId) {
-        if (cartList == null || cartList.isEmpty()) {
-            return emptyBundlePromo();
+    @Transactional
+    public Result<Void> replaceCartWithCombo(Long userId, List<Long> checkedCartItemIds, Long comboSkuId) {
+        if (checkedCartItemIds == null || checkedCartItemIds.isEmpty()) {
+            return Result.error("请选择要替换为套餐的购物车商品");
         }
-        List<LineItem> lines = new ArrayList<>();
-        for (Cart cart : cartList) {
-            if (cart == null || cart.getProductId() == null || cart.getQuantity() == null || cart.getQuantity() <= 0) {
-                continue;
-            }
-            Product product = productMap.get(cart.getProductId());
-            if (product == null) {
-                continue;
+        if (comboSkuId == null) {
+            return Result.error("套餐参数缺失");
+        }
+        List<Cart> carts = loadCheckedCarts(userId, checkedCartItemIds);
+        if (carts.isEmpty()) {
+            return Result.error("购物车商品不存在");
+        }
+        ComboOfferDef def = comboOfferService.loadDef(comboSkuId);
+        if (def == null || def.getChildren() == null || def.getChildren().isEmpty()) {
+            return Result.error("套餐不存在或未配置");
+        }
+        // 按 (productId, skuId) 聚合勾选行数量
+        Map<String, Integer> pool = new HashMap<>();
+        for (Cart c : carts) {
+            String k = offerKey(c.getProductId(), c.getSkuId());
+            pool.put(k, pool.getOrDefault(k, 0) + c.getQuantity());
+        }
+        // 后端重算组数(忽略前端传入组数)
+        int groups = Integer.MAX_VALUE;
+        for (ComboChild child : def.getChildren()) {
+            int need = child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1;
+            int have = pool.getOrDefault(offerKey(child.getChildProductId(), child.getChildSkuId()), 0);
+            groups = Math.min(groups, have / need);
+        }
+        if (groups == Integer.MAX_VALUE || groups <= 0) {
+            return Result.error("购物车商品数量不足,无法替换为套餐");
+        }
+        // 逐行扣减子商品数量(仅勾选行;不足则抛异常回滚)
+        for (ComboChild child : def.getChildren()) {
+            int needTotal = (child.getQuantity() != null && child.getQuantity() > 0 ? child.getQuantity() : 1) * groups;
+            int remaining = needTotal;
+            for (Cart c : carts) {
+                if (c.getSkuId() != null && c.getSkuId().equals(child.getChildSkuId())
+                        && c.getProductId().equals(child.getChildProductId())) {
+                    int deduct = Math.min(c.getQuantity(), remaining);
+                    c.setQuantity(c.getQuantity() - deduct);
+                    remaining -= deduct;
+                    if (c.getQuantity() <= 0) {
+                        cartMapper.deleteById(c.getId());
+                    } else {
+                        c.setUpdatedAt(LocalDateTime.now());
+                        cartMapper.updateById(c);
+                    }
+                    if (remaining == 0) break;
+                }
             }
-            ProductSku sku = null;
-            if (cart.getSkuId() != null) {
-                sku = productSkuService.getById(cart.getSkuId());
+            if (remaining > 0) {
+                throw new RuntimeException("购物车商品数量不足,替换套餐失败");
             }
-            // 解析后身份 + 下单口径单价(linked 转发商品、SKU 价、否则会员价)
+        }
+        // upsert 套餐 SKU 行(与现有 add 行为一致:同商品+SKU 合并数量)
+        ProductSku comboSku = productSkuService.getById(comboSkuId);
+        if (comboSku == null || comboSku.getProductId() == null) {
+            return Result.error("套餐SKU不存在");
+        }
+        Cart existing = cartMapper.selectOne(new LambdaQueryWrapper<Cart>()
+                .eq(Cart::getUserId, userId)
+                .eq(Cart::getProductId, comboSku.getProductId())
+                .eq(Cart::getSkuId, comboSkuId));
+        if (existing != null) {
+            existing.setQuantity(existing.getQuantity() + groups);
+            existing.setUpdatedAt(LocalDateTime.now());
+            cartMapper.updateById(existing);
+        } else {
+            Cart nc = new Cart();
+            nc.setUserId(userId);
+            nc.setProductId(comboSku.getProductId());
+            nc.setSkuId(comboSkuId);
+            nc.setQuantity(groups);
+            nc.setCreatedAt(LocalDateTime.now());
+            nc.setUpdatedAt(LocalDateTime.now());
+            cartMapper.insert(nc);
+        }
+        return Result.success();
+    }
+
+    private List<Cart> loadCheckedCarts(Long userId, List<Long> ids) {
+        if (ids == null || ids.isEmpty()) return Collections.emptyList();
+        return cartMapper.selectList(new LambdaQueryWrapper<Cart>()
+                .in(Cart::getId, ids)
+                .eq(Cart::getUserId, userId));
+    }
+
+    private List<LineItem> buildOfferLines(List<Cart> carts, Long userId) {
+        List<LineItem> lines = new ArrayList<>();
+        for (Cart cart : carts) {
+            if (cart == null || cart.getProductId() == null || cart.getQuantity() == null || cart.getQuantity() <= 0) continue;
+            Product product = productMapper.selectById(cart.getProductId());
+            if (product == null) continue;
             Long resolvedProductId = product.getId();
             int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, userId);
+            ProductSku sku = null;
+            if (cart.getSkuId() != null) sku = productSkuService.getById(cart.getSkuId());
             if (sku != null && sku.getLinkedProductId() != null) {
                 Product linked = productMapper.selectById(sku.getLinkedProductId());
                 if (linked != null) {
@@ -465,48 +415,11 @@ public class CartService {
             }
             lines.add(new LineItem(resolvedProductId, cart.getSkuId(), cart.getQuantity(), unitPrice));
         }
-        if (lines.isEmpty()) {
-            return emptyBundlePromo();
-        }
-        BundleDiscountResult result = bundlePricingService.compute(lines);
-        return toBundlePromo(result);
-    }
-
-    private CartItemDTO.BundlePromo toBundlePromo(BundleDiscountResult r) {
-        CartItemDTO.BundlePromo promo = new CartItemDTO.BundlePromo();
-        promo.setActive(r.isActive());
-        promo.setOriginalTotal(r.getOriginalTotal() != null ? r.getOriginalTotal() : 0);
-        promo.setPayPrice(r.getPayPrice() != null ? r.getPayPrice() : 0);
-        promo.setDiscount(r.getDiscount() != null ? r.getDiscount() : 0);
-        List<CartItemDTO.BundleItemHit> hits = new ArrayList<>();
-        if (r.getBundleList() != null) {
-            for (BundleHit hit : r.getBundleList()) {
-                CartItemDTO.BundleItemHit h = new CartItemDTO.BundleItemHit();
-                h.setBundleSkuId(hit.getBundleSkuId());
-                h.setBundleName(hit.getBundleName());
-                h.setTimes(hit.getTimes());
-                h.setOriginalTotal(hit.getOriginalTotal());
-                h.setBundlePrice(hit.getBundlePrice());
-                h.setSaving(hit.getSaving());
-                hits.add(h);
-            }
-        }
-        promo.setBundleList(hits);
-        return promo;
+        return lines;
     }
 
-    private CartItemDTO.BundlePromo emptyBundlePromo() {
-        return toBundlePromo(BundleDiscountResult.inactive());
-    }
-
-    private CartItemDTO.GiftPromo buildEmptyGiftPromo() {
-        CartItemDTO.GiftPromo promo = new CartItemDTO.GiftPromo();
-        promo.setGiftOriginalTotal(0);
-        promo.setThreshold(0);
-        promo.setPayPrice(0);
-        promo.setDiscount(0);
-        promo.setActive(false);
-        promo.setRemaining(0);
-        return promo;
+    private String offerKey(Long productId, Long skuId) {
+        return productId + "_" + (skuId == null ? "" : skuId);
     }
 }
+

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ComboOfferService.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ProductComboItem;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductComboItemMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferCalculator;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 购物车套餐 offer 装载与计算:从 {@code product_combo_items} 按 combo_sku_id 聚合组装
+ * {@link ComboOfferDef}(套餐价取套餐SKU.price,展示名取套餐SKU.label),脏行跳过,
+ * 委托 {@link ComboOfferCalculator} 计算最优 offer。
+ */
+@Slf4j
+@Service
+public class ComboOfferService {
+
+    @Resource
+    private ProductComboItemMapper comboItemMapper;
+
+    @Resource
+    private ProductSkuMapper skuMapper;
+
+    /** 购物车行 → 最优套餐 offer */
+    public ComboOfferResult computeForLines(List<LineItem> lines) {
+        if (lines == null || lines.isEmpty()) return ComboOfferResult.inactive();
+        List<ComboOfferDef> defs = loadAllDefs();
+        return new ComboOfferCalculator().compute(lines, defs);
+    }
+
+    /** 加载单个套餐定义(replace-combo 用);不存在或内容缺失返回 null */
+    public ComboOfferDef loadDef(Long comboSkuId) {
+        if (comboSkuId == null) return null;
+        List<ProductComboItem> items = comboItemMapper.selectList(
+                new LambdaQueryWrapper<ProductComboItem>()
+                        .eq(ProductComboItem::getComboSkuId, comboSkuId));
+        return buildDef(comboSkuId, items);
+    }
+
+    private List<ComboOfferDef> loadAllDefs() {
+        List<ProductComboItem> items = comboItemMapper.selectList(null);
+        if (items == null || items.isEmpty()) return Collections.emptyList();
+        Map<Long, List<ProductComboItem>> bySku = new LinkedHashMap<>();
+        for (ProductComboItem it : items) {
+            if (it == null || it.getComboSkuId() == null) continue;
+            bySku.computeIfAbsent(it.getComboSkuId(), k -> new ArrayList<>()).add(it);
+        }
+        List<ComboOfferDef> defs = new ArrayList<>();
+        for (Map.Entry<Long, List<ProductComboItem>> e : bySku.entrySet()) {
+            ComboOfferDef def = buildDef(e.getKey(), e.getValue());
+            if (def != null) defs.add(def);
+        }
+        return defs;
+    }
+
+    private ComboOfferDef buildDef(Long comboSkuId, List<ProductComboItem> items) {
+        if (items == null || items.isEmpty()) return null;
+        ProductSku sku = skuMapper.selectById(comboSkuId);
+        if (sku == null) {
+            log.warn("套餐SKU不存在,跳过脏套餐定义: comboSkuId={}", comboSkuId);
+            return null;
+        }
+        List<ComboChild> children = new ArrayList<>();
+        for (ProductComboItem it : items) {
+            if (it == null || it.getChildProductId() == null || it.getChildSkuId() == null) {
+                log.warn("套餐内容脏行(缺子商品/子SKU),跳过: comboSkuId={}, itemId={}", comboSkuId, it == null ? null : it.getId());
+                continue;
+            }
+            ComboChild c = new ComboChild();
+            c.setChildProductId(it.getChildProductId());
+            c.setChildSkuId(it.getChildSkuId());
+            c.setQuantity(it.getQuantity() != null && it.getQuantity() > 0 ? it.getQuantity() : 1);
+            children.add(c);
+        }
+        if (children.isEmpty()) return null;
+        ComboOfferDef def = new ComboOfferDef();
+        def.setComboSkuId(comboSkuId);
+        def.setComboName(sku.getLabel() != null && !sku.getLabel().trim().isEmpty()
+                ? sku.getLabel() : String.valueOf(comboSkuId));
+        def.setComboPrice(sku.getPrice() != null ? sku.getPrice() : 0);
+        def.setChildren(children);
+        return def;
+    }
+}

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

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

+ 0 - 76
cfc-backend/src/main/java/com/etotem/cfc/service/InventoryService.java

@@ -26,8 +26,6 @@ public class InventoryService {
     @Resource
     private InventoryCountingRecordMapper countingRecordMapper;
     @Resource
-    private ProductBundleItemMapper bundleItemMapper;
-    @Resource
     private ProductMapper productMapper;
     @Resource
     private ProductSkuMapper productSkuMapper;
@@ -434,83 +432,9 @@ public class InventoryService {
                         .last("LIMIT 10"));
         data.put("recentTransactions", recentTx);
 
-        if ("bundle".equals(product.getProductType())) {
-            List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
-                    new LambdaQueryWrapper<ProductBundleItem>()
-                            .eq(ProductBundleItem::getBundleProductId, productId));
-            List<Map<String, Object>> childProducts = new ArrayList<>();
-            for (ProductBundleItem bi : bundleItems) {
-                Product child = productMapper.selectById(bi.getChildProductId());
-                if (child != null) {
-                    Map<String, Object> cp = new HashMap<>();
-                    cp.put("productId", child.getId());
-                    cp.put("productName", child.getName());
-                    cp.put("stock", child.getStock());
-                    cp.put("quantity", bi.getQuantity());
-                    cp.put("skuId", bi.getChildSkuId());
-                    childProducts.add(cp);
-                }
-            }
-            data.put("bundleItems", childProducts);
-        }
-
         return Result.success(data);
     }
 
-    // ========== 套餐商品组成 ==========
-
-    @Transactional
-    public Result<String> saveBundleItems(Long bundleProductId, List<Map<String, Object>> items) {
-        bundleItemMapper.delete(new LambdaQueryWrapper<ProductBundleItem>()
-                .eq(ProductBundleItem::getBundleProductId, bundleProductId));
-
-        for (Map<String, Object> item : items) {
-            ProductBundleItem bi = new ProductBundleItem();
-            bi.setBundleProductId(bundleProductId);
-            bi.setChildProductId(ParamUtils.getLong(item.get("childProductId")));
-            if (item.get("childSkuId") != null) {
-                bi.setChildSkuId(ParamUtils.getLong(item.get("childSkuId")));
-            }
-            bi.setQuantity(item.get("quantity") != null ? Integer.valueOf(item.get("quantity").toString()) : 1);
-            bi.setCreatedAt(new Date());
-            bundleItemMapper.insert(bi);
-        }
-        return Result.success("套餐商品配置已保存");
-    }
-
-    public Result<List<ProductBundleItem>> listBundleItems(Long bundleProductId) {
-        List<ProductBundleItem> items = bundleItemMapper.selectList(
-                new LambdaQueryWrapper<ProductBundleItem>()
-                        .eq(ProductBundleItem::getBundleProductId, bundleProductId));
-        return Result.success(items);
-    }
-
-    // ========== 套餐库存检查 ==========
-
-    public Result<String> checkBundleStock(Long bundleProductId, Integer quantity) {
-        List<ProductBundleItem> items = bundleItemMapper.selectList(
-                new LambdaQueryWrapper<ProductBundleItem>()
-                        .eq(ProductBundleItem::getBundleProductId, bundleProductId));
-        if (items.isEmpty()) return Result.error("套餐未配置子商品");
-
-        for (ProductBundleItem bi : items) {
-            int need = bi.getQuantity() * quantity;
-            if (bi.getChildSkuId() != null) {
-                ProductSku sku = productSkuMapper.selectById(bi.getChildSkuId());
-                if (sku == null || sku.getStock() == null || sku.getStock() < need) {
-                    Product p = productMapper.selectById(bi.getChildProductId());
-                    return Result.error("子商品库存不足: " + (p != null ? p.getName() : "未知"));
-                }
-            } else {
-                Product p = productMapper.selectById(bi.getChildProductId());
-                if (p == null || p.getStock() == null || p.getStock() < need) {
-                    return Result.error("子商品库存不足: " + (p != null ? p.getName() : "未知"));
-                }
-            }
-        }
-        return Result.success("库存充足");
-    }
-
     // ========== 手动出库 ==========
 
     @Transactional

+ 40 - 176
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -12,11 +12,9 @@ import com.etotem.cfc.entity.AssessmentProduct;
 import com.etotem.cfc.entity.Consignee;
 import com.etotem.cfc.entity.UserAddress;
 import com.etotem.cfc.mapper.UserAddressMapper;
-import com.etotem.cfc.pricing.LineItem;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.entity.ProductOnlineSlot;
 import com.etotem.cfc.entity.OrderItem;
@@ -42,10 +40,7 @@ import com.etotem.cfc.mapper.FamilyCouponMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.entity.ProductSkuComboItem;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.service.ProductSkuService;
 import com.etotem.cfc.service.AssessmentProductService;
 import com.etotem.cfc.service.AssessmentQuotaService;
@@ -99,15 +94,9 @@ public class ProductOrderService {
     private ProductSkuService productSkuService;
     @Resource
     private MemberPriceResolver memberPriceResolver;
-    @Resource
-    private BundlePricingService bundlePricingService;
-
     @Resource
     private InventoryService inventoryService;
 
-    @Resource
-    private ProductBundleItemMapper bundleItemMapper;
-
     @Resource
     private CfCommissionService cfCommissionService;
 
@@ -246,13 +235,6 @@ public class ProductOrderService {
                 && product.getStock() < dto.getQuantity()) {
             return Result.error("库存不足");
         }
-        // 套餐商品:检查子商品库存
-        if ("bundle".equals(product.getProductType())) {
-            Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), dto.getQuantity());
-            if (bundleCheck.getCode() != 200) {
-                return Result.error(bundleCheck.getMessage());
-            }
-        }
         // 校验最小购买数量
         if (product.getMinQuantity() != null && product.getMinQuantity() > 1
                 && dto.getQuantity() < product.getMinQuantity()) {
@@ -276,12 +258,7 @@ public class ProductOrderService {
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
-        // 套餐自动定价优惠(服务端权威):按下单最终单价(含履约覆盖价)计算;前端 discountAmount 仅含规则优惠
-        int baseUnitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
-        int effectiveUnitPrice = fv.unitPrice != null ? fv.unitPrice : baseUnitPrice;
-        int bundleDiscount = bundlePricingService.compute(java.util.Collections.singletonList(
-                new LineItem(product.getId(), null, dto.getQuantity(), effectiveUnitPrice))).getDiscount();
-        int discountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0) + bundleDiscount;
+        int discountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0);
         // 优惠券抵扣:校验并计入优惠金额(orderType=PRODUCT)
         Long appliedCouponId = null;
         if (dto.getCouponId() != null) {
@@ -413,12 +390,6 @@ public class ProductOrderService {
                 && product.getStock() < quantity) {
             return Result.error("库存不足");
         }
-        if ("bundle".equals(product.getProductType())) {
-            Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), quantity);
-            if (bundleCheck.getCode() != 200) {
-                return Result.error(bundleCheck.getMessage());
-            }
-        }
         if (product.getMinQuantity() != null && product.getMinQuantity() > 1
                 && quantity < product.getMinQuantity()) {
             return Result.error("该商品最少需购买" + product.getMinQuantity() + "件");
@@ -756,16 +727,9 @@ public class ProductOrderService {
         java.util.List<OrderItem> orderItemRecords = new java.util.ArrayList<>();
         Product firstProduct = null;
         int firstUnitPrice = 0;
-        // 收集下单口径行项(解析后身份 + 结算单价),用于服务端权威的套餐自动定价
-        java.util.List<LineItem> bundleLines = new java.util.ArrayList<>();
-        Long comboSkuId = null; // 首个组合行 SKU(多商品订单组合可不在首行)
 
         for (OrderItemVO item : dto.getItems()) {
             ProductSku itemSku = item.getSkuId() != null ? productSkuService.getById(item.getSkuId()) : null;
-            boolean comboItem = productSkuService.hasComboItems(itemSku);
-            if (comboItem && comboSkuId == null) {
-                comboSkuId = item.getSkuId();
-            }
             Product product = productMapper.selectById(item.getProductId());
             if (product == null) {
                 return Result.error("商品不存在: " + item.getProductId());
@@ -773,44 +737,21 @@ public class ProductOrderService {
             if (!"on_shelf".equals(product.getStatus())) {
                 return Result.error("商品已下架: " + product.getName());
             }
-            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查(组合商品按子商品校验)
-            if (!comboItem && "physical".equals(product.getProductType())
+            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查
+            if ("physical".equals(product.getProductType())
                     && product.getStock() != null
                     && product.getStock() < item.getQuantity()) {
                 return Result.error("库存不足: " + product.getName());
             }
-            // 套餐商品:检查子商品库存
-            if ("bundle".equals(product.getProductType())) {
-                Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), item.getQuantity());
-                if (bundleCheck.getCode() != 200) {
-                    return Result.error(bundleCheck.getMessage());
-                }
-            }
-            // 组合商品:校验各子商品库存(子 SKU / 子商品双通道)
-            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库存(只扣套餐SKU,不扣子商品)
+            if ("bundle".equals(product.getProductType())
+                    && itemSku != null
+                    && !productSkuService.hasStock(itemSku.getId(), item.getQuantity())) {
+                return Result.error("套餐库存不足: " + product.getName());
             }
             int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
             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) {
+                if (itemSku.getLinkedProductId() != null) {
                     // Linked SKU: 转发到关联商品
                     Product linkedProduct = productMapper.selectById(itemSku.getLinkedProductId());
                     if (linkedProduct != null) {
@@ -847,14 +788,11 @@ public class ProductOrderService {
             itemRecord.setSkuId(item.getSkuId());
             itemRecord.setProductName(product.getName());
             itemRecord.setProductImage(product.getCoverImage());
-            itemRecord.setSkuSpecs(comboItem
-                    ? productSkuService.buildComboSummary(itemSku.getComboItems())
-                    : item.getSpecOptionIds());
+            itemRecord.setSkuSpecs(item.getSpecOptionIds());
             itemRecord.setPrice(java.math.BigDecimal.valueOf(unitPrice));
             itemRecord.setQuantity(item.getQuantity());
             itemRecord.setSubtotal(java.math.BigDecimal.valueOf((long) unitPrice * item.getQuantity()));
             orderItemRecords.add(itemRecord);
-            bundleLines.add(new LineItem(product.getId(), item.getSkuId(), item.getQuantity(), unitPrice));
         }
 
         User buyer = userMapper.selectById(buyerId);
@@ -863,7 +801,7 @@ public class ProductOrderService {
         order.setProductId(firstProduct.getId());
         order.setProductName(firstProduct.getName() + "等多件");
         order.setProductType(firstProduct.getProductType());
-        order.setSkuId(comboSkuId != null ? comboSkuId : firstItem.getSkuId());
+        order.setSkuId(firstItem.getSkuId());
         order.setBuyerId(buyerId);
         order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
         order.setQuantity(totalQuantity);
@@ -874,8 +812,7 @@ public class ProductOrderService {
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
         // 套餐自动定价优惠(服务端权威):前端 discountAmount 仅含规则优惠,套餐部分由服务端重算加入
-        int bundleDiscount = bundlePricingService.compute(bundleLines).getDiscount();
-        int multiDiscountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0) + bundleDiscount;
+        int multiDiscountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0);
         // 优惠券抵扣:校验并计入优惠金额(orderType=PRODUCT)
         Long appliedCouponId = null;
         if (dto.getCouponId() != null) {
@@ -1079,9 +1016,7 @@ public class ProductOrderService {
         // 仅对实物商品扣减库存;虚拟商品/优惠券无库存概念
         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())) {
+        if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
             boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
             if (!stockOk) {
                 log.error("库存扣减失败: orderNo={}, productId={}, quantity={}",
@@ -1094,29 +1029,19 @@ public class ProductOrderService {
             // 记录出库流水
             inventoryService.recordOutbound(order.getProductId(), null, order.getQuantity(),
                     "order", order.getId(), null, "订单支付:" + orderNo);
-        } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType())) {
-            // 套餐商品:穿透到子商品扣减库存
-            List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
-                    new LambdaQueryWrapper<ProductBundleItem>()
-                            .eq(ProductBundleItem::getBundleProductId, paidProduct.getId()));
-            for (ProductBundleItem bi : bundleItems) {
-                int need = bi.getQuantity() * order.getQuantity();
-                if (bi.getChildSkuId() != null) {
-                    boolean skuOk = productSkuService.decreaseStock(bi.getChildSkuId(), need);
-                    if (!skuOk) {
-                        throw new RuntimeException("套餐子商品库存不足: bundleItemId=" + bi.getId());
-                    }
-                    inventoryService.recordOutbound(bi.getChildProductId(), bi.getChildSkuId(), need,
-                            "order", order.getId(), null, "套餐出库:" + orderNo);
-                } else {
-                    boolean stockOk = productService.decreaseStock(bi.getChildProductId(), need);
-                    if (!stockOk) {
-                        throw new RuntimeException("套餐子商品库存不足: bundleItemId=" + bi.getId());
-                    }
-                    inventoryService.recordOutbound(bi.getChildProductId(), null, need,
-                            "order", order.getId(), null, "套餐出库:" + orderNo);
-                }
+        } else if (paidProduct != null && "bundle".equals(paidProduct.getProductType()) && paidSku != null) {
+            // 套餐商品:只扣套餐SKU库存,不扣子商品库存
+            boolean stockOk = productSkuService.decreaseStock(paidSku.getId(), order.getQuantity());
+            if (!stockOk) {
+                log.error("套餐库存扣减失败: orderNo={}, skuId={}, quantity={}",
+                        orderNo, paidSku.getId(), order.getQuantity());
+                order.setRemark("套餐库存扣减失败");
+                order.setUpdatedAt(new Date());
+                orderMapper.updateById(order);
+                throw new RuntimeException("套餐库存不足");
             }
+            inventoryService.recordOutbound(order.getProductId(), paidSku.getId(), order.getQuantity(),
+                    "order", order.getId(), null, "订单支付:" + orderNo);
         }
         order.setStatus("paid");
         order.setTransactionId(transactionId);
@@ -1290,73 +1215,6 @@ public class ProductOrderService {
         return Result.success("支付成功");
     }
 
-    /**
-     * 组合 SKU 出库:按组合行子项(子 SKU / 子商品)扣减库存并记出库流水。
-     * 包级可见,供单测直接调用。任一子项库存不足抛 {@link RuntimeException} 由事务回滚。
-     */
-    void deductComboStock(ProductSku combo, ProductOrder order, String orderNo) {
-        for (ProductSkuComboItem ci : combo.getComboItems()) {
-            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
-            int need = unit * comboLineQuantity(order);
-            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);
-            }
-        }
-    }
-
-    /**
-     * 组合订单回补:若订单 SKU 为组合,回补各子项库存并记退款入库流水,返回 true;
-     * 非组合订单返回 false,由调用方回退到原有父商品回补逻辑。包级可见,供单测直接调用。
-     */
-    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 * comboLineQuantity(order);
-            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;
-    }
-
-    /**
-     * 组合订单的组合行数量:查 order_items 中与订单 SKU 匹配行的数量合计。
-     * 混合订单(组合行 + 其他行)下 order.quantity 为订单总量,不能用于子项扣减/回补;
-     * 无匹配行(如旧数据/单测缺省)时回退 order.quantity。包级可见,供单测直接调用。
-     */
-    int comboLineQuantity(ProductOrder order) {
-        if (order.getId() == null || order.getSkuId() == null) {
-            return order.getQuantity() == null ? 1 : order.getQuantity();
-        }
-        List<OrderItem> comboLines = orderItemMapper.selectList(
-                new LambdaQueryWrapper<OrderItem>()
-                        .eq(OrderItem::getOrderId, order.getId())
-                        .eq(OrderItem::getSkuId, order.getSkuId()));
-        int q = 0;
-        for (OrderItem li : comboLines) {
-            q += li.getQuantity() == null ? 0 : li.getQuantity();
-        }
-        return q > 0 ? q : (order.getQuantity() == null ? 1 : order.getQuantity());
-    }
-
     public Result<String> cancel(String orderNo, Long userId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -1376,13 +1234,15 @@ public class ProductOrderService {
         releaseCouponForCancelledOrder(order.getCouponId());
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本,幂等 refId = 商品订单 ID)
         refundOrderPoints(order, "取消订单退还CF值:" + orderNo);
-        // Restore stock on cancel(组合订单回补子商品,非组合回补父商品)
-        if (!restockComboOrder(order)) {
+        // Restore stock on cancel
+        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+        } else {
             productService.increaseStock(order.getProductId(), order.getQuantity());
-            // 记录退款入库流水
-            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
-                    "order", order.getId(), null, "取消订单:" + orderNo);
         }
+        // 记录退款入库流水
+        inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                "order", order.getId(), null, "取消订单:" + orderNo);
         // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断取消)
         try {
             specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
@@ -1827,11 +1687,13 @@ public class ProductOrderService {
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        if (!restockComboOrder(order)) {
+        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+        } else {
             productService.increaseStock(order.getProductId(), order.getQuantity());
-            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
-                    "order", order.getId(), adminId, "退款:" + orderNo);
         }
+        inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                "order", order.getId(), adminId, "退款:" + orderNo);
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
         releaseCouponForCancelledOrder(order.getCouponId());
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本)
@@ -2047,7 +1909,9 @@ public class ProductOrderService {
             return Result.error("该订单无法关闭");
         }
         if ("paid".equals(currentStatus) || "pending_receipt".equals(currentStatus)) {
-            if (!restockComboOrder(order)) {
+            if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
+                productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
+            } else {
                 productService.increaseStock(order.getProductId(), order.getQuantity());
             }
         }

+ 64 - 25
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -14,12 +14,11 @@ import com.etotem.cfc.entity.AssessmentProduct;
 import com.etotem.cfc.entity.ContentDimensionThreshold;
 import com.etotem.cfc.entity.GrowthArchiveProduct;
 import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
+import com.etotem.cfc.entity.ProductComboItem;
 import com.etotem.cfc.entity.ProductSku;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductSkuMapper;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
@@ -71,11 +70,10 @@ public class ProductService {
     @Resource
     private ProductDeliveryConfigService productDeliveryConfigService;
 
-    @Resource
-    private ProductBundleItemMapper bundleItemMapper;
-
     @Resource
     private ProductSkuMapper productSkuMapper;
+    @Resource
+    private ComboService comboService;
 
     @Resource
     private FiveDimensionScoreService fiveDimensionScoreService;
@@ -225,6 +223,10 @@ wrapper.in(Product::getId, productIds)
         if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
             wrapper.like(Product::getName, query.getKeyword());
         }
+        // 营养标签筛选:逗号分隔标签中任一命中即返回(NULL/空=不限)
+        if (query.getNutritionTag() != null && !query.getNutritionTag().trim().isEmpty()) {
+            wrapper.like(Product::getNutritionTags, query.getNutritionTag().trim());
+        }
         SortUtil.applySort(wrapper);
         IPage<Product> result = productMapper.selectPage(page, wrapper);
         boolean isGuest = (userId == null);
@@ -291,28 +293,27 @@ wrapper.in(Product::getId, productIds)
             return Result.error("商品未上架");
         }
         ProductDTO dto = ProductDTO.from(product);
-        // 套餐商品:填充套餐组成(子商品名称/封面/SKU规格/数量)
+        // 套餐商品:套餐内容(新模型:内容=子商品SKU×数量,挂套餐SKU级;下单只扣套餐SKU库存)
         if ("bundle".equals(product.getProductType())) {
-            List<ProductBundleItem> items = bundleItemMapper.selectList(
-                    new LambdaQueryWrapper<ProductBundleItem>()
-                            .eq(ProductBundleItem::getBundleProductId, id));
-            List<ProductDTO.BundleItemVO> bundleItems = new ArrayList<>();
-            for (ProductBundleItem bi : items) {
-                Product child = productMapper.selectById(bi.getChildProductId());
-                if (child == null) continue;   // 子商品已删除则跳过
-                ProductDTO.BundleItemVO vo = new ProductDTO.BundleItemVO();
-                vo.setChildProductId(child.getId());
-                vo.setChildProductName(child.getName());
-                vo.setCoverImage(child.getCoverImage());
-                vo.setChildSkuId(bi.getChildSkuId());
-                vo.setQuantity(bi.getQuantity());
-                if (bi.getChildSkuId() != null) {
-                    ProductSku sku = productSkuMapper.selectById(bi.getChildSkuId());
-                    if (sku != null) vo.setSpecs(sku.getSpecs());
+            // 套餐内容(新模型:内容=子商品SKU×数量,挂套餐SKU级;下单只扣套餐SKU库存)
+            List<ProductDTO.ComboItemVO> comboItems = new ArrayList<>();
+            List<ProductSku> comboSkus = productSkuMapper.selectList(
+                    new LambdaQueryWrapper<ProductSku>().eq(ProductSku::getProductId, id));
+            if (comboSkus != null) {
+                for (ProductSku comboSku : comboSkus) {
+                    for (ProductComboItem ci : comboService.listComboItems(comboSku.getId())) {
+                        ProductDTO.ComboItemVO vo = new ProductDTO.ComboItemVO();
+                        vo.setChildProductId(ci.getChildProductId());
+                        vo.setChildProductName(ci.getChildProductName());
+                        vo.setCoverImage(ci.getChildProductImage());
+                        vo.setChildSkuId(ci.getChildSkuId());
+                        vo.setSpecs(ci.getChildSkuSpecs());
+                        vo.setQuantity(ci.getQuantity());
+                        comboItems.add(vo);
+                    }
                 }
-                bundleItems.add(vo);
             }
-            dto.setBundleItems(bundleItems);
+            dto.setComboItems(comboItems);
         }
         ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(id);
         if (giftRule != null) {
@@ -619,9 +620,23 @@ wrapper.in(Product::getId, productIds)
         // updateById 默认忽略 null 字段:clearable 下拉清空后需显式置 NULL
         boolean clearCategory = product.getCategoryId() == null;
         boolean clearDistributionSystem = product.getDistributionSystemId() == null;
+        // 营养数据字段为 nullable 且允许清空:管理端清空输入框后需显式置 NULL,
+        // 否则 updateById 忽略 null 会残留旧值;空串营养标签同样规整为 NULL。
+        // 仅当原记录确实有值时才需要清空,避免每次编辑都多打一条无意义的 UPDATE。
+        boolean clearCalories = existing.getCaloriesKcal() != null && product.getCaloriesKcal() == null;
+        boolean clearProtein = existing.getProteinG() != null && product.getProteinG() == null;
+        boolean clearFat = existing.getFatG() != null && product.getFatG() == null;
+        boolean clearCarb = existing.getCarbG() != null && product.getCarbG() == null;
+        boolean clearFiber = existing.getFiberG() != null && product.getFiberG() == null;
+        if (product.getNutritionTags() != null && product.getNutritionTags().trim().isEmpty()) {
+            product.setNutritionTags(null);
+        }
+        boolean clearNutritionTags = existing.getNutritionTags() != null && product.getNutritionTags() == null;
         productMapper.updateById(product);
         nutritionProductService.syncFromProduct(product);
-        if (clearMinVisibleLevel || clearCategory || clearDistributionSystem) {
+        if (clearMinVisibleLevel || clearCategory || clearDistributionSystem
+                || clearCalories || clearProtein || clearFat || clearCarb || clearFiber
+                || clearNutritionTags) {
             LambdaUpdateWrapper<Product> clearWrapper = new LambdaUpdateWrapper<Product>()
                     .eq(Product::getId, product.getId());
             if (clearMinVisibleLevel) {
@@ -633,6 +648,24 @@ wrapper.in(Product::getId, productIds)
             if (clearDistributionSystem) {
                 clearWrapper.set(Product::getDistributionSystemId, null);
             }
+            if (clearCalories) {
+                clearWrapper.set(Product::getCaloriesKcal, null);
+            }
+            if (clearProtein) {
+                clearWrapper.set(Product::getProteinG, null);
+            }
+            if (clearFat) {
+                clearWrapper.set(Product::getFatG, null);
+            }
+            if (clearCarb) {
+                clearWrapper.set(Product::getCarbG, null);
+            }
+            if (clearFiber) {
+                clearWrapper.set(Product::getFiberG, null);
+            }
+            if (clearNutritionTags) {
+                clearWrapper.set(Product::getNutritionTags, null);
+            }
             productMapper.update(null, clearWrapper);
         }
         return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
@@ -731,6 +764,12 @@ wrapper.in(Product::getId, productIds)
         copy.setSortOrder(original.getSortOrder());
         copy.setVisibleRoles(original.getVisibleRoles());
         copy.setMinVisibleLevel(original.getMinVisibleLevel());
+        copy.setCaloriesKcal(original.getCaloriesKcal());
+        copy.setProteinG(original.getProteinG());
+        copy.setFatG(original.getFatG());
+        copy.setCarbG(original.getCarbG());
+        copy.setFiberG(original.getFiberG());
+        copy.setNutritionTags(original.getNutritionTags());
         copy.setCreatedAt(new Date());
         copy.setUpdatedAt(new Date());
         productMapper.insert(copy);

+ 0 - 106
cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java

@@ -1,11 +1,8 @@
 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;
@@ -29,26 +26,18 @@ public class ProductSkuService {
     @Resource
     private ProductMapper productMapper;
 
-    @Resource
-    private ProductSkuComboItemMapper comboItemMapper;
-
     public List<ProductSku> listByProductId(Long productId) {
         List<ProductSku> list = skuMapper.findByProductId(productId);
-        fillComboItems(list);
         return list;
     }
 
     public List<ProductSku> listByProductIdAll(Long productId) {
         List<ProductSku> list = skuMapper.findByProductIdAll(productId);
-        fillComboItems(list);
         return list;
     }
 
     public ProductSku getById(Long id) {
         ProductSku sku = skuMapper.findById(id);
-        if (sku != null) {
-            fillComboItems(java.util.Collections.singletonList(sku));
-        }
         return sku;
     }
 
@@ -69,18 +58,12 @@ 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()) {
@@ -89,7 +72,6 @@ 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())
@@ -99,8 +81,6 @@ public class ProductSkuService {
 
     @Transactional
     public void delete(Long id) {
-        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
-                .eq(ProductSkuComboItem::getSkuId, id));
         skuMapper.deleteById(id);
     }
 
@@ -236,90 +216,4 @@ public class ProductSkuService {
                 .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();
-    }
 }

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -136,7 +136,10 @@ public class RecommendationService {
                             .gt(Product::getStock, 0)
                             .and(w -> w.like(Product::getName, keyword)
                                     .or()
-                                    .like(Product::getDescription, keyword))
+                                    .like(Product::getDescription, keyword)
+                                    // 营养标签优先命中:商品已配置 nutrition_tags(逗号分隔)时按标签匹配
+                                    .or()
+                                    .like(Product::getNutritionTags, keyword))
                             .last("LIMIT " + (limit - results.size()))
             );
 

+ 2 - 163
cfc-backend/src/main/java/com/etotem/cfc/service/SupplyCustomerRelationService.java

@@ -3,21 +3,16 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.etotem.cfc.entity.FamilyMember;
-import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.SupplyCustomerRelation;
 import com.etotem.cfc.entity.SupplySystem;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
-import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.SupplyCustomerRelationMapper;
 import com.etotem.cfc.mapper.SupplySystemMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-import java.text.SimpleDateFormat;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -36,14 +31,10 @@ public class SupplyCustomerRelationService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
-    @Resource
-    private ProductOrderMapper productOrderMapper;
-
     /**
      * 查询某上级供应商下的客户列表
-     * 返回:{ list: [...], total: N } —— list 为分页后的客户(家庭ID、家庭名称、成员昵称、加入时间、归属来源
-     *       + 客户维度购买情况 orderCount / totalAmountYuan / recentOrders),
-     *       total 为该体系/上级下的客户总数(团队数据「客户归属」的真实总数,T31;T32 修订:客户维度购买明细,非 SKU 聚合)
+     * 返回:{ list: [...], total: N } —— list 为分页后的客户(家庭ID、家庭名称、成员昵称、加入时间、归属来源),
+     *       total 为该体系/上级下的客户总数(团队数据「客户归属」的真实总数,T31)
      */
     public Map<String, Object> listCustomers(Long systemId, Long parentId, Integer page, Integer size) {
         // parentId 为空时返回整个体系的客户列表(团队维度「团队客户」,T31);否则按指定上级过滤
@@ -61,9 +52,6 @@ public class SupplyCustomerRelationService {
             relations = relations.subList(fromIndex, toIndex);
         }
 
-        // 客户维度购买情况(T32 修订):一次查询本页客户关联的体系内已完成订单,按客户归集
-        Map<Long, Map<String, Object>> purchaseStats = buildCustomerPurchaseStats(systemId, relations);
-
         List<Map<String, Object>> result = new ArrayList<>();
         for (SupplyCustomerRelation relation : relations) {
             Map<String, Object> customer = new HashMap<>();
@@ -105,18 +93,6 @@ public class SupplyCustomerRelationService {
                 }
             }
 
-            // 客户维度购买明细(T32 修订):关联订单数 / 关联金额(元) / 最近 1-3 笔
-            Map<String, Object> stats = purchaseStats.get(relation.getId());
-            if (stats != null) {
-                customer.put("orderCount", stats.get("orderCount"));
-                customer.put("totalAmountYuan", stats.get("totalAmountYuan"));
-                customer.put("recentOrders", stats.get("recentOrders"));
-            } else {
-                customer.put("orderCount", 0);
-                customer.put("totalAmountYuan", BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
-                customer.put("recentOrders", Collections.emptyList());
-            }
-
             result.add(customer);
         }
         Map<String, Object> wrapper = new HashMap<>();
@@ -125,143 +101,6 @@ public class SupplyCustomerRelationService {
         return wrapper;
     }
 
-    /**
-     * 客户维度购买情况(T32 修订,规格 §3.2):
-     * 数据来源 = product_orders WHERE supplySystemId=? AND (buyerId=? OR familyId=?)
-     *            AND fulfillStatus='completed'(去重 orderNo)。
-     * orderCount=去重订单数;totalAmountYuan=sum(totalAmount)/100(2 位 HALF_UP);
-     * recentOrders=最近 1-3 笔(orderNo, productName, amountYuan, paidAt)。
-     * 一次查询本页客户关联订单,内存归集,避免 N+1。
-     */
-    private Map<Long, Map<String, Object>> buildCustomerPurchaseStats(Long systemId, List<SupplyCustomerRelation> relations) {
-        Map<Long, Map<String, Object>> statsById = new HashMap<>();
-
-        Set<Long> buyerIds = new HashSet<>();
-        Set<Long> familyIds = new HashSet<>();
-        for (SupplyCustomerRelation r : relations) {
-            if (r.getChildUserId() != null) buyerIds.add(r.getChildUserId());
-            if (r.getFamilyId() != null) familyIds.add(r.getFamilyId());
-        }
-
-        // 无任何可匹配的买家/家庭时,全部归零
-        if (buyerIds.isEmpty() && familyIds.isEmpty()) {
-            for (SupplyCustomerRelation r : relations) {
-                statsById.put(r.getId(), zeroPurchaseStats());
-            }
-            return statsById;
-        }
-
-        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(ProductOrder::getSupplySystemId, systemId)
-               .eq(ProductOrder::getFulfillStatus, "completed");
-        wrapper.and(w -> {
-            boolean first = true;
-            if (!buyerIds.isEmpty()) {
-                w.in(ProductOrder::getBuyerId, buyerIds);
-                first = false;
-            }
-            if (!familyIds.isEmpty()) {
-                if (!first) {
-                    w.or();
-                }
-                w.in(ProductOrder::getFamilyId, familyIds);
-            }
-        });
-        List<ProductOrder> orders = productOrderMapper.selectList(wrapper);
-
-        for (SupplyCustomerRelation r : relations) {
-            Long buyerId = r.getChildUserId();
-            Long familyId = r.getFamilyId();
-
-            // 去重 orderNo(同一订单多行/多商品只计一笔,取金额最大的一行代表整单)
-            Map<String, ProductOrder> byOrderNo = new LinkedHashMap<>();
-            for (ProductOrder o : orders) {
-                boolean match = false;
-                if (buyerId != null && buyerId.equals(o.getBuyerId())) {
-                    match = true;
-                }
-                if (!match && familyId != null && familyId.equals(o.getFamilyId())) {
-                    match = true;
-                }
-                if (!match) {
-                    continue;
-                }
-                ProductOrder existing = byOrderNo.get(o.getOrderNo());
-                if (existing == null
-                        || (o.getTotalAmount() != null
-                            && (existing.getTotalAmount() == null || o.getTotalAmount() > existing.getTotalAmount()))) {
-                    byOrderNo.put(o.getOrderNo(), o);
-                }
-            }
-
-            statsById.put(r.getId(), buildStatsFromOrders(byOrderNo.values()));
-        }
-        return statsById;
-    }
-
-    private Map<String, Object> buildStatsFromOrders(Collection<ProductOrder> orders) {
-        int orderCount = 0;
-        long totalCents = 0;
-        for (ProductOrder o : orders) {
-            orderCount++;
-            if (o.getTotalAmount() != null) {
-                totalCents += o.getTotalAmount();
-            }
-        }
-
-        List<ProductOrder> sorted = new ArrayList<>(orders);
-        sorted.sort((a, b) -> {
-            Date da = a.getPaidAt() != null ? a.getPaidAt() : a.getCreatedAt();
-            Date db = b.getPaidAt() != null ? b.getPaidAt() : b.getCreatedAt();
-            if (da == null && db == null) return 0;
-            if (da == null) return 1;
-            if (db == null) return -1;
-            return db.compareTo(da);
-        });
-
-        SimpleDateFormat isoFmt = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
-        List<Map<String, Object>> recentOrders = new ArrayList<>();
-        for (int i = 0; i < Math.min(3, sorted.size()); i++) {
-            ProductOrder o = sorted.get(i);
-            Map<String, Object> item = new HashMap<>();
-            item.put("orderNo", o.getOrderNo());
-            item.put("productName", o.getProductName());
-            item.put("amountYuan", centsToYuan(o.getTotalAmount()));
-            Date paidAt = o.getPaidAt() != null ? o.getPaidAt() : o.getCreatedAt();
-            item.put("paidAt", paidAt != null ? isoFmt.format(paidAt) : null);
-            recentOrders.add(item);
-        }
-
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("orderCount", orderCount);
-        stats.put("totalAmountYuan", centsToYuan(totalCents));
-        stats.put("recentOrders", recentOrders);
-        return stats;
-    }
-
-    private Map<String, Object> zeroPurchaseStats() {
-        Map<String, Object> stats = new HashMap<>();
-        stats.put("orderCount", 0);
-        stats.put("totalAmountYuan", BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
-        stats.put("recentOrders", Collections.emptyList());
-        return stats;
-    }
-
-    /** 分 → 元,2 位 HALF_UP */
-    private BigDecimal centsToYuan(Long cents) {
-        if (cents == null) {
-            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
-        }
-        return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
-    }
-
-    private BigDecimal centsToYuan(Integer cents) {
-        if (cents == null) {
-            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
-        }
-        return BigDecimal.valueOf(cents).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
-    }
-
     /**
      * 绑定客户归属关系(人工绑定)
      */

+ 0 - 376
cfc-backend/src/main/java/com/etotem/cfc/service/SupplyServiceAssignmentService.java

@@ -1,376 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.ProductOrder;
-import com.etotem.cfc.entity.ProductServicePerson;
-import com.etotem.cfc.entity.SupplyCustomerRelation;
-import com.etotem.cfc.entity.SupplyServiceAssignment;
-import com.etotem.cfc.entity.SupplySystemMember;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.ProductServicePersonMapper;
-import com.etotem.cfc.mapper.SupplyCustomerRelationMapper;
-import com.etotem.cfc.mapper.SupplyServiceAssignmentMapper;
-import com.etotem.cfc.mapper.SupplySystemMemberMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import javax.annotation.Resource;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * 团队服务指派(T34,见 specs/2026-11-01-team-system-design.md §3.4)。
- *
- * <p>把服务性内容(售后/咨询/活动支持)指派给团队成员跟进;成员在「服务任务」中查看并推进状态
- * (PENDING → PROCESSING → DONE)。售后建单时可通过 {@link #createFromAfterSales} 自动生成待指派任务。</p>
- */
-@Service
-public class SupplyServiceAssignmentService {
-
-    private static final Logger log = LoggerFactory.getLogger(SupplyServiceAssignmentService.class);
-
-    public static final String TYPE_AFTER_SALE = "AFTER_SALE";
-    public static final String TYPE_CONSULT = "CONSULT";
-    public static final String TYPE_ACTIVITY_SUPPORT = "ACTIVITY_SUPPORT";
-
-    public static final String STATUS_PENDING = "PENDING";
-    public static final String STATUS_PROCESSING = "PROCESSING";
-    public static final String STATUS_DONE = "DONE";
-
-    @Resource
-    private SupplyServiceAssignmentMapper supplyServiceAssignmentMapper;
-
-    @Resource
-    private SupplyCustomerRelationMapper supplyCustomerRelationMapper;
-
-    @Resource
-    private SupplySystemMemberMapper supplySystemMemberMapper;
-
-    @Resource
-    private ProductServicePersonMapper productServicePersonMapper;
-
-    @Resource
-    private UserMapper userMapper;
-
-    @Resource
-    private SupplySystemService supplySystemService;
-
-    /** 服务类型白名单 */
-    private static final Set<String> SERVICE_TYPES = new java.util.HashSet<>(java.util.Arrays.asList(
-            TYPE_AFTER_SALE, TYPE_CONSULT, TYPE_ACTIVITY_SUPPORT));
-
-    /**
-     * 服务指派:把某客户(售后/咨询)指派给某成员跟进。
-     *
-     * @param systemId           团队体系ID(必填)
-     * @param customerRelationId 客户归属关系ID(supply_customer_relation.id,选填)
-     * @param assigneeUserId     被指派成员用户ID(必填)
-     * @param serviceType        服务类型 AFTER_SALE/CONSULT/ACTIVITY_SUPPORT
-     * @param refOrderId         关联订单ID(选填)
-     * @param refRequestId       关联请求ID(选填)
-     * @param note               备注(选填)
-     * @param assignedBy         指派人用户ID
-     */
-    @Transactional
-    public Result<SupplyServiceAssignment> assign(Long systemId, Long customerRelationId, Long assigneeUserId,
-                                                  String serviceType, Long refOrderId, Long refRequestId,
-                                                  String note, Long assignedBy) {
-        if (systemId == null) {
-            return Result.error("systemId不能为空");
-        }
-        if (assigneeUserId == null) {
-            return Result.error("assigneeUserId不能为空");
-        }
-        if (serviceType == null || !SERVICE_TYPES.contains(serviceType)) {
-            return Result.error("serviceType不合法,须为 AFTER_SALE/CONSULT/ACTIVITY_SUPPORT");
-        }
-
-        // 校验客户归属关系属于该体系(指定时)
-        if (customerRelationId != null) {
-            SupplyCustomerRelation relation = supplyCustomerRelationMapper.selectById(customerRelationId);
-            if (relation == null || !systemId.equals(relation.getSystemId())) {
-                return Result.error("客户归属关系不存在或不属于该团队");
-            }
-        }
-
-        // 校验被指派人是有效候选人:团队成员 或 商品服务人
-        if (!isValidAssignee(systemId, assigneeUserId)) {
-            return Result.error("被指派人不属于该团队成员,也不是商品服务人");
-        }
-
-        SupplyServiceAssignment assignment = new SupplyServiceAssignment();
-        assignment.setSystemId(systemId);
-        assignment.setCustomerRelationId(customerRelationId);
-        assignment.setAssigneeUserId(assigneeUserId);
-        assignment.setServiceType(serviceType);
-        assignment.setRefOrderId(refOrderId);
-        assignment.setRefRequestId(refRequestId);
-        assignment.setStatus(STATUS_PENDING);
-        assignment.setAssignedBy(assignedBy);
-        assignment.setAssignedAt(new Date());
-        assignment.setNote(note);
-        assignment.setCreatedAt(new Date());
-        assignment.setUpdatedAt(new Date());
-        supplyServiceAssignmentMapper.insert(assignment);
-        log.info("服务指派已创建: id={}, systemId={}, assignee={}, type={}",
-                assignment.getId(), systemId, assigneeUserId, serviceType);
-        return Result.success(assignment);
-    }
-
-    /**
-     * 成员侧「派给我的任务」/ 团队侧服务任务列表。
-     *
-     * @param systemId 团队体系ID(团队侧查看时必填)
-     * @param scope    mine=仅派给我的(默认);team=团队全部任务(需为团队成员/管理员)
-     * @param userId   当前登录用户
-     */
-    public List<Map<String, Object>> listTasks(Long systemId, String scope, String status, Long userId,
-                                               Integer page, Integer size) {
-        boolean teamScope = "team".equals(scope);
-        if (teamScope && (systemId == null || !supplySystemService.isMemberOrAdmin(systemId, userId))) {
-            return new ArrayList<>();
-        }
-
-        LambdaQueryWrapper<SupplyServiceAssignment> wrapper = new LambdaQueryWrapper<>();
-        if (systemId != null) {
-            wrapper.eq(SupplyServiceAssignment::getSystemId, systemId);
-        }
-        if (teamScope) {
-            if (systemId == null) {
-                // 未指定团队时退化为「派给我的」
-                wrapper.eq(SupplyServiceAssignment::getAssigneeUserId, userId);
-            }
-        } else {
-            wrapper.eq(SupplyServiceAssignment::getAssigneeUserId, userId);
-        }
-        if (status != null && !status.isEmpty()) {
-            wrapper.eq(SupplyServiceAssignment::getStatus, status);
-        }
-        wrapper.orderByDesc(SupplyServiceAssignment::getAssignedAt);
-
-        List<SupplyServiceAssignment> all = supplyServiceAssignmentMapper.selectList(wrapper);
-        return buildTaskList(all, page, size);
-    }
-
-    /**
-     * 某客户的全部服务记录(对客户归属关系维度)。
-     */
-    public List<Map<String, Object>> listCustomerServices(Long customerRelationId, Long userId) {
-        if (customerRelationId == null) {
-            return new ArrayList<>();
-        }
-        SupplyCustomerRelation relation = supplyCustomerRelationMapper.selectById(customerRelationId);
-        if (relation == null || !supplySystemService.isMemberOrAdmin(relation.getSystemId(), userId)) {
-            return new ArrayList<>();
-        }
-        LambdaQueryWrapper<SupplyServiceAssignment> wrapper = new LambdaQueryWrapper<SupplyServiceAssignment>()
-                .eq(SupplyServiceAssignment::getCustomerRelationId, customerRelationId)
-                .orderByDesc(SupplyServiceAssignment::getAssignedAt);
-        return buildTaskList(supplyServiceAssignmentMapper.selectList(wrapper), 1, 100);
-    }
-
-    /**
-     * 更新任务状态(PENDING→PROCESSING→DONE)。
-     * 仅被指派成员本人或团队管理员可操作。
-     */
-    @Transactional
-    public Result<String> updateStatus(Long id, String status, String note, Long userId) {
-        if (id == null || status == null || status.isEmpty()) {
-            return Result.error("id/status不能为空");
-        }
-        if (!STATUS_PENDING.equals(status) && !STATUS_PROCESSING.equals(status) && !STATUS_DONE.equals(status)) {
-            return Result.error("status不合法,须为 PENDING/PROCESSING/DONE");
-        }
-        SupplyServiceAssignment assignment = supplyServiceAssignmentMapper.selectById(id);
-        if (assignment == null) {
-            return Result.error("服务任务不存在");
-        }
-        // 权限:被指派成员本人 或 团队管理员/成员
-        boolean isAssignee = assignment.getAssigneeUserId() != null && assignment.getAssigneeUserId().equals(userId);
-        boolean isTeamMember = assignment.getSystemId() != null
-                && supplySystemService.isMemberOrAdmin(assignment.getSystemId(), userId);
-        if (!isAssignee && !isTeamMember) {
-            return Result.error("无权操作该服务任务");
-        }
-
-        assignment.setStatus(status);
-        if (STATUS_DONE.equals(status)) {
-            assignment.setDoneAt(new Date());
-        }
-        if (note != null) {
-            assignment.setNote(note);
-        }
-        assignment.setUpdatedAt(new Date());
-        supplyServiceAssignmentMapper.updateById(assignment);
-        return Result.success("操作成功");
-    }
-
-    /**
-     * 指派候选人:团队成员(supply_system_member active)+ 商品服务人(product_service_persons)。
-     */
-    public List<Map<String, Object>> listCandidates(Long systemId, Long userId) {
-        if (systemId == null || !supplySystemService.isMemberOrAdmin(systemId, userId)) {
-            return new ArrayList<>();
-        }
-        List<Map<String, Object>> candidates = new ArrayList<>();
-
-        // 1. 团队成员
-        List<SupplySystemMember> members = supplySystemMemberMapper.selectList(
-                new LambdaQueryWrapper<SupplySystemMember>()
-                        .eq(SupplySystemMember::getSystemId, systemId)
-                        .eq(SupplySystemMember::getStatus, "active"));
-        for (SupplySystemMember m : members) {
-            User u = userMapper.selectById(m.getUserId());
-            Map<String, Object> c = new HashMap<>();
-            c.put("userId", m.getUserId());
-            c.put("nickname", u != null ? u.getNickname() : "成员" + m.getUserId());
-            c.put("avatar", u != null ? u.getAvatar() : null);
-            c.put("role", m.getRole());
-            c.put("source", "MEMBER");
-            candidates.add(c);
-        }
-
-        // 2. 商品服务人(去重已出现的 userId)
-        Set<Long> seen = members.stream().map(SupplySystemMember::getUserId).collect(Collectors.toSet());
-        List<ProductServicePerson> persons = productServicePersonMapper.selectList(
-                new LambdaQueryWrapper<ProductServicePerson>().eq(ProductServicePerson::getEnabled, 1));
-        for (ProductServicePerson p : persons) {
-            if (p.getUserId() == null || seen.contains(p.getUserId())) {
-                continue;
-            }
-            seen.add(p.getUserId());
-            Map<String, Object> c = new HashMap<>();
-            c.put("userId", p.getUserId());
-            c.put("nickname", p.getName() != null ? p.getName() : "服务人" + p.getUserId());
-            c.put("avatar", p.getAvatar());
-            c.put("title", p.getTitle());
-            c.put("role", "member");
-            c.put("source", "SERVICE_PERSON");
-            candidates.add(c);
-        }
-        return candidates;
-    }
-
-    /**
-     * 售后建单联动:自动生成一条 PENDING 服务任务(refRequestId 关联售后单)。
-     * 指派给订单的履约服务人(product_orders.service_person_id 即商品服务人),未选择则为待指派(assigneeUserId=NULL)。
-     * 幂等:同一售后单不重复生成。
-     */
-    @Transactional
-    public void createFromAfterSales(ProductOrder order, Long afterSalesRequestId) {
-        try {
-            if (order == null || order.getSupplySystemId() == null || afterSalesRequestId == null) {
-                return;
-            }
-            Long count = supplyServiceAssignmentMapper.selectCount(
-                    new LambdaQueryWrapper<SupplyServiceAssignment>()
-                            .eq(SupplyServiceAssignment::getRefRequestId, afterSalesRequestId)
-                            .eq(SupplyServiceAssignment::getServiceType, TYPE_AFTER_SALE));
-            if (count != null && count > 0) {
-                return; // 已生成过,幂等
-            }
-
-            SupplyServiceAssignment assignment = new SupplyServiceAssignment();
-            assignment.setSystemId(order.getSupplySystemId());
-            assignment.setCustomerRelationId(findCustomerRelationId(order.getSupplySystemId(), order.getFamilyId()));
-            assignment.setAssigneeUserId(order.getServicePersonId()); // 商品服务人:可选,可为空=待指派
-            assignment.setServiceType(TYPE_AFTER_SALE);
-            assignment.setRefOrderId(order.getId());
-            assignment.setRefRequestId(afterSalesRequestId);
-            assignment.setStatus(STATUS_PENDING);
-            assignment.setAssignedAt(new Date());
-            assignment.setNote("售后申请自动生成服务任务");
-            assignment.setCreatedAt(new Date());
-            assignment.setUpdatedAt(new Date());
-            supplyServiceAssignmentMapper.insert(assignment);
-            log.info("售后建单自动生成服务任务: assignmentId={}, systemId={}, refRequestId={}",
-                    assignment.getId(), order.getSupplySystemId(), afterSalesRequestId);
-        } catch (Exception e) {
-            log.warn("售后建单自动生成服务任务失败(不阻断主流程): {}", e.getMessage());
-        }
-    }
-
-    /** 校验 assignee 是否为有效候选人:团队成员 或 商品服务人 */
-    private boolean isValidAssignee(Long systemId, Long assigneeUserId) {
-        Long memberCount = supplySystemMemberMapper.selectCount(
-                new LambdaQueryWrapper<SupplySystemMember>()
-                        .eq(SupplySystemMember::getSystemId, systemId)
-                        .eq(SupplySystemMember::getUserId, assigneeUserId)
-                        .eq(SupplySystemMember::getStatus, "active"));
-        if (memberCount != null && memberCount > 0) {
-            return true;
-        }
-        Long personCount = productServicePersonMapper.selectCount(
-                new LambdaQueryWrapper<ProductServicePerson>()
-                        .eq(ProductServicePerson::getUserId, assigneeUserId)
-                        .eq(ProductServicePerson::getEnabled, 1));
-        return personCount != null && personCount > 0;
-    }
-
-    /** 按 体系ID+家庭ID 查客户归属关系ID(不存在返回 null) */
-    private Long findCustomerRelationId(Long systemId, Long familyId) {
-        if (systemId == null || familyId == null) {
-            return null;
-        }
-        SupplyCustomerRelation relation = supplyCustomerRelationMapper.selectOne(
-                new LambdaQueryWrapper<SupplyCustomerRelation>()
-                        .eq(SupplyCustomerRelation::getSystemId, systemId)
-                        .eq(SupplyCustomerRelation::getFamilyId, familyId)
-                        .last("LIMIT 1"));
-        return relation != null ? relation.getId() : null;
-    }
-
-    /** 组装任务列表 VO(含客户昵称/被指派成员昵称/订单号) */
-    private List<Map<String, Object>> buildTaskList(List<SupplyServiceAssignment> list, Integer page, Integer size) {
-        int p = page != null && page > 0 ? page : 1;
-        int s = size != null && size > 0 ? size : 20;
-        List<Map<String, Object>> result = new ArrayList<>();
-        if (list == null || list.isEmpty()) {
-            return result;
-        }
-        int from = (p - 1) * s;
-        if (from >= list.size()) {
-            return result;
-        }
-        int to = Math.min(from + s, list.size());
-        for (int i = from; i < to; i++) {
-            SupplyServiceAssignment a = list.get(i);
-            Map<String, Object> task = new HashMap<>();
-            task.put("id", a.getId());
-            task.put("systemId", a.getSystemId());
-            task.put("customerRelationId", a.getCustomerRelationId());
-            task.put("assigneeUserId", a.getAssigneeUserId());
-            task.put("serviceType", a.getServiceType());
-            task.put("refOrderId", a.getRefOrderId());
-            task.put("refRequestId", a.getRefRequestId());
-            task.put("status", a.getStatus());
-            task.put("assignedBy", a.getAssignedBy());
-            task.put("assignedAt", a.getAssignedAt());
-            task.put("doneAt", a.getDoneAt());
-            task.put("note", a.getNote());
-
-            if (a.getCustomerRelationId() != null) {
-                SupplyCustomerRelation relation = supplyCustomerRelationMapper.selectById(a.getCustomerRelationId());
-                if (relation != null && relation.getFamilyId() != null) {
-                    task.put("familyId", relation.getFamilyId());
-                }
-            }
-            if (a.getAssigneeUserId() != null) {
-                User assignee = userMapper.selectById(a.getAssigneeUserId());
-                task.put("assigneeNickname", assignee != null ? assignee.getNickname() : null);
-                task.put("assigneeAvatar", assignee != null ? assignee.getAvatar() : null);
-            }
-            result.add(task);
-        }
-        return result;
-    }
-}

+ 15 - 44
cfc-backend/src/main/resources/schema.sql

@@ -1180,6 +1180,12 @@ CREATE TABLE IF NOT EXISTS products (
     profit_rate INT DEFAULT 0 COMMENT '平台利润率(千分比)',
     sort_order INT DEFAULT 0 COMMENT '排序(值越小越靠前)',
     points_multiplier DECIMAL(10,2) NOT NULL DEFAULT 1.00 COMMENT '积分倍数【已迁移→service_config cf.rate.points_multiplier,本列仅过渡期兼容】',
+    calories_kcal DECIMAL(10,2) DEFAULT NULL COMMENT '热量(kcal/100g),NULL=未配置',
+    protein_g DECIMAL(10,2) DEFAULT NULL COMMENT '蛋白质(g/100g),NULL=未配置',
+    fat_g DECIMAL(10,2) DEFAULT NULL COMMENT '脂肪(g/100g),NULL=未配置',
+    carb_g DECIMAL(10,2) DEFAULT NULL COMMENT '碳水化合物(g/100g),NULL=未配置',
+    fiber_g DECIMAL(10,2) DEFAULT NULL COMMENT '膳食纤维(g/100g),NULL=未配置',
+    nutrition_tags VARCHAR(255) DEFAULT NULL COMMENT '营养标签,逗号分隔(如"低糖,高蛋白"),NULL=无标签',
     delivery_method TINYINT DEFAULT 1 COMMENT '配送方式: 1=快递 2=自提 3=两者皆可 4=线上-可选人 5=线上-单人 6=同城配送',
     purchase_count_threshold INT DEFAULT 0 COMMENT '最小购买数量限制',
     category_id BIGINT COMMENT '商品类目ID',
@@ -5625,30 +5631,17 @@ CREATE TABLE IF NOT EXISTS system_prompt_versions (
     INDEX idx_prompt_key (prompt_key),
     INDEX idx_version (prompt_key, version)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统提示词版本历史';
--- 套餐商品组成明细表(套餐 = 商品 productType=bundle,子商品在 product_bundle_items 中配置)
-CREATE TABLE IF NOT EXISTS product_bundle_items (
-    id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    bundle_product_id BIGINT DEFAULT NULL COMMENT '套餐商品ID(定价套餐行置NULL以避免误触发出库联动;出库联动行=$套餐商品ID)',
-    bundle_sku_id BIGINT DEFAULT NULL COMMENT '套餐SKU锚点:非空=定价套餐行(订单套餐自动定价,套餐价取该SKU.price);空=仅出库联动',
+-- 套餐内容明细表(套餐=独立商品 product_type='bundle',内容=子商品SKU×数量,挂套餐SKU级)
+CREATE TABLE IF NOT EXISTS product_combo_items (
+    id BIGINT NOT NULL AUTO_INCREMENT,
+    combo_sku_id BIGINT NOT NULL COMMENT '套餐SKU ID(product_skus.id),内容挂SKU级',
     child_product_id BIGINT NOT NULL COMMENT '子商品ID',
-    child_sku_id BIGINT DEFAULT NULL COMMENT '子SKU ID(为空时直接扣 product.stock)',
-    quantity INT NOT NULL DEFAULT 1 COMMENT '子商品数量',
+    child_sku_id BIGINT NOT NULL COMMENT '子商品SKU ID(精确到规格,必填)',
+    quantity INT NOT NULL DEFAULT 1 COMMENT '该子商品数量',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-    INDEX idx_bundle (bundle_product_id),
-    INDEX idx_child (child_product_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成明细';
-
--- 商品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组合明细';
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_combo_sku_child (combo_sku_id, child_sku_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐内容明细(套餐商品包含的子商品SKU×数量)';
 
 -- 联系人表(cfclub 联系人管理)
 CREATE TABLE IF NOT EXISTS contacts (
@@ -6355,28 +6348,6 @@ CREATE TABLE IF NOT EXISTS supply_customer_relation (
     UNIQUE KEY uk_system_family (system_id, family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商团队客户归属关系表';
 
--- 迁移438: 创建 supply_service_assignment 表(团队服务指派,规格 2026-11-01-team-system-design.md §3.4 / T34)
-CREATE TABLE IF NOT EXISTS supply_service_assignment (
-    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
-    system_id BIGINT NOT NULL COMMENT '团队体系ID(supply_system.id)',
-    customer_relation_id BIGINT DEFAULT NULL COMMENT '客户归属关系ID(supply_customer_relation.id)',
-    assignee_user_id BIGINT DEFAULT NULL COMMENT '被指派成员用户ID(users.id),NULL=待指派',
-    service_type VARCHAR(32) NOT NULL COMMENT '服务类型: AFTER_SALE/CONSULT/ACTIVITY_SUPPORT',
-    ref_order_id BIGINT DEFAULT NULL COMMENT '关联订单ID(product_orders.id)',
-    ref_request_id BIGINT DEFAULT NULL COMMENT '关联请求ID(after_sales_requests.id)',
-    status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态: PENDING/PROCESSING/DONE',
-    assigned_by BIGINT DEFAULT NULL COMMENT '指派人用户ID',
-    assigned_at DATETIME DEFAULT NULL COMMENT '指派时间',
-    done_at DATETIME DEFAULT NULL COMMENT '完成时间',
-    note VARCHAR(500) DEFAULT NULL COMMENT '备注',
-    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-    INDEX idx_system_status (system_id, status),
-    INDEX idx_assignee_status (assignee_user_id, status),
-    INDEX idx_customer_relation (customer_relation_id),
-    INDEX idx_ref_request (ref_request_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队服务指派表';
-
 -- 迁移345: 商品维度关联表(product_dimension_mapping)
 CREATE TABLE IF NOT EXISTS product_dimension_mapping (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',

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

@@ -1,129 +0,0 @@
-package com.etotem.cfc.service;
-
-import com.etotem.cfc.entity.OrderItem;
-import com.etotem.cfc.entity.ProductOrder;
-import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.entity.ProductSkuComboItem;
-import com.etotem.cfc.mapper.OrderItemMapper;
-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;
-    private OrderItemMapper orderItemMapper;
-
-    @BeforeEach
-    void setUp() {
-        orderService = new ProductOrderService();
-        skuService = mock(ProductSkuService.class);
-        productService = mock(ProductService.class);
-        inventoryService = mock(InventoryService.class);
-        orderItemMapper = mock(OrderItemMapper.class);
-        ReflectionTestUtils.setField(orderService, "productSkuService", skuService);
-        ReflectionTestUtils.setField(orderService, "productService", productService);
-        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
-        ReflectionTestUtils.setField(orderService, "orderItemMapper", orderItemMapper);
-    }
-
-    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);
-    }
-
-    @Test
-    void deductComboStock_mixedOrder_usesComboLineQuantity() {
-        ProductOrder o = order();
-        o.setQuantity(6); // 订单总数量含其他行,组合行自身仅 1
-        OrderItem comboLine = new OrderItem();
-        comboLine.setSkuId(9L);
-        comboLine.setQuantity(1);
-        when(orderItemMapper.selectList(any())).thenReturn(java.util.Collections.singletonList(comboLine));
-        when(skuService.decreaseStock(100L, 2)).thenReturn(true); // 2(组合unit) × 1(行数量)
-        when(productService.decreaseStock(11L, 1)).thenReturn(true);
-
-        orderService.deductComboStock(combo(), o, "PO1");
-
-        verify(skuService).decreaseStock(100L, 2);
-        verify(inventoryService).recordOutbound(10L, 100L, 2, "order", 77L, null, "组合出库:PO1");
-        verify(productService).decreaseStock(11L, 1);
-        verify(inventoryService).recordOutbound(11L, null, 1, "order", 77L, null, "组合出库:PO1");
-    }
-}

+ 0 - 168
cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingCalculatorTest.java

@@ -1,168 +0,0 @@
-package com.etotem.cfc.unit;
-
-import com.etotem.cfc.pricing.BundleChild;
-import com.etotem.cfc.pricing.BundleDef;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.BundleHit;
-import com.etotem.cfc.pricing.BundlePricingCalculator;
-import com.etotem.cfc.pricing.LineItem;
-import org.junit.jupiter.api.Test;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/**
- * 订单套餐自动定价核心算法单测(TDD)。
- * 规则:凑整套餐+单品兜底;多套餐贪心选节省额最大;节省额<=0不触发;
- * child_sku_id 为空按商品聚合(跨 SKU);不同基础款套餐互不干扰。
- */
-class BundlePricingCalculatorTest {
-
-    private final BundlePricingCalculator calc = new BundlePricingCalculator();
-
-    private LineItem line(Long productId, Long skuId, int qty, int unitPrice) {
-        return new LineItem(productId, skuId, qty, unitPrice);
-    }
-
-    private BundleChild child(Long productId, Long childSkuId, int qty) {
-        return new BundleChild(productId, childSkuId, qty);
-    }
-
-    private BundleDef def(Long bundleSkuId, String name, int bundlePrice, BundleChild... children) {
-        return new BundleDef(bundleSkuId, name, bundlePrice, Arrays.asList(children));
-    }
-
-    @Test
-    void singleBundleHit_twoBasicItems() {
-        // 2×69900 配套餐(2个基础款=128000) → 命中,省 11800
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
-        List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertTrue(r.isActive());
-        assertEquals(139800, r.getOriginalTotal());
-        assertEquals(128000, r.getPayPrice());
-        assertEquals(11800, r.getDiscount());
-        assertEquals(1, r.getBundleList().size());
-        BundleHit hit = r.getBundleList().get(0);
-        assertEquals(3L, hit.getBundleSkuId());
-        assertEquals(1, hit.getTimes());
-        assertEquals(11800, hit.getSaving());
-    }
-
-    @Test
-    void wholeBundlePlusSingleFallback_threeItems() {
-        // 3×69900 → 1套套餐(128000) + 1个单品兜底(69900),只算命中部分
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 3, 69900));
-        List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertTrue(r.isActive());
-        assertEquals(1, r.getBundleList().get(0).getTimes());
-        assertEquals(139800, r.getOriginalTotal());
-        assertEquals(128000, r.getPayPrice());
-        assertEquals(11800, r.getDiscount());
-    }
-
-    @Test
-    void multiBundle_pickMaxSaving() {
-        // 2×69900,两个套餐都凑得齐:defA省11800、defB省29800 → 选defB
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
-        List<BundleDef> defs = Arrays.asList(
-                def(3L, "套餐一", 128000, child(10L, null, 2)),
-                def(9L, "套餐二", 110000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertTrue(r.isActive());
-        assertEquals(1, r.getBundleList().size());
-        assertEquals(9L, r.getBundleList().get(0).getBundleSkuId());
-        assertEquals(29800, r.getDiscount());
-        assertEquals(110000, r.getPayPrice());
-    }
-
-    @Test
-    void savingNotPositive_notTriggered_memberPriceBelowBundle() {
-        // 会员价 55900×2=111800 < 128000 → 按套餐反而贵,不触发
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 55900));
-        List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 128000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertFalse(r.isActive());
-        assertEquals(0, r.getDiscount());
-    }
-
-    @Test
-    void childSkuNull_aggregateAcrossSkus() {
-        // 两行不同 sku(69900 + 59900)配 product10 qty2(child_sku_id 空→按商品聚合)
-        List<LineItem> lines = Arrays.asList(
-                line(10L, 2L, 1, 69900),
-                line(10L, 4L, 1, 59900));
-        List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 120000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertTrue(r.isActive());
-        assertEquals(129800, r.getOriginalTotal());
-        assertEquals(9800, r.getDiscount());
-    }
-
-    @Test
-    void emptyLinesOrDefs_inactive() {
-        assertFalse(calc.compute(Collections.emptyList(), Collections.singletonList(
-                def(3L, "套餐一", 128000, child(10L, null, 2)))).isActive());
-        assertFalse(calc.compute(Collections.singletonList(line(10L, null, 2, 69900)), Collections.emptyList()).isActive());
-    }
-
-    @Test
-    void bundlePriceAboveOriginal_notTriggered() {
-        // 套餐价 150000 > 原价 139800 → 不触发
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
-        List<BundleDef> defs = Collections.singletonList(def(3L, "套餐一", 150000, child(10L, null, 2)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertFalse(r.isActive());
-        assertEquals(0, r.getDiscount());
-    }
-
-    @Test
-    void twoIndependentBundles_bothHit() {
-        // 不同基础款的两个套餐同单各自命中:product10×2(69900)→套餐一128000省11800;
-        // product20×1(99900)→套餐二89900省10000
-        List<LineItem> lines = Arrays.asList(
-                line(10L, null, 2, 69900),
-                line(20L, null, 1, 99900));
-        List<BundleDef> defs = Arrays.asList(
-                def(3L, "套餐一", 128000, child(10L, null, 2)),
-                def(9L, "套餐二", 89900, child(20L, null, 1)));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertTrue(r.isActive());
-        assertEquals(2, r.getBundleList().size());
-        assertEquals(239700, r.getOriginalTotal());
-        assertEquals(217900, r.getPayPrice());
-        assertEquals(21800, r.getDiscount());
-    }
-
-    @Test
-    void defWithoutChildren_skipped() {
-        List<LineItem> lines = Collections.singletonList(line(10L, null, 2, 69900));
-        List<BundleDef> defs = new ArrayList<>();
-        defs.add(new BundleDef(3L, "无子商品", 128000, Collections.emptyList()));
-
-        BundleDiscountResult r = calc.compute(lines, defs);
-
-        assertFalse(r.isActive());
-    }
-}

+ 0 - 126
cfc-backend/src/test/java/com/etotem/cfc/unit/BundlePricingServiceTest.java

@@ -1,126 +0,0 @@
-package com.etotem.cfc.unit;
-
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.etotem.cfc.entity.ProductBundleItem;
-import com.etotem.cfc.entity.ProductSku;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.LineItem;
-import com.etotem.cfc.service.BundlePricingService;
-import com.etotem.cfc.service.ProductSkuService;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
-
-/**
- * BundlePricingService 映射单测:装载 bundle_sku_id 非空行、组装 BundleDef、委托计算。
- */
-@ExtendWith(MockitoExtension.class)
-class BundlePricingServiceTest {
-
-    @Mock
-    private ProductBundleItemMapper productBundleItemMapper;
-    @Mock
-    private ProductSkuService productSkuService;
-    @InjectMocks
-    private BundlePricingService service;
-
-    private ProductBundleItem row(Long bundleSkuId, Long childProductId, Long childSkuId, int qty) {
-        ProductBundleItem r = new ProductBundleItem();
-        r.setBundleSkuId(bundleSkuId);
-        r.setChildProductId(childProductId);
-        r.setChildSkuId(childSkuId);
-        r.setQuantity(qty);
-        return r;
-    }
-
-    private ProductSku sku(Long id, String label, int price) {
-        ProductSku s = new ProductSku();
-        s.setId(id);
-        s.setLabel(label);
-        s.setPrice(price);
-        return s;
-    }
-
-    @Test
-    void noPricingRows_inactive() {
-        when(productBundleItemMapper.selectList(any())).thenReturn(Collections.emptyList());
-        BundleDiscountResult r = service.compute(Collections.singletonList(
-                new LineItem(10L, null, 2, 69900)));
-        assertFalse(r.isActive());
-    }
-
-    @Test
-    void assembleDefsAndDelegate_hit() {
-        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
-                Collections.singletonList(row(3L, 10L, null, 2)));
-        when(productSkuService.getById(3L)).thenReturn(sku(3L, "套餐一", 128000));
-
-        BundleDiscountResult r = service.compute(Collections.singletonList(
-                new LineItem(10L, null, 2, 69900)));
-
-        assertTrue(r.isActive());
-        assertEquals(11800, r.getDiscount());
-        assertEquals("套餐一", r.getBundleList().get(0).getBundleName());
-        assertEquals(1, r.getBundleList().get(0).getTimes());
-    }
-
-    @Test
-    void anchorSkuMissing_skipDef() {
-        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
-                Collections.singletonList(row(3L, 10L, null, 2)));
-        when(productSkuService.getById(3L)).thenReturn(null);
-
-        BundleDiscountResult r = service.compute(Collections.singletonList(
-                new LineItem(10L, null, 2, 69900)));
-
-        assertFalse(r.isActive());
-    }
-
-    @Test
-    void multipleChildrenSameDef_merged() {
-        // 同一套餐锚点两行子商品:childA(10,qty2) + childB(20,qty1)
-        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(Arrays.asList(
-                row(3L, 10L, null, 2),
-                row(3L, 20L, null, 1)));
-        when(productSkuService.getById(3L)).thenReturn(sku(3L, "套餐一", 128000));
-
-        BundleDiscountResult r = service.compute(Arrays.asList(
-                new LineItem(10L, null, 2, 69900),
-                new LineItem(20L, null, 1, 50000)));
-
-        assertTrue(r.isActive());
-        assertEquals(1, r.getBundleList().size());
-        // 原价 = 2×69900 + 1×50000 = 189800;省 = 189800 - 128000 = 61800
-        assertEquals(61800, r.getDiscount());
-    }
-
-    @Test
-    void labelFallbackToSpecs() {
-        when(productBundleItemMapper.selectList(any(Wrapper.class))).thenReturn(
-                Collections.singletonList(row(3L, 10L, null, 2)));
-        ProductSku s = new ProductSku();
-        s.setId(3L);
-        s.setLabel(null);
-        s.setSpecs("成长烦恼、学业提升");
-        s.setPrice(128000);
-        when(productSkuService.getById(3L)).thenReturn(s);
-
-        BundleDiscountResult r = service.compute(Collections.singletonList(
-                new LineItem(10L, null, 2, 69900)));
-
-        assertEquals("成长烦恼、学业提升", r.getBundleList().get(0).getBundleName());
-    }
-}

+ 205 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CartReplaceComboTest.java

@@ -0,0 +1,205 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Cart;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.CartMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import com.etotem.cfc.service.CartService;
+import com.etotem.cfc.service.ComboOfferService;
+import com.etotem.cfc.service.MemberPriceResolver;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductSkuService;
+import com.etotem.cfc.service.SysConfigService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CartService.computeComboOffer / replaceCartWithCombo 服务层单测(mock mapper,Review Focus 4/5)。
+ * 勾选态是前端本地态(CartItemDTO.id=购物车主键),后端按勾选子集重算。
+ */
+@ExtendWith(MockitoExtension.class)
+class CartReplaceComboTest {
+
+    @Mock private CartMapper cartMapper;
+    @Mock private ProductMapper productMapper;
+    @Mock private ProductSkuService productSkuService;
+    @Mock private ProductGiftRuleService productGiftRuleService;
+    @Mock private SysConfigService sysConfigService;
+    @Mock private MemberPriceResolver memberPriceResolver;
+    @Mock private ComboOfferService comboOfferService;
+
+    @InjectMocks private CartService cartService;
+
+    private Product product(Long id, String name, int price) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        p.setPrice(price);
+        p.setProductType("physical");
+        return p;
+    }
+
+    private ProductSku sku(Long id, Long productId, int price, Long linkedProductId) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setProductId(productId);
+        s.setPrice(price);
+        s.setLinkedProductId(linkedProductId);
+        return s;
+    }
+
+    private Cart cart(Long id, Long productId, Long skuId, Integer qty) {
+        Cart c = new Cart();
+        c.setId(id);
+        c.setUserId(100L);
+        c.setProductId(productId);
+        c.setSkuId(skuId);
+        c.setQuantity(qty);
+        return c;
+    }
+
+    private ComboOfferDef defAB() {
+        ComboOfferDef d = new ComboOfferDef();
+        d.setComboSkuId(900L);
+        d.setComboName("套餐X");
+        d.setComboPrice(15000);
+        ComboChild a = new ComboChild();
+        a.setChildProductId(1L);
+        a.setChildSkuId(11L);
+        a.setQuantity(2);
+        ComboChild b = new ComboChild();
+        b.setChildProductId(2L);
+        b.setChildSkuId(22L);
+        b.setQuantity(1);
+        d.setChildren(Arrays.asList(a, b));
+        return d;
+    }
+
+    /** 仅按勾选记录计算(未勾选行不参与),saving>0 才 active */
+    @Test
+    @SuppressWarnings("unchecked")
+    void computeComboOffer_onlyCheckedRowsInvolved() {
+        Cart ca = cart(1L, 1L, 11L, 2);
+        Cart cb = cart(2L, 2L, 22L, 1);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(productMapper.selectById(1L)).thenReturn(product(1L, "A", 5000));
+        when(productMapper.selectById(2L)).thenReturn(product(2L, "B", 6000));
+        when(productSkuService.getById(11L)).thenReturn(sku(11L, 1L, 5000, null));
+        when(productSkuService.getById(22L)).thenReturn(sku(22L, 2L, 6000, null));
+        when(memberPriceResolver.resolveEligibleMemberPrice(any(), any())).thenReturn(0);
+        ComboOfferResult stub = new ComboOfferResult();
+        stub.setActive(true);
+        when(comboOfferService.computeForLines(anyList())).thenReturn(stub);
+
+        ComboOfferResult r = cartService.computeComboOffer(100L, Arrays.asList(1L, 2L));
+
+        assertTrue(r.isActive());
+        ArgumentCaptor<List<LineItem>> captor = ArgumentCaptor.forClass((Class) List.class);
+        verify(comboOfferService).computeForLines(captor.capture());
+        assertEquals(2, captor.getValue().size());   // 仅勾选 2 行参与
+        LineItem l0 = captor.getValue().get(0);
+        assertEquals(1L, l0.getProductId());
+        assertEquals(11L, l0.getSkuId());
+        assertEquals(2, l0.getQuantity());
+        assertEquals(5000, l0.getUnitPrice());
+    }
+
+    /** 后端重算组数(忽略前端传入):A×4+B×2 传 99 份 → 只替换 2 组 */
+    @Test
+    void replaceCartWithCombo_recomputesGroupsIgnoringFrontend() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(null);   // 无既有套餐行
+
+        Result<Void> res = cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        assertNotNull(res);
+        // 子商品行扣 2 组:A 扣4→0 删、B 扣2→0 删
+        verify(cartMapper).deleteById(1L);
+        verify(cartMapper).deleteById(2L);
+        // 新增套餐 SKU 行 quantity=2(后端重算,非前端 99)
+        ArgumentCaptor<Cart> captor = ArgumentCaptor.forClass(Cart.class);
+        verify(cartMapper).insert(captor.capture());
+        assertEquals(99L, captor.getValue().getProductId());
+        assertEquals(900L, captor.getValue().getSkuId());
+        assertEquals(2, captor.getValue().getQuantity());
+    }
+
+    /** 余量保留:A×5+B×2 → A 余 1、B 余 0、套餐行 2 */
+    @Test
+    void replaceCartWithCombo_keepsRemainder() {
+        Cart ca = cart(1L, 1L, 11L, 5);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(null);
+
+        cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        verify(cartMapper).updateById(ca);            // A 扣 4 → 余 1
+        assertEquals(1, ca.getQuantity());
+        verify(cartMapper).deleteById(2L);            // B 扣 2 → 0 删
+        verify(cartMapper).insert(any());
+    }
+
+    /** 套餐商品已在购物车(同 product+sku)→ 合并数量而非重复套装 */
+    @Test
+    void replaceCartWithCombo_comboAlreadyInCart_mergesQuantity() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        Cart existingCombo = cart(3L, 99L, 900L, 1);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        when(productSkuService.getById(900L)).thenReturn(sku(900L, 99L, 15000, null));
+        when(cartMapper.selectOne(any())).thenReturn(existingCombo);
+
+        cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L);
+
+        verify(cartMapper, never()).insert(any());
+        verify(cartMapper).updateById(existingCombo);
+        assertEquals(3, existingCombo.getQuantity());   // 1 + 2
+    }
+
+    /** 事务性:任一步失败(deleteById 抛异常)→ 传播且未插入套餐行(整体回滚语义) */
+    @Test
+    void replace_anyStepFails_throwsAndNoComboInsert() {
+        Cart ca = cart(1L, 1L, 11L, 4);
+        Cart cb = cart(2L, 2L, 22L, 2);
+        when(cartMapper.selectList(any())).thenReturn(Arrays.asList(ca, cb));
+        when(comboOfferService.loadDef(900L)).thenReturn(defAB());
+        doThrow(new RuntimeException("boom")).when(cartMapper).deleteById(anyLong());
+
+        assertThrows(RuntimeException.class,
+                () -> cartService.replaceCartWithCombo(100L, Arrays.asList(1L, 2L), 900L));
+        verify(cartMapper, never()).insert(any());
+    }
+}

+ 0 - 137
cfc-backend/src/test/java/com/etotem/cfc/unit/CartServiceTest.java

@@ -1,137 +0,0 @@
-package com.etotem.cfc.unit;
-
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.etotem.cfc.dto.CartItemDTO;
-import com.etotem.cfc.dto.CartListResultDTO;
-import com.etotem.cfc.entity.Cart;
-import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.mapper.CartMapper;
-import com.etotem.cfc.mapper.ProductBundleItemMapper;
-import com.etotem.cfc.mapper.ProductMapper;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.BundleHit;
-import com.etotem.cfc.pricing.LineItem;
-import com.etotem.cfc.service.BundlePricingService;
-import com.etotem.cfc.service.CartService;
-import com.etotem.cfc.service.MemberPriceResolver;
-import com.etotem.cfc.service.ProductGiftRuleService;
-import com.etotem.cfc.service.ProductSkuService;
-import com.etotem.cfc.service.SysConfigService;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyList;
-import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.when;
-
-/**
- * CartService.getCartList 单测:bundlePromo 绑定(委托 BundlePricingService)、giftPromo 置空(一口价废弃)。
- */
-@ExtendWith(MockitoExtension.class)
-class CartServiceTest {
-
-    @Mock private CartMapper cartMapper;
-    @Mock private ProductMapper productMapper;
-    @Mock private ProductBundleItemMapper bundleItemMapper;
-    @Mock private ProductSkuService productSkuService;
-    @Mock private ProductGiftRuleService productGiftRuleService;
-    @Mock private SysConfigService sysConfigService;
-    @Mock private BundlePricingService bundlePricingService;
-    @Mock private MemberPriceResolver memberPriceResolver;
-    @InjectMocks private CartService cartService;
-
-    private Cart cart(Long productId, Long skuId, int qty) {
-        Cart c = new Cart();
-        c.setId(1L);
-        c.setUserId(1L);
-        c.setProductId(productId);
-        c.setSkuId(skuId);
-        c.setQuantity(qty);
-        return c;
-    }
-
-    private Product product(Long id, String name, int price) {
-        Product p = new Product();
-        p.setId(id);
-        p.setName(name);
-        p.setPrice(price);
-        p.setProductType("assessment"); // 非 bundle,避免 buildBundleInfo
-        p.setStatus("on_shelf");
-        return p;
-    }
-
-    @Test
-    void getCartList_bindsBundlePromo_andGiftPromoEmpty() {
-        when(cartMapper.selectList(any(Wrapper.class)))
-                .thenReturn(Collections.singletonList(cart(10L, null, 2)));
-        when(productMapper.selectBatchIds(anyList()))
-                .thenReturn(Collections.singletonList(product(10L, "DAN测评", 69900)));
-        // attachBundleMatches:查询 bundle 商品 → 空,直接返回
-        when(productMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), any())).thenReturn(69900);
-        // 模拟定价命中:1 套套餐,省 11800
-        BundleDiscountResult r = new BundleDiscountResult();
-        r.setActive(true);
-        r.setOriginalTotal(139800);
-        r.setPayPrice(128000);
-        r.setDiscount(11800);
-        BundleHit hit = new BundleHit(3L, "套餐一", 1, 139800, 128000, 11800);
-        r.setBundleList(new ArrayList<>(Collections.singletonList(hit)));
-        when(bundlePricingService.compute(anyList())).thenReturn(r);
-
-        CartListResultDTO result = cartService.getCartList(1L);
-
-        assertNotNull(result.getBundlePromo());
-        assertTrue(result.getBundlePromo().getActive());
-        assertEquals(11800, result.getBundlePromo().getDiscount());
-        assertEquals(1, result.getBundlePromo().getBundleList().size());
-        assertEquals("套餐一", result.getBundlePromo().getBundleList().get(0).getBundleName());
-        // 一口价废弃:giftPromo 恒空
-        assertNotNull(result.getGiftPromo());
-        assertFalse(result.getGiftPromo().getActive());
-        assertEquals(0, result.getGiftPromo().getDiscount());
-    }
-
-    @Test
-    void getCartList_emptyCart_bundlePromoInactive() {
-        when(cartMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        CartListResultDTO result = cartService.getCartList(1L);
-
-        assertNotNull(result.getBundlePromo());
-        assertFalse(result.getBundlePromo().getActive());
-        assertEquals(0, result.getBundlePromo().getDiscount());
-        assertFalse(result.getGiftPromo().getActive());
-    }
-
-    @Test
-    void getCartList_noBundleMatch_bundlePromoInactive() {
-        when(cartMapper.selectList(any(Wrapper.class)))
-                .thenReturn(Collections.singletonList(cart(10L, null, 1)));
-        when(productMapper.selectBatchIds(anyList()))
-                .thenReturn(Collections.singletonList(product(10L, "DAN测评", 69900)));
-        when(productMapper.selectList(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), any())).thenReturn(69900);
-        // 定价服务判定不命中
-        when(bundlePricingService.compute(anyList())).thenReturn(BundleDiscountResult.inactive());
-
-        CartListResultDTO result = cartService.getCartList(1L);
-
-        assertNotNull(result.getBundlePromo());
-        assertFalse(result.getBundlePromo().getActive());
-        assertEquals(0, result.getBundlePromo().getDiscount());
-    }
-}

+ 140 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOfferCalculatorTest.java

@@ -0,0 +1,140 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.pricing.ComboChild;
+import com.etotem.cfc.pricing.ComboOfferCalculator;
+import com.etotem.cfc.pricing.ComboOfferDef;
+import com.etotem.cfc.pricing.ComboOfferResult;
+import com.etotem.cfc.pricing.LineItem;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * ComboOfferCalculator 纯计算单测(Review Focus 1/2/3/7)。
+ * lines 用 (productId, skuId, quantity, unitPrice分) 构造。
+ */
+class ComboOfferCalculatorTest {
+
+    private final ComboOfferCalculator calc = new ComboOfferCalculator();
+
+    private static ComboOfferDef def(Long skuId, String name, int price, ComboChild... children) {
+        ComboOfferDef d = new ComboOfferDef();
+        d.setComboSkuId(skuId);
+        d.setComboName(name);
+        d.setComboPrice(price);
+        d.setChildren(Arrays.asList(children));
+        return d;
+    }
+
+    private static ComboChild child(Long pid, Long skuId, int qty) {
+        ComboChild c = new ComboChild();
+        c.setChildProductId(pid);
+        c.setChildSkuId(skuId);
+        c.setQuantity(qty);
+        return c;
+    }
+
+    private static LineItem line(Long pid, Long skuId, int qty, int price) {
+        return new LineItem(pid, skuId, qty, price);
+    }
+
+    /** 恰好覆盖:A×2+B×1(A=5000,B=6000)→ 套餐X(15000) 命中,saving=16000-15000=1000 */
+    @Test
+    void exactCoverage_hitsCombo() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals("套餐X", r.getComboName());
+        assertEquals(1, r.getGroups());
+        assertEquals(16000, r.getOriginalTotal());
+        assertEquals(15000, r.getComboPrice());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 套餐价>=总和:16000 → inactive(原价结算,Review Focus 1) */
+    @Test
+    void comboPriceEqualsTotal_inactive() {
+        ComboOfferDef x = def(900L, "套餐X", 16000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 覆盖不足:A×1+B×1 → inactive(Review Focus 2) */
+    @Test
+    void insufficientCoverage_inactive() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 1, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 超量覆盖:A×4+B×2 → groups=2,originalTotal=32000,套餐 15000×2,saving=32000-30000=2000(Review Focus 3) */
+    @Test
+    void overCoverage_twoGroups() {
+        ComboOfferDef x = def(900L, "套餐X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 4, 5000), line(2L, 22L, 2, 6000)),
+                Collections.singletonList(x));
+        assertTrue(r.isActive());
+        assertEquals(2, r.getGroups());
+        assertEquals(32000, r.getOriginalTotal());
+        assertEquals(15000, r.getComboPrice());   // 每组套餐价
+        assertEquals(2000, r.getSaving());
+    }
+
+    /** 多套餐候选:X省1000 vs Y省200 → 返回 X(节省最大,Review Focus 7) */
+    @Test
+    void multipleCandidates_picksMaxSaving() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferDef yc = def(901L, "Y", 15800, child(2L, 22L, 1), child(3L, 33L, 1));
+        // X: 16000-15000=1000;Y: B×1(6000)+C×1(10000) 原价16000-15800=200 → 选 X
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000), line(3L, 33L, 1, 10000)),
+                Arrays.asList(x, yc));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 两套餐同额 → 返回先出现者(Review Focus 7) */
+    @Test
+    void equalSaving_keepsFirst() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferDef y = def(901L, "Y", 15000, child(1L, 11L, 2), child(2L, 22L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Arrays.asList(x, y));
+        assertTrue(r.isActive());
+        assertEquals(900L, r.getComboSkuId());
+        assertEquals(1000, r.getSaving());
+    }
+
+    /** 脏行防御:child 的 childSkuId 不在 lines → 不命中该套餐 */
+    @Test
+    void dirtyChildSkuId_notInLines_inactive() {
+        ComboOfferDef x = def(900L, "X", 15000, child(1L, 11L, 2), child(2L, 999L, 1));
+        ComboOfferResult r = calc.compute(
+                Arrays.asList(line(1L, 11L, 2, 5000), line(2L, 22L, 1, 6000)),
+                Collections.singletonList(x));
+        assertFalse(r.isActive());
+    }
+
+    /** 空 lines / 空 defs → inactive */
+    @Test
+    void emptyInputs_inactive() {
+        assertFalse(calc.compute(null, Collections.singletonList(def(900L, "X", 15000))).isActive());
+        assertFalse(calc.compute(Collections.singletonList(line(1L, 11L, 2, 5000)), null).isActive());
+        assertFalse(calc.compute(Collections.emptyList(), Collections.emptyList()).isActive());
+    }
+}

+ 149 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ComboOrderStockTest.java

@@ -0,0 +1,149 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CreateProductOrderDTO;
+import com.etotem.cfc.dto.OrderItemVO;
+import com.etotem.cfc.dto.ProductOrderDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
+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.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 套餐商品(product_type='bundle')下单库存走套餐SKU:校验/扣减/回补均只作用于套餐SKU,
+ * 不触碰商品级库存(product.stock)与子商品SKU库存(无任何子商品扣减代码)。
+ */
+class ComboOrderStockTest {
+
+    private ProductOrderService orderService;
+    private ProductOrderMapper orderMapper;
+    private ProductMapper productMapper;
+    private ProductSkuService productSkuService;
+    private ProductService productService;
+    private InventoryService inventoryService;
+
+    @BeforeEach
+    void setUp() {
+        orderService = new ProductOrderService();
+        orderMapper = mock(ProductOrderMapper.class);
+        productMapper = mock(ProductMapper.class);
+        productSkuService = mock(ProductSkuService.class);
+        productService = mock(ProductService.class);
+        inventoryService = mock(InventoryService.class);
+        ReflectionTestUtils.setField(orderService, "orderMapper", orderMapper);
+        ReflectionTestUtils.setField(orderService, "productMapper", productMapper);
+        ReflectionTestUtils.setField(orderService, "productSkuService", productSkuService);
+        ReflectionTestUtils.setField(orderService, "productService", productService);
+        ReflectionTestUtils.setField(orderService, "inventoryService", inventoryService);
+    }
+
+    private Product bundleProduct(Long id, String name) {
+        Product p = new Product();
+        p.setId(id);
+        p.setName(name);
+        p.setProductType("bundle");
+        p.setStatus("on_shelf");
+        return p;
+    }
+
+    private ProductSku combo(Long id, Long productId, int price) {
+        ProductSku s = new ProductSku();
+        s.setId(id);
+        s.setProductId(productId);
+        s.setPrice(price);
+        return s;
+    }
+
+    private ProductOrder bundleOrder(String orderNo) {
+        ProductOrder o = new ProductOrder();
+        o.setId(1L);
+        o.setOrderNo(orderNo);
+        o.setProductId(99L);
+        o.setProductType("bundle");
+        o.setSkuId(900L);
+        o.setQuantity(2);
+        o.setStatus("pending");
+        o.setBuyerId(100L);
+        o.setTotalAmount(30000);
+        o.setMoneyAmount(30000);
+        o.setPointsUsed(0);
+        return o;
+    }
+
+    /** 断言1:下单套餐商品 → 库存校验走套餐SKU(product_skus.stock),不足则拒绝 */
+    @Test
+    void createBundle_invalidatesComboSkuStock() {
+        OrderItemVO item = new OrderItemVO();
+        item.setProductId(99L);
+        item.setSkuId(900L);
+        item.setQuantity(2);
+        CreateProductOrderDTO dto = new CreateProductOrderDTO();
+        dto.setItems(Collections.singletonList(item));
+        when(productMapper.selectById(99L)).thenReturn(bundleProduct(99L, "套餐X"));
+        when(productSkuService.getById(900L)).thenReturn(combo(900L, 99L, 15000));
+        when(productSkuService.hasStock(900L, 2)).thenReturn(false);
+
+        Result<ProductOrderDTO> res = orderService.create(dto, 100L);
+
+        assertNotNull(res);
+        assertNotEquals(200, res.getCode());
+        assertTrue(res.getMessage() != null && res.getMessage().contains("套餐库存不足"));
+        verify(productSkuService).hasStock(900L, 2);   // 走套餐SKU库存校验
+    }
+
+    /** 断言2:支付成功只扣套餐SKU库存(不扣商品级库存、无子商品扣减) */
+    @Test
+    void paymentSuccess_deductsComboSkuStockOnly() {
+        ProductOrder order = bundleOrder("B20261101");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+        when(productMapper.selectById(99L)).thenReturn(bundleProduct(99L, "套餐X"));
+        when(productSkuService.getById(900L)).thenReturn(combo(900L, 99L, 15000));
+        when(productSkuService.decreaseStock(900L, 2)).thenReturn(true);
+
+        Result<String> res = (Result<String>) ReflectionTestUtils.invokeMethod(
+                orderService, "doHandlePaymentSuccess", "B20261101", "TX123");
+
+        assertNotNull(res);
+        assertNotEquals(-1, res.getCode() == null ? -1 : res.getCode());
+        // 只扣套餐SKU一次,商品级库存不扣,子商品SKU不被触碰
+        verify(productSkuService, times(1)).decreaseStock(900L, 2);
+        verify(productService, never()).decreaseStock(anyLong(), any());
+        // 出库流水记到套餐SKU
+        verify(inventoryService).recordOutbound(99L, 900L, 2, "order", 1L, null, "订单支付:B20261101");
+    }
+
+    /** 断言3:取消/关闭回补只加套餐SKU库存 */
+    @Test
+    void adminClose_restoresComboSkuStockOnly() {
+        ProductOrder order = bundleOrder("B20261101");
+        order.setStatus("paid");
+        when(orderMapper.selectOne(any())).thenReturn(order);
+
+        Result<String> res = orderService.adminClose("B20261101", 1L);
+
+        assertNotNull(res);
+        verify(productSkuService).increaseStock(900L, 2);   // 只加套餐SKU库存
+        verify(productService, never()).increaseStock(anyLong(), any());
+    }
+}

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

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

+ 40 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductDTOMappingTest.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.entity.Product;
 import org.junit.jupiter.api.Test;
 
+import java.math.BigDecimal;
+
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -26,4 +28,42 @@ class ProductDTOMappingTest {
         assertEquals(1L, json.getLong("categoryId"));
         assertEquals(3L, json.getLong("distributionSystemId"));
     }
+
+    @Test
+    void from_映射营养数据与营养标签() {
+        Product p = new Product();
+        p.setId(8L);
+        p.setName("高钙奶粉");
+        p.setCaloriesKcal(new BigDecimal("490"));
+        p.setProteinG(new BigDecimal("23.6"));
+        p.setFatG(new BigDecimal("25.1"));
+        p.setCarbG(new BigDecimal("40.8"));
+        p.setFiberG(new BigDecimal("1.2"));
+        p.setNutritionTags("低糖,高钙");
+
+        ProductDTO dto = ProductDTO.from(p);
+
+        assertEquals(new BigDecimal("490"), dto.getCaloriesKcal());
+        assertEquals(new BigDecimal("23.6"), dto.getProteinG());
+        assertEquals(new BigDecimal("25.1"), dto.getFatG());
+        assertEquals(new BigDecimal("40.8"), dto.getCarbG());
+        assertEquals(new BigDecimal("1.2"), dto.getFiberG());
+        assertEquals("低糖,高钙", dto.getNutritionTags());
+    }
+
+    @Test
+    void from_未配置营养数据时字段为null() {
+        Product p = new Product();
+        p.setId(9L);
+        p.setName("还原水机滤芯");
+
+        ProductDTO dto = ProductDTO.from(p);
+
+        assertNull(dto.getCaloriesKcal());
+        assertNull(dto.getProteinG());
+        assertNull(dto.getFatG());
+        assertNull(dto.getCarbG());
+        assertNull(dto.getFiberG());
+        assertNull(dto.getNutritionTags());
+    }
 }

+ 88 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductListNutritionTagFilterTest.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ProductListQueryDTO;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.ProductGiftRuleService;
+import com.etotem.cfc.service.ProductService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 商城商品列表:nutritionTag 参数必须下推到查询条件(LIKE nutrition_tags),
+ * 而不是只加在 DTO 里不生效。
+ */
+class ProductListNutritionTagFilterTest {
+
+    private ProductService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        service = new ProductService();
+        productMapper = mock(ProductMapper.class);
+        set("productMapper", productMapper);
+
+        ProductGiftRuleService giftRuleService = mock(ProductGiftRuleService.class);
+        when(giftRuleService.listActiveByProductIds(any())).thenReturn(Collections.emptyList());
+        set("productGiftRuleService", giftRuleService);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = ProductService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    @Test
+    void list_按营养标签过滤商品() {
+        when(productMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+                .thenReturn(new Page<Product>(1, 10));
+
+        ProductListQueryDTO query = new ProductListQueryDTO();
+        query.setNutritionTag("低糖");
+
+        Result<?> result = service.list(query, null, null);
+        assertEquals(200, result.getCode());
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectPage(any(Page.class), captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("nutrition_tags"), sql);
+    }
+
+    @Test
+    void list_未传营养标签时不加过滤条件() {
+        when(productMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+                .thenReturn(new Page<Product>(1, 10));
+
+        ProductListQueryDTO query = new ProductListQueryDTO();
+
+        assertEquals(200, service.list(query, null, null).getCode());
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectPage(any(Page.class), captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertFalse(sql.contains("nutrition_tags"), sql);
+    }
+}

+ 0 - 140
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductOrderServiceTest.java

@@ -1,140 +0,0 @@
-package com.etotem.cfc.unit;
-
-import com.etotem.cfc.common.Result;
-import com.etotem.cfc.dto.CreateProductOrderDTO;
-import com.etotem.cfc.dto.OrderItemVO;
-import com.etotem.cfc.dto.ProductOrderDTO;
-import com.etotem.cfc.entity.Product;
-import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.OrderItemMapper;
-import com.etotem.cfc.mapper.ProductMapper;
-import com.etotem.cfc.mapper.ProductOrderMapper;
-import com.etotem.cfc.mapper.UserMapper;
-import com.etotem.cfc.pricing.BundleDiscountResult;
-import com.etotem.cfc.pricing.LineItem;
-import com.etotem.cfc.service.BundlePricingService;
-import com.etotem.cfc.service.CouponService;
-import com.etotem.cfc.service.MemberPriceResolver;
-import com.etotem.cfc.service.ProductGiftRuleService;
-import com.etotem.cfc.service.ProductOrderService;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyList;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * ProductOrderService.createMultiItem 单测:服务端权威重算套餐优惠计入 moneyAmount(前端 discountAmount 仅含规则优惠)。
- */
-@ExtendWith(MockitoExtension.class)
-class ProductOrderServiceTest {
-
-    @Mock private ProductMapper productMapper;
-    @Mock private UserMapper userMapper;
-    @Mock private ProductOrderMapper orderMapper;
-    @Mock private OrderItemMapper orderItemMapper;
-    @Mock private MemberPriceResolver memberPriceResolver;
-    @Mock private BundlePricingService bundlePricingService;
-    @Mock private ProductGiftRuleService productGiftRuleService;
-    @Mock private CouponService couponService;
-    @InjectMocks private ProductOrderService orderService;
-
-    private Product product(Long id, String name, int price) {
-        Product p = new Product();
-        p.setId(id);
-        p.setName(name);
-        p.setPrice(price);
-        p.setProductType("assessment");
-        p.setStatus("on_shelf");
-        return p;
-    }
-
-    private CreateProductOrderDTO dto(long productId, int qty, Integer discountAmount) {
-        CreateProductOrderDTO dto = new CreateProductOrderDTO();
-        OrderItemVO item = new OrderItemVO();
-        item.setProductId(productId);
-        item.setQuantity(qty);
-        dto.setItems(Collections.singletonList(item));
-        dto.setDiscountAmount(discountAmount);
-        return dto;
-    }
-
-    @Test
-    void createMultiItem_addsServerBundleDiscountToMoneyAmount() {
-        // 2×69900 配套餐省11800 → moneyAmount = 139800 - 0 - 11800 = 128000
-        when(productMapper.selectById(10L)).thenReturn(product(10L, "DAN测评", 69900));
-        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), anyLong())).thenReturn(69900);
-        BundleDiscountResult r = new BundleDiscountResult();
-        r.setActive(true);
-        r.setOriginalTotal(139800);
-        r.setPayPrice(128000);
-        r.setDiscount(11800);
-        r.setBundleList(new ArrayList<>());
-        when(bundlePricingService.compute(anyList())).thenReturn(r);
-        when(userMapper.selectById(anyLong())).thenReturn(new User());
-        when(orderMapper.insert(any())).thenReturn(1);
-        when(orderItemMapper.insert(any())).thenReturn(1);
-
-        Result<ProductOrderDTO> result = orderService.create(dto(10L, 2, null), 1L);
-
-        assertTrue(result.getCode() == 200, "下单应成功: " + result.getMessage());
-        assertEquals(128000, result.getData().getMoneyAmount());
-    }
-
-    @Test
-    void createMultiItem_bundleLinesCollectResolvedIdentityAndPrice() {
-        // 收集行项:解析后 productId、skuId=null、quantity、unitPrice=69900
-        when(productMapper.selectById(10L)).thenReturn(product(10L, "DAN测评", 69900));
-        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), anyLong())).thenReturn(69900);
-        when(bundlePricingService.compute(anyList())).thenReturn(BundleDiscountResult.inactive());
-        when(userMapper.selectById(anyLong())).thenReturn(new User());
-        when(orderMapper.insert(any())).thenReturn(1);
-        when(orderItemMapper.insert(any())).thenReturn(1);
-
-        orderService.create(dto(10L, 2, null), 1L);
-
-        ArgumentCaptor<List<LineItem>> captor = ArgumentCaptor.forClass(List.class);
-        verify(bundlePricingService).compute(captor.capture());
-        List<LineItem> lines = captor.getValue();
-        assertEquals(1, lines.size());
-        assertEquals(10L, lines.get(0).getProductId());
-        assertEquals(null, lines.get(0).getSkuId());
-        assertEquals(2, lines.get(0).getQuantity());
-        assertEquals(69900, lines.get(0).getUnitPrice());
-    }
-
-    @Test
-    void createMultiItem_frontendRuleDiscountNotMixedWithBundle() {
-        // 前端传入规则优惠5000(不含套餐)+ 套餐11800 → moneyAmount = 139800 - 5000 - 11800 = 123000
-        when(productMapper.selectById(10L)).thenReturn(product(10L, "DAN测评", 69900));
-        when(memberPriceResolver.resolveEligibleMemberPrice(any(Product.class), anyLong())).thenReturn(69900);
-        BundleDiscountResult r = new BundleDiscountResult();
-        r.setActive(true);
-        r.setOriginalTotal(139800);
-        r.setPayPrice(128000);
-        r.setDiscount(11800);
-        r.setBundleList(new ArrayList<>());
-        when(bundlePricingService.compute(anyList())).thenReturn(r);
-        when(userMapper.selectById(anyLong())).thenReturn(new User());
-        when(orderMapper.insert(any())).thenReturn(1);
-        when(orderItemMapper.insert(any())).thenReturn(1);
-
-        Result<ProductOrderDTO> result = orderService.create(dto(10L, 2, 5000), 1L);
-
-        assertTrue(result.getCode() == 200);
-        assertEquals(123000, result.getData().getMoneyAmount());
-    }
-}

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

@@ -1,117 +0,0 @@
-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)));
-    }
-}

+ 89 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ProductUpdateClearTest.java

@@ -13,6 +13,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import java.lang.reflect.Field;
+import java.math.BigDecimal;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -96,4 +97,92 @@ class ProductUpdateClearTest {
 
         verify(productMapper, never()).update(isNull(), any(Wrapper.class));
     }
+
+    /**
+     * 营养字段为可空输入:管理端清空输入框提交 null 时,必须真正写入 NULL,
+     * 否则 updateById 忽略 null 会残留旧值。
+     */
+    @Test
+    void adminUpdate_营养字段清空时显式置NULL() {
+        Product existing = existing();
+        existing.setCaloriesKcal(new BigDecimal("320"));
+        existing.setProteinG(new BigDecimal("18.5"));
+        existing.setFatG(new BigDecimal("9"));
+        existing.setCarbG(new BigDecimal("36"));
+        existing.setFiberG(new BigDecimal("4.2"));
+        existing.setNutritionTags("低糖,高纤维");
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+        // 营养字段全部未填写(清空)
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(productMapper).update(isNull(), captor.capture());
+        String sqlSet = ((LambdaUpdateWrapper<Product>) captor.getValue()).getSqlSet();
+        assertTrue(sqlSet.contains("calories_kcal"), sqlSet);
+        assertTrue(sqlSet.contains("protein_g"), sqlSet);
+        assertTrue(sqlSet.contains("fat_g"), sqlSet);
+        assertTrue(sqlSet.contains("carb_g"), sqlSet);
+        assertTrue(sqlSet.contains("fiber_g"), sqlSet);
+        assertTrue(sqlSet.contains("nutrition_tags"), sqlSet);
+    }
+
+    /**
+     * 原记录本来就没有营养数据、且表单也未提交营养字段时,不应触发多余的清空 UPDATE。
+     */
+    @Test
+    void adminUpdate_原记录无营养数据时不额外置NULL() {
+        Product existing = existing();
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        verify(productMapper, never()).update(isNull(), any(Wrapper.class));
+    }
+
+    /**
+     * 营养标签传空串(含纯空格)时归一化为 NULL:库里不留空串,原有标签被真正清空。
+     */
+    @Test
+    void adminUpdate_营养标签传空串时归一化为NULL() {
+        Product existing = existing();
+        existing.setNutritionTags("低糖");
+        when(productMapper.selectById(7L)).thenReturn(existing);
+        when(productMapper.updateById(any(Product.class))).thenReturn(1);
+        when(productMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+
+        Product form = new Product();
+        form.setId(7L);
+        form.setName("还原水机滤芯");
+        form.setCategoryId(1L);
+        form.setDistributionSystemId(3L);
+        form.setNutritionTags("   ");
+
+        assertEquals(200, service.adminUpdate(form).getCode());
+
+        ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
+        verify(productMapper).updateById(productCaptor.capture());
+        assertNull(productCaptor.getValue().getNutritionTags());
+
+        ArgumentCaptor<Wrapper> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(productMapper).update(isNull(), captor.capture());
+        String sqlSet = ((LambdaUpdateWrapper<Product>) captor.getValue()).getSqlSet();
+        assertTrue(sqlSet.contains("nutrition_tags"), sqlSet);
+    }
 }

+ 79 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/RecommendationNutritionTagTest.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.RecommendationQuery;
+import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.service.RecommendationService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 推荐搜索:营养需求标签必须能命中商品已配置的 nutrition_tags(逗号分隔标签),
+ * 而不是只按名称/描述匹配。
+ */
+class RecommendationNutritionTagTest {
+
+    private RecommendationService service;
+    private ProductMapper productMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        MapperBuilderAssistant assistant =
+                new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, Product.class);
+
+        service = new RecommendationService();
+        productMapper = mock(ProductMapper.class);
+        set("productMapper", productMapper);
+    }
+
+    private void set(String name, Object value) throws Exception {
+        Field f = RecommendationService.class.getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(service, value);
+    }
+
+    @Test
+    void search_按营养标签命中商品nutritionTags() {
+        Product p = new Product();
+        p.setId(11L);
+        p.setName("即食燕麦片");
+        p.setNutritionTags("膳食纤维,低GI");
+        p.setStatus("on_shelf");
+        p.setStock(10);
+        when(productMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.singletonList(p));
+
+        RecommendationQuery query = new RecommendationQuery();
+        query.setNutritionTags(Collections.singletonList("膳食纤维"));
+        query.setTypes(Collections.singletonList("product"));
+        query.setLimit(1);
+
+        List<RecommendationResult> results = service.search(query);
+
+        ArgumentCaptor<LambdaQueryWrapper<Product>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(productMapper).selectList(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("nutrition_tags"), sql);
+
+        assertEquals(1, results.size());
+        assertEquals("product", results.get(0).getType());
+        assertEquals(11L, results.get(0).getId());
+        assertEquals("即食燕麦片", results.get(0).getName());
+    }
+}

+ 30 - 44
cfc-frontend/__tests__/utils/bundle-pricing.test.js

@@ -1,58 +1,44 @@
 /**
  * Tests for cfc-frontend utils/bundle-pricing.js
  *
- * 验证套餐优惠的纯函数语义:未命中/未激活 → 0;active=true + discount>0 才生效。
+ * 验证购物车套餐offer展示辅助:comboOfferOf 命中/未激活语义;formatComboOfferText 提示文案;
+ * 总价不再被自动扣减(纯展示,权威计算在后端 POST /api/cart/combo-offer)。
  */
 const {
-  bundleDiscountOf,
-  formatBundleLines,
-  applyBundleDiscount
+  comboOfferOf,
+  formatComboOfferText
 } = require('@/utils/bundle-pricing')
 
-describe('bundleDiscountOf', () => {
-  it('null/undefined → 0', () => {
-    expect(bundleDiscountOf(null)).toBe(0)
-    expect(bundleDiscountOf(undefined)).toBe(0)
+describe('comboOfferOf', () => {
+  it('null/undefined → null', () => {
+    expect(comboOfferOf(null)).toBeNull()
+    expect(comboOfferOf(undefined)).toBeNull()
   })
-  it('active 非 true → 0', () => {
-    expect(bundleDiscountOf({ active: false, discount: 11800 })).toBe(0)
-    expect(bundleDiscountOf({ active: true, discount: 11800 })).not.toBe(0)
-    expect(bundleDiscountOf({})).toBe(0)
+  it('active 非 true → null', () => {
+    expect(comboOfferOf({ active: false, comboName: '套餐X' })).toBeNull()
+    expect(comboOfferOf({})).toBeNull()
   })
-  it('active=true + 正折扣 → 折扣值', () => {
-    expect(bundleDiscountOf({ active: true, discount: 11800 })).toBe(11800)
-  })
-  it('active=true + 非正折扣 → 0', () => {
-    expect(bundleDiscountOf({ active: true, discount: 0 })).toBe(0)
-    expect(bundleDiscountOf({ active: true, discount: -1 })).toBe(0)
+  it('active=true → 返回 offer(纯展示,不篡改总价)', () => {
+    const offer = { active: true, comboName: '套餐X', comboPrice: 15000, saving: 2000, groups: 1 }
+    expect(comboOfferOf(offer)).toBe(offer)
   })
 })
 
-describe('formatBundleLines', () => {
-  it('null/空数组 → []', () => {
-    expect(formatBundleLines(null)).toEqual([])
-    expect(formatBundleLines([])).toEqual([])
-  })
-  it('过滤无 bundleName 的行', () => {
-    const rows = formatBundleLines([
-      { bundleName: '套餐一', times: 1, saving: 11800 },
-      { times: 2 }
-    ])
-    expect(rows).toHaveLength(1)
-    expect(rows[0].name).toBe('套餐一')
-  })
-  it('补齐默认 times/saving', () => {
-    const rows = formatBundleLines([{ bundleName: '套餐一' }])
-    expect(rows[0].times).toBe(1)
-    expect(rows[0].saving).toBe(0)
-  })
-})
-
-describe('applyBundleDiscount', () => {
-  it('未命中 → 原总价', () => {
-    expect(applyBundleDiscount(139800, null)).toBe(139800)
-  })
-  it('命中 → 扣减套餐优惠', () => {
-    expect(applyBundleDiscount(139800, { active: true, discount: 11800 })).toBe(128000)
+describe('formatComboOfferText', () => {
+  it('无 offer → 空串', () => {
+    expect(formatComboOfferText(null)).toBe('')
+    expect(formatComboOfferText({ active: false })).toBe('')
+  })
+  it('有 offer → 「这N件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)」', () => {
+    const txt = formatComboOfferText({ active: true, comboName: '豪华套餐', comboPrice: 15000, saving: 2000 }, 3)
+    expect(txt).toContain('3 件')
+    expect(txt).toContain('豪华套餐')
+    expect(txt).toContain('150.00')
+    expect(txt).toContain('20.00')
+  })
+  it('有 offer 无 checkedCount → 用 groups 兜底;saving=0 不显示省', () => {
+    const txt = formatComboOfferText({ active: true, comboName: '豪华套餐', comboPrice: 15000, saving: 0, groups: 2 })
+    expect(txt).toContain('2 件')
+    expect(txt).not.toContain('省')
   })
 })

+ 0 - 6
cfc-frontend/pages.json

@@ -759,12 +759,6 @@
           "style": {
             "navigationBarTitleText": "我的团队"
           }
-        },
-        {
-          "path": "team-service/team-service",
-          "style": {
-            "navigationBarTitleText": "服务任务"
-          }
         }
       ]
     },

+ 92 - 3
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -125,6 +125,20 @@
         </view>
       </view>
 
+      <!-- 营养成分(未配置任何营养数据/标签时整块隐藏) -->
+      <view v-if="hasNutritionInfo" class="nutrition-section">
+        <text class="section-title">营养成分(每100g)</text>
+        <view class="nutrition-list">
+          <view class="nutrition-item" v-for="(item, idx) in nutritionList" :key="idx">
+            <text class="nutrition-label">{{ item.label }}</text>
+            <text class="nutrition-value">{{ item.value }}</text>
+          </view>
+        </view>
+        <view v-if="nutritionTagList.length > 0" class="nutrition-tags">
+          <text class="nutrition-tag" v-for="(tag, ti) in nutritionTagList" :key="ti">{{ tag }}</text>
+        </view>
+      </view>
+
       <view class="intro-section" v-if="product.intro">
         <text class="section-title">商品介绍</text>
         <mp-html :content="product.intro" :domain="apiBaseUrl" :tag-style="contentTagStyle" />
@@ -135,9 +149,9 @@
         <mp-html :content="product.description" :domain="apiBaseUrl" :tag-style="contentTagStyle" />
       </view>
 
-      <!-- 套餐包含(bundle商品展示子商品组成) -->
+      <!-- 套餐内含清单(bundle商品展示子商品SKU×数量) -->
       <view v-if="isBundleProduct && bundleItems.length > 0" class="bundle-section">
-        <text class="section-title">套餐包含</text>
+        <text class="section-title">内含清单</text>
         <view class="bundle-item" v-for="(bi, idx) in bundleItems" :key="getBundleKey(bi, idx)">
           <image v-if="bi.coverImage" class="bundle-item-img" :src="getImageUrl(bi.coverImage)" mode="aspectFill" />
           <view class="bundle-item-info">
@@ -371,6 +385,40 @@ export default {
         }
       }
       return list
+    },
+    // 营养成分列表(每100g),未配置的项不展示
+    nutritionList: function() {
+      var p = this.product || {}
+      var fields = [
+        { label: '热量', value: p.caloriesKcal, unit: 'kcal' },
+        { label: '蛋白质', value: p.proteinG, unit: 'g' },
+        { label: '脂肪', value: p.fatG, unit: 'g' },
+        { label: '碳水化合物', value: p.carbG, unit: 'g' },
+        { label: '膳食纤维', value: p.fiberG, unit: 'g' }
+      ]
+      var list = []
+      for (var i = 0; i < fields.length; i++) {
+        var f = fields[i]
+        if (f.value === null || f.value === undefined || f.value === '') continue
+        list.push({ label: f.label, value: f.value + ' ' + f.unit })
+      }
+      return list
+    },
+    // 营养标签(逗号分隔)
+    nutritionTagList: function() {
+      var tags = this.product && this.product.nutritionTags
+      if (!tags) return []
+      var arr = String(tags).split(',')
+      var list = []
+      for (var i = 0; i < arr.length; i++) {
+        var t = arr[i].trim()
+        if (t) list.push(t)
+      }
+      return list
+    },
+    // 营养信息区是否展示(无任何营养数据与标签时隐藏)
+    hasNutritionInfo: function() {
+      return this.nutritionList.length > 0 || this.nutritionTagList.length > 0
     }
   },
   onLoad(options) {
@@ -513,7 +561,7 @@ export default {
         that.loading = false
         if (res.code === 200 && res.data) {
           that.product = res.data
-          that.bundleItems = (res.data && res.data.bundleItems) || []
+          that.bundleItems = (res.data && res.data.comboItems) || []
           // 计算可得CF值:floor(原价/100) × pointsMultiplier
           if (res.data.price && res.data.pointsMultiplier) {
             that.cfEarnedValue = Math.floor(res.data.price / 100) * res.data.pointsMultiplier;
@@ -1034,6 +1082,47 @@ export default {
 .bundle-item-name { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 .bundle-item-spec { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-muted; margin-top: 4rpx; }
 .bundle-item-qty { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: bold; color: $brand-primary; margin-left: 16rpx; flex-shrink: 0; }
+/* 营养成分 */
+.nutrition-section {
+  background: $app-surface;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+}
+.nutrition-list {
+  display: flex;
+  flex-wrap: wrap;
+}
+.nutrition-item {
+  width: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid $facade-fill-2;
+}
+.nutrition-label {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+.nutrition-value {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-strong;
+  font-weight: bold;
+}
+.nutrition-tags {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 20rpx;
+}
+.nutrition-tag {
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  background: $facade-fill-cool;
+  border-radius: 100rpx;
+  padding: 6rpx 20rpx;
+  margin-right: 12rpx;
+  margin-bottom: 12rpx;
+}
 /* SKU 选择器 */
 .sku-section {
   background: $app-surface;

+ 86 - 25
cfc-frontend/pages/shop/cart/cart.vue

@@ -107,27 +107,18 @@
       <view v-else class="gift-promo-reached">已达标,支付后自动赠送会员</view>
     </view>
 
-    <!-- 订单套餐自动定价优惠提示(后端权威下发,命中才显示) -->
-    <view v-if="bundlePromo && bundlePromo.active" class="bundle-promo-section">
-      <view class="bundle-promo-header">
-        <text class="bundle-promo-icon">📦</text>
-        <text class="bundle-promo-title">订单套餐自动匹配,已享套餐价</text>
-      </view>
-      <view v-for="(b, idx) in (bundlePromo.bundleList || [])" :key="idx" class="bundle-promo-row">
-        <text class="bundle-promo-name">{{ b.bundleName }} × {{ b.times }}</text>
-        <text class="bundle-promo-save">省 {{ formatPriceWithSymbol(b.saving) }}</text>
-      </view>
-      <view class="bundle-promo-total">
-        <text class="bundle-promo-total-label">套餐优惠合计</text>
-        <text class="bundle-promo-discount">-{{ formatPriceWithSymbol(bundlePromo.discount) }}</text>
-      </view>
-    </view>
-
     <view v-else-if="giftIsGranted" class="gift-granted-hint">
       <text class="gift-granted-icon">✅</text>
       <text class="gift-granted-text">本家庭已赠送会员,请前往会员中心查看</text>
     </view>
 
+    <!-- 购物车套餐offer(勾选子集,后端权威下发,纯展示不扣款) -->
+    <view v-if="comboOffer && comboOffer.active" class="combo-offer-section">
+      <view class="combo-offer-title">{{ comboOfferText }}</view>
+      <view class="combo-offer-price">套餐价 {{ formatPriceWithSymbol(comboOffer.comboPrice) }} · 省 {{ formatPriceWithSymbol(comboOffer.saving) }}</view>
+      <button class="combo-replace-btn" @click="replaceWithCombo">一键替换为套餐</button>
+    </view>
+
     <view class="bottom-bar" v-if="items.length > 0">
       <view class="bottom-left">
         <text class="selected-count">已选 {{ selectedCount }} 件</text>
@@ -157,8 +148,8 @@
 
 <script>
 import config from '@/config.js'
-import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
-import { applyBundleDiscount } from '@/utils/bundle-pricing.js'
+import { getMyMembership, getGiftMembershipProgress, queryComboOffer, replaceCartWithCombo } from '@/utils/api.js'
+import { formatComboOfferText } from '@/utils/bundle-pricing.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -173,7 +164,8 @@ export default {
       isMember: false,
       giftProgress: null,  // 赠会员累计进度
       giftPromo: null,     // 送会员一口价促销信息(已废弃,后端恒返回非激活)
-      bundlePromo: null    // 订单套餐自动定价优惠(后端权威下发)
+      comboOffer: null,    // 勾选子集套餐offer(后端权威下发,纯展示提示)
+      offerTimer: null     // 勾选变化防抖定时器
     }
   },
   computed: {
@@ -198,8 +190,7 @@ export default {
           total = total + (this.items[i].subtotal || this.items[i].unitPrice * this.items[i].quantity)
         }
       }
-      // 套餐自动定价优惠(后端权威),命中则扣减
-      return applyBundleDiscount(total, this.bundlePromo)
+      return total
     },
     originalTotalAmount() {
       var total = 0
@@ -211,6 +202,13 @@ export default {
       }
       return total
     },
+    comboOfferText() {
+      var count = 0
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked) count++
+      }
+      return formatComboOfferText(this.comboOffer, count)
+    },
     estimatedTotalAmount() {
       var total = 0
       for (var i = 0; i < this.items.length; i++) {
@@ -223,8 +221,7 @@ export default {
           total = total + (price * this.items[i].quantity)
         }
       }
-      // 套餐自动定价优惠(后端权威),命中则扣减
-      return applyBundleDiscount(total, this.bundlePromo)
+      return total
     },
     hasGiftItems() {
       for (var i = 0; i < this.items.length; i++) {
@@ -297,6 +294,44 @@ export default {
         }
       }).catch(function() {})
     },
+    scheduleOfferCheck() {
+      if (this.offerTimer) clearTimeout(this.offerTimer)
+      this.offerTimer = setTimeout(() => { this.queryOffer() }, 400)
+    },
+    queryOffer() {
+      var that = this
+      var ids = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].id) ids.push(this.items[i].id)
+      }
+      if (!ids.length) {
+        that.comboOffer = null
+        return
+      }
+      queryComboOffer({ checkedCartItemIds: ids }).then(function(res) {
+        if (res && res.code === 200 && res.data && res.data.active) {
+          that.comboOffer = res.data
+        } else {
+          that.comboOffer = null
+        }
+      }).catch(function() {})
+    },
+    replaceWithCombo() {
+      var that = this
+      var ids = []
+      for (var i = 0; i < this.items.length; i++) {
+        if (this.items[i].checked && this.items[i].id) ids.push(this.items[i].id)
+      }
+      if (!ids.length || !this.comboOffer) return
+      replaceCartWithCombo({ checkedCartItemIds: ids, comboSkuId: this.comboOffer.comboSkuId }).then(function(res) {
+        if (res && res.code === 200) {
+          uni.showToast({ title: '已替换为套餐', icon: 'success' })
+          that.loadCart()
+        } else {
+          uni.showToast({ title: (res && res.message) || '替换失败', icon: 'none' })
+        }
+      }).catch(function() {})
+    },
     goUpgrade() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
@@ -323,8 +358,8 @@ export default {
               that.items[i].subtotal = that.items[i].unitPrice * that.items[i].quantity
             }
             that.giftPromo = result.giftPromo || null
-            that.bundlePromo = result.bundlePromo || null
             that.loadGiftProgress()
+            that.scheduleOfferCheck()
           }
         },
         fail: function() {
@@ -343,6 +378,7 @@ export default {
       for (var i = 0; i < this.items.length; i++) {
         this.items[i].checked = newState
       }
+      this.scheduleOfferCheck()
     },
     toggleItem(productId) {
       for (var i = 0; i < this.items.length; i++) {
@@ -351,6 +387,7 @@ export default {
           break
         }
       }
+      this.scheduleOfferCheck()
     },
     increaseQty(item) {
       item.quantity = item.quantity + 1
@@ -432,7 +469,6 @@ export default {
       }
       uni.setStorageSync('checkoutItems', selected)
       uni.setStorageSync('checkoutGiftPromo', this.giftPromo)
-      uni.setStorageSync('checkoutBundlePromo', this.bundlePromo)
       uni.navigateTo({ url: '/pages/shop/checkout/checkout' + (this.taskId ? '?taskId=' + this.taskId : '') })
     },
     formatPriceWithSymbol(price) {
@@ -836,4 +872,29 @@ export default {
   font-size: calc(26rpx * var(--font-scale, 1));
   color: #15803D;
 }
+.combo-offer-section {
+  margin: 20rpx $uni-space-page;
+  padding: 20rpx;
+  background: #FFF7E6;
+  border-radius: 16rpx;
+}
+.combo-offer-title {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $status-warning-strong;
+  line-height: 1.5;
+}
+.combo-offer-price {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $status-warning-deep;
+  margin-top: 8rpx;
+}
+.combo-replace-btn {
+  margin-top: 16rpx;
+  padding: 14rpx 24rpx;
+  background: $status-warning-deep;
+  color: #FFFFFF;
+  border-radius: 40rpx;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  text-align: center;
+}
 </style>

+ 0 - 27
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -238,24 +238,6 @@
         </view>
       </view>
 
-      <!-- 订单套餐自动定价优惠(后端权威下发,命中才显示) -->
-      <view v-if="bundlePromo && bundlePromo.active" class="section bundle-promo-section">
-        <view class="bundle-promo-row">
-          <text class="bundle-promo-icon">📦</text>
-          <view class="bundle-promo-content">
-            <text class="bundle-promo-title">订单套餐自动匹配,已享套餐价</text>
-            <view v-for="(b, idx) in (bundlePromo.bundleList || [])" :key="idx" class="bundle-promo-line">
-              <text class="bundle-promo-name">{{ b.bundleName }} × {{ b.times }}</text>
-              <text class="bundle-promo-save">省 {{ formatPriceWithSymbol(b.saving) }}</text>
-            </view>
-            <view class="bundle-promo-total">
-              <text class="bundle-promo-total-label">套餐优惠合计</text>
-              <text class="bundle-promo-discount">-{{ formatPriceWithSymbol(bundlePromoDiscount) }}</text>
-            </view>
-          </view>
-        </view>
-      </view>
-
       <!-- Coupon Section -->
       <view v-if="!isSpecialCouponDisabled" class="section coupon-section" @click="openCouponPicker">
         <view class="info-row">
@@ -504,7 +486,6 @@ import { BrandColors } from '@/utils/brand-colors.js'
 import config from '@/config.js'
 import { getCouponList, getCouponCheckoutList, addressList, getProductRequiredFields, getMyMembership, getGiftMembershipProgress, productDetail, getFamilyPlatformBalance, exchangeCouponByCf, shopDiscountCalculate, getSpecialPriceInfo, applyCoupon } from '../../../utils/api.js'
 import { parseDate } from '@/utils/format.js'
-import { bundleDiscountOf } from '@/utils/bundle-pricing.js'
 import CountdownTag from '@/pages/shop/components/countdown-tag.vue'
 import HandSignature from '@/components/hand-signature/hand-signature.vue'
 import fontScaleMixin from '@/mixins/font-scale.js'
@@ -557,7 +538,6 @@ export default {
       giftProgress: null,   // 赠会员累计进度
       discountInfo: null,
       giftPromo: null,       // 送会员一口价促销信息(已废弃,后端恒返回非激活)
-      bundlePromo: null,    // 订单套餐自动定价优惠(后端权威下发)
       taskId: null,          // 购买任务打卡:透传至支付页
       // 限时特价(扫码闪购):由 product-detail 经 storage 一次性带入,onLoad 消费后即清除
       checkoutSpecialDealId: null,
@@ -597,10 +577,6 @@ export default {
       if (!this.giftPromo || this.giftPromo.active !== true) return 0
       return this.giftPromo.discount || 0
     },
-    // 订单套餐自动定价优惠(后端权威),命中则展示与扣减
-    bundlePromoDiscount() {
-      return bundleDiscountOf(this.bundlePromo)
-    },
     // 限时特价(扫码闪购):特价单且活动禁止优惠券时隐藏优惠券选择区。
     // 活动信息未返回(加载中/失败)时默认视为不可用券(特价单保守处理)。
     isSpecialCouponDisabled() {
@@ -621,7 +597,6 @@ export default {
       total = total - (this.couponDiscount || 0)
       total = total - (this.pointsUsed || 0)
       total = total - this.giftPromoDiscount
-      total = total - this.bundlePromoDiscount
       if (total < 0) total = 0
       return total
     },
@@ -671,10 +646,8 @@ export default {
     var stored = uni.getStorageSync('checkoutItems')
     var giftIds = uni.getStorageSync('checkoutGiftItemIds')
     var giftPromo = uni.getStorageSync('checkoutGiftPromo')
-    var bundlePromo = uni.getStorageSync('checkoutBundlePromo')
     this.giftItemIds = (giftIds && giftIds.length > 0) ? giftIds : []
     this.giftPromo = giftPromo || null
-    this.bundlePromo = bundlePromo || null
     if (stored && stored.length > 0) {
       this.items = stored
       this.calcAmount()

+ 20 - 221
cfc-frontend/pages/vendor/team-customers/team-customers.vue

@@ -35,63 +35,31 @@
 
         <!-- 列表区域 -->
         <view class="customer-item" v-for="customer in filteredCustomers" :key="customer.id">
-          <view class="customer-main">
-            <view class="customer-avatar-wrap">
-              <image
-                v-if="customer.avatar"
-                class="customer-avatar-img"
-                :src="customer.avatar"
-                mode="aspectFill"></image>
-              <text v-else class="customer-avatar-icon">👤</text>
-            </view>
-
-            <view class="customer-info">
-              <view class="customer-name">{{ customer.familyName || customer.buyerNickname || '微信用户' }}</view>
-              <view class="customer-meta">
-                <text class="member-type">{{ customer.memberType === 'login' ? '登录用户' : '本地用户' }}</text>
-                <text class="join-time">{{ formatJoinTime(customer.createdAt) }}</text>
-              </view>
-            </view>
-
-            <view class="customer-right">
-              <view class="order-count">
-                <text>{{ customer.loginMemberCount || 0 }}</text>
-                <text>成员</text>
-              </view>
+          <view class="customer-avatar-wrap">
+            <image
+              v-if="customer.avatar"
+              class="customer-avatar-img"
+              :src="customer.avatar"
+              mode="aspectFill"></image>
+            <text v-else class="customer-avatar-icon">👤</text>
+          </view>
 
-              <view class="source-tag" :class="'tag-' + (customer.source || 'default')">
-                <text>{{ getSourceTagLabel(customer.source) }}</text>
-              </view>
+          <view class="customer-info">
+            <view class="customer-name">{{ customer.familyName || customer.buyerNickname || '微信用户' }}</view>
+            <view class="customer-meta">
+              <text class="member-type">{{ customer.memberType === 'login' ? '登录用户' : '本地用户' }}</text>
+              <text class="join-time">{{ formatJoinTime(customer.createdAt) }}</text>
             </view>
           </view>
 
-          <!-- 客户维度购买情况(T32):关联订单数 / 关联金额,点击下钻购买明细 -->
-          <view class="purchase-summary" @click="togglePurchaseDetail(customer)">
-            <view class="purchase-stat">
-              <text class="purchase-stat-value">{{ customer.orderCount || 0 }}</text>
-              <text class="purchase-stat-label">关联订单</text>
-            </view>
-            <view class="purchase-stat">
-              <text class="purchase-stat-value">¥{{ formatAmountYuan(customer.totalAmountYuan) }}</text>
-              <text class="purchase-stat-label">关联金额</text>
-            </view>
-            <view class="purchase-toggle" v-if="hasRecentOrders(customer)">
-              <text>{{ isPurchaseExpanded(customer) ? '收起明细' : '购买明细' }}</text>
-              <text class="purchase-arrow">{{ isPurchaseExpanded(customer) ? '▲' : '▼' }}</text>
+          <view class="customer-right">
+            <view class="order-count">
+              <text>{{ customer.loginMemberCount || 0 }}</text>
+              <text>成员</text>
             </view>
-          </view>
 
-          <!-- 下钻:最近购买明细(orderNo / productName / amountYuan / paidAt) -->
-          <view class="purchase-detail" v-if="isPurchaseExpanded(customer)">
-            <view class="purchase-order-item" v-for="order in customer.recentOrders" :key="getOrderKey(order)">
-              <view class="purchase-order-main">
-                <text class="purchase-order-name">{{ order.productName || '商品' }}</text>
-                <text class="purchase-order-no">订单号 {{ order.orderNo || '-' }}</text>
-              </view>
-              <view class="purchase-order-side">
-                <text class="purchase-order-amount">¥{{ formatAmountYuan(order.amountYuan) }}</text>
-                <text class="purchase-order-time">{{ formatOrderTime(order.paidAt) }}</text>
-              </view>
+            <view class="source-tag" :class="'tag-' + (customer.source || 'default')">
+              <text>{{ getSourceTagLabel(customer.source) }}</text>
             </view>
           </view>
         </view>
@@ -111,7 +79,7 @@
 <script>
 import fontScaleMixin from '@/mixins/font-scale.js'
 import { getVendorTeamCustomers } from '@/utils/api.js'
-import { parseDate, formatDateTime } from '@/utils/format.js'
+import { parseDate } from '@/utils/format.js'
 export default {
   mixins: [fontScaleMixin],
   data() {
@@ -123,7 +91,6 @@ export default {
       loading: false,
       loadingMore: false,
       refreshing: false,
-      expandedCustomerIds: {}, // 已展开购买明细的客户 id 集合(T32)
 
       // 数据
       customers: [],         // 所有客户列表
@@ -181,7 +148,6 @@ export default {
           // 后端返回 { list, total }(Map),兼容旧版数组形状
           var data = res.data
           var list = data instanceof Array ? data : (data.list || data.records || [])
-          list = this.normalizeCustomers(list)
           this.customers = this.page === 1 ? list : this.customers.concat(list)
           this.hasMore = (list || []).length >= 20
           this.page++
@@ -203,66 +169,6 @@ export default {
       return customers.filter(c => c.source === status)
     },
 
-    // 归一化客户数据(后端新增 T32 购买字段,做空值兜底)
-    normalizeCustomers(list) {
-      if (!list || !(list instanceof Array)) return []
-      return list.map(c => {
-        var item = c || {}
-        if (!item.orderCount) item.orderCount = 0
-        if (item.totalAmountYuan === null || item.totalAmountYuan === undefined) item.totalAmountYuan = 0
-        if (!(item.recentOrders instanceof Array)) item.recentOrders = []
-        return item
-      })
-    },
-
-    // 展开/收起该客户的购买明细
-    togglePurchaseDetail(customer) {
-      if (!customer || customer.id === null || customer.id === undefined) return
-      var key = String(customer.id)
-      var next = {}
-      for (var k in this.expandedCustomerIds) {
-        if (this.expandedCustomerIds[k]) next[k] = true
-      }
-      if (next[key]) {
-        delete next[key]
-      } else {
-        next[key] = true
-      }
-      this.expandedCustomerIds = next
-    },
-
-    // 该客户的购买明细是否展开
-    isPurchaseExpanded(customer) {
-      if (!customer || customer.id === null || customer.id === undefined) return false
-      return !!this.expandedCustomerIds[String(customer.id)]
-    },
-
-    // 是否有可展示的购买明细
-    hasRecentOrders(customer) {
-      if (!customer) return false
-      return customer.recentOrders instanceof Array && customer.recentOrders.length > 0
-    },
-
-    // 金额格式化(元,保留 2 位)
-    formatAmountYuan(value) {
-      var num = Number(value)
-      if (!value || isNaN(num)) return '0.00'
-      return num.toFixed(2)
-    },
-
-    // 下单时间格式化(yyyy-MM-dd HH:mm:ss)
-    formatOrderTime(timeStr) {
-      if (!timeStr) return '-'
-      return formatDateTime(timeStr) || '-'
-    },
-
-    // v-for :key(小程序不支持表达式 :key,统一走方法)
-    getOrderKey(order) {
-      if (!order) return ''
-      if (order.orderNo) return order.orderNo
-      return (order.productName || '') + '-' + (order.paidAt || '')
-    },
-
     // 处理状态筛选切换
     handleStatusChange(key) {
       this.status = key
@@ -368,10 +274,6 @@ export default {
   padding: 24rpx;
   margin-bottom: 24rpx;
   box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
-}
-
-/* 客户主体行(头像 + 信息 + 右侧) */
-.customer-main {
   display: flex;
   align-items: center;
 }
@@ -476,109 +378,6 @@ export default {
   color: $facade-text-body;
 }
 
-/* 客户维度购买情况(T32) */
-.purchase-summary {
-  display: flex;
-  align-items: center;
-  margin-top: 20rpx;
-  padding: 16rpx 20rpx;
-  background-color: $facade-fill-blue-ghost;
-  border-radius: 16rpx;
-}
-
-.purchase-stat {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin-right: 40rpx;
-}
-
-.purchase-stat-value {
-  font-size: calc(28rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
-}
-
-.purchase-stat-label {
-  font-size: calc(20rpx * var(--font-scale, 1));
-  color: $app-text-subtle;
-  margin-top: 4rpx;
-}
-
-.purchase-toggle {
-  margin-left: auto;
-  display: flex;
-  align-items: center;
-  padding: 8rpx 16rpx;
-  border-radius: 16rpx;
-  background-color: $brand-primary-soft;
-}
-
-.purchase-toggle text {
-  font-size: calc(22rpx * var(--font-scale, 1));
-  color: $brand-primary;
-}
-
-.purchase-arrow {
-  margin-left: 6rpx;
-  font-size: calc(18rpx * var(--font-scale, 1));
-}
-
-/* 下钻购买明细 */
-.purchase-detail {
-  margin-top: 16rpx;
-  border-top: 1rpx solid $app-border-subtle;
-  padding-top: 8rpx;
-}
-
-.purchase-order-item {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 12rpx 4rpx;
-}
-
-.purchase-order-main {
-  flex: 1;
-  min-width: 0;
-  margin-right: 16rpx;
-}
-
-.purchase-order-name {
-  display: block;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-strong;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.purchase-order-no {
-  display: block;
-  font-size: calc(20rpx * var(--font-scale, 1));
-  color: $app-text-subtle;
-  margin-top: 4rpx;
-}
-
-.purchase-order-side {
-  flex-shrink: 0;
-  text-align: right;
-}
-
-.purchase-order-amount {
-  display: block;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $brand-primary;
-}
-
-.purchase-order-time {
-  display: block;
-  font-size: calc(18rpx * var(--font-scale, 1));
-  color: $app-text-subtle;
-  margin-top: 4rpx;
-}
-
 /* 加载更多 */
 .load-more {
   padding: 24rpx;

+ 0 - 658
cfc-frontend/pages/vendor/team-service/team-service.vue

@@ -1,658 +0,0 @@
-<template>
-  <view class="page-team-service" :style="{ '--font-scale': fontScale }">
-    <!-- 顶部:范围切换(我的任务 / 团队服务) -->
-    <view class="scope-tabs">
-      <view
-        class="scope-tab"
-        :class="{ 'scope-tab-active': scope === 'mine' }"
-        @click="switchScope('mine')"
-      >
-        <text class="scope-tab-text">派给我的</text>
-      </view>
-      <view
-        class="scope-tab"
-        :class="{ 'scope-tab-active': scope === 'team' }"
-        @click="switchScope('team')"
-      >
-        <text class="scope-tab-text">团队服务</text>
-      </view>
-    </view>
-
-    <!-- 状态筛选 -->
-    <view class="status-tabs">
-      <view
-        class="status-tab"
-        :class="{ 'status-tab-active': status === '' }"
-        @click="switchStatus('')"
-      >
-        <text>全部</text>
-      </view>
-      <view
-        class="status-tab"
-        :class="{ 'status-tab-active': status === 'PENDING' }"
-        @click="switchStatus('PENDING')"
-      >
-        <text>待处理</text>
-      </view>
-      <view
-        class="status-tab"
-        :class="{ 'status-tab-active': status === 'PROCESSING' }"
-        @click="switchStatus('PROCESSING')"
-      >
-        <text>处理中</text>
-      </view>
-      <view
-        class="status-tab"
-        :class="{ 'status-tab-active': status === 'DONE' }"
-        @click="switchStatus('DONE')"
-      >
-        <text>已完成</text>
-      </view>
-    </view>
-
-    <!-- 团队范围:指派入口 -->
-    <view class="assign-bar" v-if="scope === 'team'">
-      <button class="btn-assign" @click="openAssign">指派服务</button>
-    </view>
-
-    <!-- 任务列表 -->
-    <view class="task-list">
-      <view class="task-item" v-for="task in tasks" :key="getItemKey(task)">
-        <view class="task-head">
-          <text class="task-type">{{ getTypeLabel(task.serviceType) }}</text>
-          <text
-            class="task-status"
-            :class="{ 'status-pending': task.status === 'PENDING', 'status-processing': task.status === 'PROCESSING', 'status-done': task.status === 'DONE' }"
-          >{{ getStatusLabel(task.status) }}</text>
-        </view>
-        <view class="task-row" v-if="task.note">
-          <text class="task-label">说明</text>
-          <text class="task-value">{{ task.note }}</text>
-        </view>
-        <view class="task-row" v-if="task.assigneeNickname">
-          <text class="task-label">服务人</text>
-          <text class="task-value">{{ task.assigneeNickname }}</text>
-        </view>
-        <view class="task-row">
-          <text class="task-label">指派时间</text>
-          <text class="task-value">{{ formatTime(task.assignedAt) }}</text>
-        </view>
-        <view class="task-row" v-if="task.doneAt">
-          <text class="task-label">完成时间</text>
-          <text class="task-value">{{ formatTime(task.doneAt) }}</text>
-        </view>
-        <view class="task-actions" v-if="canOperate(task)">
-          <button
-            v-if="task.status === 'PENDING'"
-            class="btn-op btn-op-start"
-            @click="startTask(task)"
-          >开始处理</button>
-          <button
-            v-if="task.status === 'PROCESSING'"
-            class="btn-op btn-op-done"
-            @click="completeTask(task)"
-          >标记完成</button>
-        </view>
-      </view>
-      <view class="empty-tip" v-if="tasks.length === 0">
-        <text>暂无服务任务</text>
-      </view>
-    </view>
-
-    <!-- 指派弹层 -->
-    <view class="mask" v-if="showAssign" @click="closeAssign"></view>
-    <view class="assign-panel" v-if="showAssign">
-      <view class="panel-title">服务指派</view>
-
-      <view class="form-item">
-        <text class="form-label">服务类型</text>
-        <picker
-          class="form-picker"
-          :range="typeOptions"
-          range-key="label"
-          :value="typeIndex"
-          @change="onTypeChange"
-        >
-          <view class="form-picker-value">{{ typeOptions[typeIndex].label }}</view>
-        </picker>
-      </view>
-
-      <view class="form-item">
-        <text class="form-label">客户</text>
-        <picker
-          class="form-picker"
-          :range="customerOptions"
-          range-key="label"
-          :value="customerIndex"
-          @change="onCustomerChange"
-        >
-          <view class="form-picker-value">{{ getCustomerLabel() }}</view>
-        </picker>
-      </view>
-
-      <view class="form-item">
-        <text class="form-label">服务人</text>
-        <picker
-          class="form-picker"
-          :range="candidateOptions"
-          range-key="label"
-          :value="candidateIndex"
-          @change="onCandidateChange"
-        >
-          <view class="form-picker-value">{{ getCandidateLabel() }}</view>
-        </picker>
-      </view>
-
-      <view class="form-item">
-        <text class="form-label">备注</text>
-        <input class="form-input" v-model="assignNote" placeholder="选填" placeholder-class="input-placeholder" />
-      </view>
-
-      <button class="btn-submit" :disabled="submitting" @click="submitAssign">确认指派</button>
-      <button class="btn-cancel" @click="closeAssign">取消</button>
-    </view>
-  </view>
-</template>
-
-<script>
-import {
-  getServiceTasks,
-  getServiceCandidates,
-  assignServiceTask,
-  updateServiceTaskStatus,
-  getVendorTeamCustomers
-} from '@/utils/api.js'
-import fontScaleMixin from '@/mixins/font-scale.js'
-import { parseDate } from '@/utils/format.js'
-
-export default {
-  mixins: [fontScaleMixin],
-  data() {
-    return {
-      systemId: null,
-      scope: 'mine',        // mine=派给我的;team=团队服务
-      status: '',           // '' 全部 / PENDING / PROCESSING / DONE
-      tasks: [],
-
-      // 指派弹层
-      showAssign: false,
-      typeIndex: 0,
-      customerIndex: -1,
-      candidateIndex: -1,
-      assignNote: '',
-      submitting: false,
-      typeOptions: [
-        { label: '售后', value: 'AFTER_SALE' },
-        { label: '咨询', value: 'CONSULT' },
-        { label: '活动支持', value: 'ACTIVITY_SUPPORT' }
-      ],
-      customerOptions: [],
-      candidateOptions: []
-    }
-  },
-
-  onLoad(options) {
-    this.systemId = options && options.systemId ? options.systemId : null
-    if (options && options.scope === 'team') {
-      this.scope = 'team'
-    }
-    this.loadTasks()
-    this._onLoadFired = true
-  },
-
-  onShow() {
-    if (this._onLoadFired) {
-      this._onLoadFired = false
-    } else {
-      this.loadTasks()
-    }
-  },
-
-  methods: {
-    // ============ 列表 ============
-    async loadTasks() {
-      try {
-        var params = {
-          scope: this.scope,
-          status: this.status,
-          page: 1,
-          size: 50
-        }
-        if (this.systemId) {
-          params.systemId = this.systemId
-        }
-        const res = await getServiceTasks(params)
-        this.tasks = (res && res.data) || []
-      } catch (e) {
-        console.error('加载服务任务失败:', e)
-      }
-    },
-
-    switchScope(scope) {
-      if (scope === this.scope) return
-      this.scope = scope
-      this.loadTasks()
-    },
-
-    switchStatus(status) {
-      if (status === this.status) return
-      this.status = status
-      this.loadTasks()
-    },
-
-    // ============ 操作 ============
-    canOperate(task) {
-      // 派给我的任务可推进状态;团队范围仅展示
-      if (this.scope !== 'mine') return false
-      return task.status === 'PENDING' || task.status === 'PROCESSING'
-    },
-
-    async startTask(task) {
-      const res = await updateServiceTaskStatus(task.id, 'PROCESSING', task.note)
-      if (res && res.code === 200) {
-        uni.showToast({ title: '已开始处理', icon: 'success' })
-        this.loadTasks()
-      } else {
-        uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
-      }
-    },
-
-    async completeTask(task) {
-      const res = await updateServiceTaskStatus(task.id, 'DONE', task.note)
-      if (res && res.code === 200) {
-        uni.showToast({ title: '已完成', icon: 'success' })
-        this.loadTasks()
-      } else {
-        uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
-      }
-    },
-
-    // ============ 指派 ============
-    async openAssign() {
-      this.showAssign = true
-      this.typeIndex = 0
-      this.customerIndex = -1
-      this.candidateIndex = -1
-      this.assignNote = ''
-      // 并发加载客户与候选人
-      try {
-        const [custRes, candRes] = await Promise.all([
-          this.loadCustomers(),
-          this.loadCandidates()
-        ])
-        this.customerOptions = custRes || []
-        this.candidateOptions = candRes || []
-      } catch (e) {
-        console.error('加载指派数据失败:', e)
-      }
-    },
-
-    async loadCustomers() {
-      if (!this.systemId) return []
-      try {
-        const res = await getVendorTeamCustomers({
-          systemId: this.systemId,
-          parentId: null,
-          page: 1,
-          size: 100
-        })
-        var data = res && res.data
-        var list = []
-        if (data instanceof Array) {
-          list = data
-        } else if (data && data.list) {
-          list = data.list
-        }
-        return list.map(function (c) {
-          return { label: c.familyName || '未知家庭', value: c.id, source: c }
-        })
-      } catch (e) {
-        return []
-      }
-    },
-
-    async loadCandidates() {
-      if (!this.systemId) return []
-      try {
-        const res = await getServiceCandidates(this.systemId)
-        var list = (res && res.data) || []
-        return list.map(function (c) {
-          return { label: c.nickname || '成员', value: c.userId, source: c }
-        })
-      } catch (e) {
-        return []
-      }
-    },
-
-    closeAssign() {
-      this.showAssign = false
-    },
-
-    onTypeChange(e) {
-      this.typeIndex = Number(e.detail.value)
-    },
-
-    onCustomerChange(e) {
-      this.customerIndex = Number(e.detail.value)
-    },
-
-    onCandidateChange(e) {
-      this.candidateIndex = Number(e.detail.value)
-    },
-
-    getCustomerLabel() {
-      if (this.customerIndex >= 0 && this.customerOptions[this.customerIndex]) {
-        return this.customerOptions[this.customerIndex].label
-      }
-      return '请选择客户'
-    },
-
-    getCandidateLabel() {
-      if (this.candidateIndex >= 0 && this.candidateOptions[this.candidateIndex]) {
-        return this.candidateOptions[this.candidateIndex].label
-      }
-      return '请选择服务人'
-    },
-
-    async submitAssign() {
-      if (this.customerIndex < 0) {
-        uni.showToast({ title: '请选择客户', icon: 'none' })
-        return
-      }
-      if (this.candidateIndex < 0) {
-        uni.showToast({ title: '请选择服务人', icon: 'none' })
-        return
-      }
-      this.submitting = true
-      try {
-        var type = this.typeOptions[this.typeIndex]
-        var customer = this.customerOptions[this.customerIndex]
-        var candidate = this.candidateOptions[this.candidateIndex]
-        var params = {
-          systemId: this.systemId,
-          customerRelationId: customer.value,
-          assigneeUserId: candidate.value,
-          serviceType: type.value,
-          note: this.assignNote
-        }
-        const res = await assignServiceTask(params)
-        if (res && res.code === 200) {
-          uni.showToast({ title: '指派成功', icon: 'success' })
-          this.closeAssign()
-          this.loadTasks()
-        } else {
-          uni.showToast({ title: (res && res.message) || '指派失败', icon: 'none' })
-        }
-      } finally {
-        this.submitting = false
-      }
-    },
-
-    // ============ 展示辅助 ============
-    getItemKey(task) {
-      return task.id
-    },
-
-    getTypeLabel(type) {
-      var map = { AFTER_SALE: '售后', CONSULT: '咨询', ACTIVITY_SUPPORT: '活动支持' }
-      return map[type] || type || '服务'
-    },
-
-    getStatusLabel(status) {
-      var map = { PENDING: '待处理', PROCESSING: '处理中', DONE: '已完成' }
-      return map[status] || status || '未知'
-    },
-
-    formatTime(timeStr) {
-      if (!timeStr) return '-'
-      var d = parseDate(timeStr)
-      if (!d) return '-'
-      var mo = ('0' + (d.getMonth() + 1)).slice(-2)
-      var da = ('0' + d.getDate()).slice(-2)
-      var hr = ('0' + d.getHours()).slice(-2)
-      var mi = ('0' + d.getMinutes()).slice(-2)
-      return d.getFullYear() + '-' + mo + '-' + da + ' ' + hr + ':' + mi
-    }
-  }
-}
-</script>
-
-<style lang="scss">
-.page-team-service {
-  background-color: $uni-primary-pale;
-  min-height: 100vh;
-  padding-bottom: 80rpx;
-}
-
-/* 范围切换 */
-.scope-tabs {
-  display: flex;
-  background-color: $app-surface;
-  padding: 20rpx $uni-space-page;
-}
-
-.scope-tab {
-  flex: 1;
-  text-align: center;
-  padding: 12rpx 0;
-  border-radius: 16rpx;
-}
-
-.scope-tab-active {
-  background-color: $brand-primary;
-}
-
-.scope-tab-text {
-  font-size: calc(28rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-}
-
-.scope-tab-active .scope-tab-text {
-  color: $app-surface;
-  font-weight: bold;
-}
-
-/* 状态筛选 */
-.status-tabs {
-  display: flex;
-  background-color: $app-surface;
-  padding: 0 $uni-space-page 16rpx;
-}
-
-.status-tab {
-  flex: 1;
-  text-align: center;
-  padding: 10rpx 0;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-}
-
-.status-tab-active {
-  color: $brand-primary;
-  font-weight: bold;
-}
-
-/* 指派入口 */
-.assign-bar {
-  padding: 20rpx $uni-space-page;
-}
-
-.btn-assign {
-  background-color: $brand-primary;
-  color: $app-surface;
-  border-radius: 16rpx;
-  font-size: calc(28rpx * var(--font-scale, 1));
-}
-
-/* 任务列表 */
-.task-list {
-  padding: 0 $uni-space-page;
-}
-
-.task-item {
-  background-color: $app-surface;
-  border-radius: 24rpx;
-  padding: 24rpx;
-  margin-bottom: 20rpx;
-  box-shadow: 0 2rpx 12rpx rgba(249,115,22,0.08);
-}
-
-.task-head {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12rpx;
-}
-
-.task-type {
-  font-size: calc(28rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
-}
-
-.task-status {
-  padding: 4rpx 16rpx;
-  border-radius: 16rpx;
-  font-size: calc(22rpx * var(--font-scale, 1));
-}
-
-.status-pending {
-  background-color: #FFF7EE;
-  color: $brand-primary;
-}
-
-.status-processing {
-  background-color: #EEF8FF;
-  color: #2F5496;
-}
-
-.status-done {
-  background-color: #E6F7EE;
-  color: #65C29A;
-}
-
-.task-row {
-  display: flex;
-  margin-top: 8rpx;
-}
-
-.task-label {
-  width: 140rpx;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $app-text-subtle;
-  flex-shrink: 0;
-}
-
-.task-value {
-  flex: 1;
-  font-size: calc(24rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-}
-
-.task-actions {
-  display: flex;
-  justify-content: flex-end;
-  margin-top: 16rpx;
-}
-
-.btn-op {
-  padding: 0 32rpx;
-  border-radius: 12rpx;
-  font-size: calc(26rpx * var(--font-scale, 1));
-}
-
-.btn-op-start {
-  background-color: #EEF8FF;
-  color: #2F5496;
-}
-
-.btn-op-done {
-  background-color: #E6F7EE;
-  color: #65C29A;
-}
-
-.empty-tip {
-  text-align: center;
-  padding: 80rpx 0;
-  color: $app-text-subtle;
-  font-size: calc(26rpx * var(--font-scale, 1));
-}
-
-/* 指派弹层 */
-.mask {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background-color: rgba(0, 0, 0, 0.5);
-  z-index: 100;
-}
-
-.assign-panel {
-  position: fixed;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background-color: $app-surface;
-  border-radius: 32rpx 32rpx 0 0;
-  padding: 32rpx $uni-space-page;
-  z-index: 101;
-}
-
-.panel-title {
-  font-size: calc(32rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
-  margin-bottom: 24rpx;
-  text-align: center;
-}
-
-.form-item {
-  display: flex;
-  align-items: center;
-  padding: 16rpx 0;
-  border-bottom: 1rpx solid $uni-primary-pale;
-}
-
-.form-label {
-  width: 140rpx;
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-  flex-shrink: 0;
-}
-
-.form-picker {
-  flex: 1;
-}
-
-.form-picker-value {
-  font-size: calc(26rpx * var(--font-scale, 1));
-  color: $facade-text-strong;
-}
-
-.form-input {
-  flex: 1;
-  font-size: calc(26rpx * var(--font-scale, 1));
-}
-
-.input-placeholder {
-  color: $app-text-subtle;
-}
-
-.btn-submit {
-  margin-top: 32rpx;
-  background-color: $brand-primary;
-  color: $app-surface;
-  border-radius: 16rpx;
-  font-size: calc(28rpx * var(--font-scale, 1));
-}
-
-.btn-cancel {
-  margin-top: 16rpx;
-  background-color: $uni-primary-pale;
-  color: $facade-text-body;
-  border-radius: 16rpx;
-  font-size: calc(28rpx * var(--font-scale, 1));
-}
-</style>

+ 7 - 132
cfc-frontend/pages/vendor/team-system/team-system.vue

@@ -136,40 +136,16 @@
       </view>
     </view>
 
-    <!-- 服务指派(T34) -->
-    <view class="section">
-      <view class="section-header">
-        <text class="section-title">服务指派</text>
-        <text class="section-action" @click="goToServiceTasks">查看全部</text>
-      </view>
-      <view class="service-list">
-        <view class="service-item" v-for="task in serviceTasks.slice(0, 5)" :key="task.id">
-          <view class="service-info">
-            <text class="service-type">{{ task.serviceType === 'AFTER_SALE' ? '售后' : task.serviceType === 'CONSULT' ? '咨询' : task.serviceType === 'ACTIVITY_SUPPORT' ? '活动支持' : '服务' }}</text>
-            <text class="service-note">{{ task.note || '(无备注)' }}</text>
-          </view>
-          <text
-            class="service-status"
-            :class="{ 'service-status-pending': task.status === 'PENDING', 'service-status-processing': task.status === 'PROCESSING', 'service-status-done': task.status === 'DONE' }"
-          >{{ task.status === 'PENDING' ? '待处理' : task.status === 'PROCESSING' ? '处理中' : task.status === 'DONE' ? '已完成' : '未知' }}</text>
-        </view>
-        <view class="empty-tip" v-if="serviceTasks.length === 0">
-          <text>暂无服务任务</text>
-        </view>
-      </view>
-    </view>
-
     <!-- 操作按钮 -->
     <view class="action-bar">
       <button class="btn-manage" @click="goToManage">团队管理</button>
       <button class="btn-customers" @click="goToCustomers">客户列表</button>
-      <button class="btn-services" @click="goToServiceTasks">服务任务</button>
     </view>
   </view>
 </template>
 
 <script>
-import { getVendorSystemInfo, getVendorHierarchyInfo, getVendorTeamMembers, getVendorTeamCustomers, getVendorTeamList, getServiceTasks } from '@/utils/api.js'
+import { getVendorSystemInfo, getVendorHierarchyInfo, getVendorTeamMembers, getVendorTeamCustomers, getVendorTeamList } from '@/utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -187,8 +163,7 @@ export default {
       },
       teamMembers: [],
       teamCustomers: [],
-      customerTotal: 0,  // 「客户归属」真实总数(后端 total,避免分页截断)
-      serviceTasks: []   // 服务指派任务列表(T34)
+      customerTotal: 0   // 「客户归属」真实总数(后端 total,避免分页截断)
     }
   },
 
@@ -237,7 +212,6 @@ export default {
           this.hierarchyInfo = null
           this.teamMembers = []
           this.teamCustomers = []
-          this.serviceTasks = []
           this.updateStats()
           return
         }
@@ -245,12 +219,11 @@ export default {
         // 2. 并发加载当前团队数据(竞态防护:切换后旧响应不覆盖新团队)
         var seq = (this._teamLoadSeq || 0) + 1
         this._teamLoadSeq = seq
-        const [systemRes, hierarchyRes, membersRes, customersRes, servicesRes] = await Promise.all([
+        const [systemRes, hierarchyRes, membersRes, customersRes] = await Promise.all([
           this.fetchSystemInfo(),
           this.fetchHierarchyInfo(),
           this.fetchTeamMembers(),
-          this.fetchTeamCustomers(),
-          this.fetchServiceTasks()
+          this.fetchTeamCustomers()
         ])
         if (seq !== this._teamLoadSeq) return
 
@@ -267,9 +240,6 @@ export default {
           this.teamCustomers = customersRes.list || []
           this.customerTotal = customersRes.total || 0
         }
-        if (servicesRes) {
-          this.serviceTasks = servicesRes
-        }
 
         // 更新统计
         this.updateStats()
@@ -286,7 +256,6 @@ export default {
       this.hierarchyInfo = null
       this.teamMembers = []
       this.teamCustomers = []
-      this.serviceTasks = []
       this.loadTeamData()
     },
 
@@ -296,12 +265,11 @@ export default {
       var seq = (this._teamLoadSeq || 0) + 1
       this._teamLoadSeq = seq
       try {
-        const [systemRes, hierarchyRes, membersRes, customersRes, servicesRes] = await Promise.all([
+        const [systemRes, hierarchyRes, membersRes, customersRes] = await Promise.all([
           this.fetchSystemInfo(),
           this.fetchHierarchyInfo(),
           this.fetchTeamMembers(),
-          this.fetchTeamCustomers(),
-          this.fetchServiceTasks()
+          this.fetchTeamCustomers()
         ])
         if (seq !== this._teamLoadSeq) return
         if (systemRes) {
@@ -317,9 +285,6 @@ export default {
           this.teamCustomers = customersRes.list || []
           this.customerTotal = customersRes.total || 0
         }
-        if (servicesRes) {
-          this.serviceTasks = servicesRes
-        }
         this.updateStats()
       } catch (error) {
         console.error('加载团队数据失败:', error)
@@ -378,34 +343,11 @@ export default {
       }
     },
 
-    // 获取服务指派任务(T34,团队全部,仅预览前几条)
-    async fetchServiceTasks() {
-      try {
-        const res = await getServiceTasks({
-          systemId: this.currentSystemId,
-          scope: 'team',
-          page: 1,
-          size: 5
-        })
-        return res.data || []
-      } catch (e) {
-        return []
-      }
-    },
-
     // 更新统计数据(「客户归属」用后端返回的真实总数,避免分页截断)
     updateStats() {
       this.teamStats.customerCount = this.customerTotal || 0
     },
 
-    // 跳转服务任务页(T34)
-    goToServiceTasks() {
-      var url = '/pages/vendor/team-service/team-service?systemId=' + this.currentSystemId + '&scope=team'
-      uni.navigateTo({
-        url: url
-      })
-    },
-
     // 跳转管理页面
     goToManage() {
       uni.navigateTo({
@@ -804,68 +746,6 @@ export default {
   color: $facade-text-body;
 }
 
-/* 服务指派(T34) */
-.service-list {
-  display: flex;
-  flex-direction: column;
-}
-
-.service-item {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 20rpx 0;
-  border-bottom: 1rpx solid $uni-primary-pale;
-}
-
-.service-item:last-child {
-  border-bottom: none;
-}
-
-.service-info {
-  display: flex;
-  flex-direction: column;
-  flex: 1;
-  margin-right: 16rpx;
-}
-
-.service-type {
-  font-size: calc(26rpx * var(--font-scale, 1));
-  font-weight: bold;
-  color: $facade-text-strong;
-  margin-bottom: 8rpx;
-}
-
-.service-note {
-  font-size: calc(22rpx * var(--font-scale, 1));
-  color: $facade-text-body;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.service-status {
-  flex-shrink: 0;
-  padding: 4rpx 16rpx;
-  border-radius: 16rpx;
-  font-size: calc(22rpx * var(--font-scale, 1));
-}
-
-.service-status-pending {
-  background-color: #FFF7EE;
-  color: $brand-primary;
-}
-
-.service-status-processing {
-  background-color: #EEF8FF;
-  color: #2F5496;
-}
-
-.service-status-done {
-  background-color: #E6F7EE;
-  color: #65C29A;
-}
-
 /* 操作按钮 */
 .action-bar {
   position: fixed;
@@ -879,7 +759,7 @@ export default {
   box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.05);
 }
 
-.btn-manage, .btn-customers, .btn-services {
+.btn-manage, .btn-customers {
   flex: 1;
   height: 80rpx;
   line-height: 80rpx;
@@ -898,9 +778,4 @@ export default {
   color: $brand-primary;
   border: 2rpx solid $brand-primary;
 }
-
-.btn-services {
-  background-color: #EEF8FF;
-  color: #2F5496;
-}
 </style>

+ 8 - 23
cfc-frontend/utils/api.js

@@ -606,6 +606,10 @@ export const getGiftMembershipProgress = () => {
 	return request('/api/membership/gift-membership-progress', 'POST')
 }
 
+// 购物车套餐offer(勾选子集检测)与「一键替换为套餐」
+export const queryComboOffer = (payload) => request('/api/cart/combo-offer', 'POST', payload)
+export const replaceCartWithCombo = (payload) => request('/api/cart/replace-combo', 'POST', payload)
+
 export const getAllQuotaStatus = () => {
 	return request('/api/basic-quota/status/all', 'POST')
 }
@@ -1634,9 +1638,11 @@ export const vendorInfo = () => {
  * 获取供应商团队客户列表
  * 后端接口:POST /api/admin/supply-team/customers
  * 参数:systemId, parentId, page, size
- * 返回:{ list, total };list 每项含客户维度购买情况 orderCount / totalAmountYuan / recentOrders
- * 规范入口:getVendorTeamCustomers(见本文件下方供应商团队区),请勿重复封装同一 URL。
+ * 返回:records + hasMore
  */
+export const getTeamCustomers = (params) => {
+  return request('/api/admin/supply-team/customers', 'POST', params)
+}
 
 //  幸福邻里部落 - 商品系统 
 
@@ -3480,27 +3486,6 @@ export const getVendorTeamCustomers = (params) =>
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
 
-// ===================== T34 团队服务指派 =====================
-/** 服务指派:把某客户(售后/咨询/活动支持)指派给团队成员跟进 */
-export const assignServiceTask = (params) =>
-  request('/api/admin/supply-team/service-assign', 'POST', params)
-
-/** 服务任务列表:scope=mine(默认,派给我的)/ scope=team(团队全部) */
-export const getServiceTasks = (params) =>
-  request('/api/admin/supply-team/service-tasks', 'POST', params)
-
-/** 某客户的服务记录(按客户归属关系ID) */
-export const getCustomerServices = (customerRelationId) =>
-  request('/api/admin/supply-team/customer/' + customerRelationId + '/services', 'POST', {})
-
-/** 更新服务任务状态(PENDING/PROCESSING/DONE) */
-export const updateServiceTaskStatus = (id, status, note) =>
-  request('/api/admin/supply-team/service-task/update', 'POST', { id, status, note })
-
-/** 指派候选人列表(团队成员 + 商品服务人) */
-export const getServiceCandidates = (systemId) =>
-  request('/api/admin/supply-team/service-candidates', 'POST', { systemId })
-
 // ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
 /** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
 export const createSsoTicket = (target) =>

+ 27 - 33
cfc-frontend/utils/bundle-pricing.js

@@ -1,44 +1,38 @@
 /**
- * 订单套餐自动定价 · 前端展示辅助(纯函数)
+ * 购物车套餐offer展示辅助(纯函数)
  *
- * 权威计算在后端:购物车/下单金额均由后端按 product_bundle_items 定价行重算,
- * 前端仅消费后端下发的 bundlePromo 做展示与总价扣减,杜绝前端篡改优惠。
+ * 权威计算在后端:POST /api/cart/combo-offer 按勾选子集检测 offer,
+ * 前端仅消费后端下发的 comboOffer 做提示与「一键替换为套餐」,不做任何金额扣减。
  */
 
 /**
- * 安全读取套餐优惠金额(分):未命中/未激活/非法值一律返回 0。
- * @param {Object|null} promo 后端下发的 bundlePromo
- * @returns {number}
+ * 安全读取套餐offer:未命中/未激活/非法值一律返回 null。
+ * @param {Object|null} offer 后端下发的 comboOffer
+ * @returns {Object|null}
  */
-export function bundleDiscountOf(promo) {
-  if (!promo || promo.active !== true) return 0
-  const d = promo.discount
-  return typeof d === 'number' && d > 0 ? d : 0
+export function comboOfferOf(offer) {
+  if (!offer || offer.active !== true) return null
+  return offer
 }
 
 /**
- * 套餐命中明细 → 展示行(过滤脏数据、补齐默认值)。
- * @param {Array|null} bundleList
- * @returns {Array<{name: string, times: number, saving: number}>}
+ * 套餐offer提示文案:这N件组成套餐「XX」,套餐价 ¥Y(省 ¥Z)
+ * @param {Object|null} offer 后端下发的 comboOffer
+ * @param {number} [checkedCount] 勾选子集件数(后端响应未含,由前端传入)
+ * @returns {string}
  */
-export function formatBundleLines(bundleList) {
-  if (!Array.isArray(bundleList)) return []
-  return bundleList
-    .filter(h => h && h.bundleName)
-    .map(h => ({
-      name: h.bundleName,
-      times: (typeof h.times === 'number' && h.times > 0) ? h.times : 1,
-      saving: (typeof h.saving === 'number' && h.saving > 0) ? h.saving : 0
-    }))
-}
-
-/**
- * 对总价应用套餐优惠(安全扣减)。
- * @param {number} total 总价(分)
- * @param {Object|null} promo
- * @returns {number}
- */
-export function applyBundleDiscount(total, promo) {
-  const base = typeof total === 'number' ? total : 0
-  return base - bundleDiscountOf(promo)
+export function formatComboOfferText(offer, checkedCount) {
+  const o = comboOfferOf(offer)
+  if (!o) return ''
+  const n = (typeof checkedCount === 'number' && checkedCount > 0) ? checkedCount
+    : ((typeof o.groups === 'number' && o.groups > 0) ? o.groups : 0)
+  const name = o.comboName || ''
+  const price = (typeof o.comboPrice === 'number' && o.comboPrice >= 0) ? o.comboPrice : 0
+  const saving = (typeof o.saving === 'number' && o.saving > 0) ? o.saving : 0
+  let txt = ''
+  if (n) txt += n + ' 件'
+  if (name) txt += '组成套餐「' + name + '」'
+  txt += ',套餐价 ¥' + (price / 100).toFixed(2)
+  if (saving) txt += '(省 ¥' + (saving / 100).toFixed(2) + ')'
+  return txt
 }

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1933",
+  "version": "1.0.1935",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1933",
+      "version": "1.0.1935",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 3 - 2
cfc-web/src/__tests__/components/ProductSkuEditor.mode.test.js

@@ -13,7 +13,7 @@ const localVue = createLocalVue()
 localVue.use(ElementUI)
 
 describe("ProductSkuEditor 模式选择", () => {
-  it("添加SKU时不再提供「关联商品」单选模式", async () => {
+  it("添加SKU时仅保留「规格变体」模式(「商品组合」「关联商品」均已移除)", async () => {
     const wrapper = mount(ProductSkuEditor, {
       localVue,
       propsData: { productId: null },
@@ -24,7 +24,8 @@ describe("ProductSkuEditor 模式选择", () => {
     await wrapper.vm.$nextTick()
     const buttons = wrapper.findAll(".el-radio-button").wrappers.map(w => w.text())
     expect(buttons).not.toContain("关联商品")
-    expect(buttons).toEqual(expect.arrayContaining(["规格变体", "商品组合"]))
+    expect(buttons).not.toContain("商品组合")
+    expect(buttons).toEqual(["规格变体"])
     wrapper.destroy()
   })
 })

+ 6 - 14
cfc-web/src/api/admin.js

@@ -2057,27 +2057,19 @@ export function getSpecialPriceQrcode(id) {
   return request({ url: '/api/admin/special-price/qrcode', method: 'post', data: { id } })
 }
 
-export function saveBundleItems(bundleProductId, items) {
+export function saveComboItems(comboSkuId, items) {
   return request({
-    url: '/api/admin/inventory/bundle/items',
+    url: '/api/admin/inventory/combo/items',
     method: 'post',
-    data: { bundleProductId, items }
+    data: { comboSkuId, items }
   })
 }
 
-export function getBundleItems(bundleProductId) {
+export function listComboItems(comboSkuId) {
   return request({
-    url: '/api/admin/inventory/bundle/items/list',
+    url: '/api/admin/inventory/combo/items/list',
     method: 'post',
-    data: { bundleProductId }
-  })
-}
-
-export function checkBundleStock(bundleProductId, quantity) {
-  return request({
-    url: '/api/admin/inventory/bundle/check-stock',
-    method: 'post',
-    data: { bundleProductId, quantity }
+    data: { comboSkuId }
   })
 }
 

+ 9 - 4
cfc-web/src/views/Dashboard.vue

@@ -65,7 +65,7 @@
             </div>
           </div>
           <div class="dwell-list">
-            <div class="dwell-item" v-for="(row, i) in this[sec.key]" :key="i">
+            <div class="dwell-item" v-for="(row, i) in sectionList(sec.key)" :key="i">
               <span class="dwell-rank" :class="'rank-' + (i + 1)">{{ i + 1 }}</span>
               <div class="dwell-main">
                 <div class="dwell-name" :title="row.name">{{ row.name }}</div>
@@ -78,7 +78,7 @@
                 <span class="dwell-val__meta">{{ row.meta }}</span>
               </div>
             </div>
-            <div v-if="!((this[sec.key] || []).length)" class="dwell-empty">
+            <div v-if="!sectionList(sec.key).length" class="dwell-empty">
               <i class="el-icon-data-analysis"></i>
               <span>{{ sec.emptyHint }}</span>
             </div>
@@ -132,7 +132,7 @@
             <span class="hot-card__sub">{{ sec.sub }}</span>
           </div>
           <div class="hot-list">
-            <div class="hot-item" v-for="(row, i) in this[sec.key]" :key="i">
+            <div class="hot-item" v-for="(row, i) in sectionList(sec.key)" :key="i">
               <span class="hot-rank" :class="'rank-' + (i + 1)">{{ i + 1 }}</span>
               <div class="hot-main">
                 <div class="hot-name" :title="row.name">{{ row.name }}</div>
@@ -142,7 +142,7 @@
               </div>
               <div class="hot-val adm-num">{{ row.value }}<span class="hot-val__unit">{{ row.unit }}</span></div>
             </div>
-            <div v-if="!((this[sec.key] || []).length)" class="hot-empty">暂无数据</div>
+            <div v-if="!sectionList(sec.key).length" class="hot-empty">暂无数据</div>
           </div>
         </div>
       </el-col>
@@ -410,6 +410,11 @@ export default {
       return v || ''
     },
     // 停留时长 / 热度 相对最大值,用于条形宽度
+    sectionList(key) {
+      // v-for 回调内 this 不是组件(Vue2 普通函数 this=undefined),
+      // 故模板用 sectionList(sec.key) 而非 this[sec.key](会抛 undefined['articleDwell'])
+      return this[key] || []
+    },
     sectionMax(key) {
       const list = this[key] || []
       if (!list.length) return 0

+ 125 - 62
cfc-web/src/views/admin/ProductEdit.vue

@@ -48,6 +48,44 @@
           <div class="form-hint-mt4">用于小程序文章详情页「相关商品」推荐:标签与文章标签一字不差才会匹配,留空则不参与匹配</div>
         </el-form-item>
 
+        <!-- 营养信息(可选) -->
+        <SectionTitle title="营养信息(可选)" />
+        <el-row :gutter="20">
+          <el-col :span="6">
+            <el-form-item label="热量(kcal)">
+              <el-input-number v-model="form.caloriesKcal" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="蛋白质(g)">
+              <el-input-number v-model="form.proteinG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="脂肪(g)">
+              <el-input-number v-model="form.fatG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="碳水(g)">
+              <el-input-number v-model="form.carbG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-row :gutter="20">
+          <el-col :span="6">
+            <el-form-item label="膳食纤维(g)">
+              <el-input-number v-model="form.fiberG" :min="0" :precision="2" placeholder="每100g" class="full-width" controls />
+            </el-form-item>
+          </el-col>
+          <el-col :span="18">
+            <el-form-item label="营养标签">
+              <el-input v-model="form.nutritionTags" placeholder="逗号分隔,如: 低糖,高蛋白,无添加" maxlength="200" clearable class="full-width" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <div class="form-hint-mt4" style="margin-left: 100px;">以上数据均为每100g含量,留空即视为未配置;营养标签用于小程序详情页展示与推荐筛选,留空则不展示营养信息</div>
+
         <!-- 测评商品扩展信息 -->
         <template v-if="form.productType === 'assessment'">
           <el-form-item label="测评类型">
@@ -79,23 +117,20 @@
           </el-row>
         </template>
 
-        <!-- 套餐商品扩展信息 -->
+        <!-- 套餐商品扩展信息(新模型:内容=子商品SKU×数量,挂套餐SKU级 product_combo_items) -->
         <template v-if="form.productType === 'bundle'">
-          <SectionTitle title="套餐组成" />
-          <el-form-item label="组成商品">
+          <SectionTitle title="套餐内容" />
+          <el-form-item label="套餐内容">
+            <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openComboPicker">选择子商品SKU</el-button>
             <div v-for="(item, idx) in bundleItems" :key="'bi' + idx" class="bundle-row">
-              <el-select v-model="item.childProductId" filterable remote placeholder="搜索子商品" :remote-method="(q) => searchBundleProduct(q, idx)" class="w-220" @change="(pid) => onBundleProductChange(pid, idx)">
-                <el-option v-for="p in item.productOptions" :key="p.id" :label="(p.name || 'ID:' + p.id) + ' (库存:' + (p.stock || 0) + ')'" :value="p.id" />
-              </el-select>
-              <el-select v-if="item.skuOptions.length" v-model="item.childSkuId" placeholder="选择规格" clearable class="w-160">
-                <el-option v-for="s in item.skuOptions" :key="s.id" :label="(s.specs || '默认') + ' (库存:' + (s.stock || 0) + ')'" :value="s.id" />
-              </el-select>
+              <span class="bundle-item-name">商品 #{{ item.childProductId }}</span>
+              <span v-if="item.childSkuId" class="bundle-item-spec">· 规格 #{{ item.childSkuId }}</span>
               <el-input-number v-model="item.quantity" :min="1" :precision="0" size="small" class="w-110" />
               <el-button type="danger" size="mini" @click="removeBundleItem(idx)">删除</el-button>
             </div>
-            <el-button type="primary" size="mini" plain @click="addBundleItem()">+ 添加子商品</el-button>
-            <div class="form-hint-mt8">套餐出库时自动完成所有组成商品出库(数量 × 套餐数量)</div>
+            <div class="form-hint-mt8">套餐内容挂套餐SKU级;下单/支付只扣套餐SKU库存,不扣子商品库存(需先添加套餐SKU作锚点)。</div>
           </el-form-item>
+          <ProductPicker ref="comboPicker" :multiple="true" :cross-category="true" :selected="comboPickerSelected" @confirm="onComboPickerConfirm" />
         </template>
 
         <!-- 关联成长档案 -->
@@ -388,7 +423,7 @@
 </template>
 
 <script>
-import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveBundleItems, getBundleItems, getApprovedGuides, saveProductGift, getProductGiftList, getCategoryTree } from '@/api/admin'
+import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots, getProductList, getProductSkuList, saveComboItems, listComboItems, getApprovedGuides, saveProductGift, getProductGiftList, getCategoryTree } from '@/api/admin'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
@@ -396,11 +431,12 @@ import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 import AssetPicker from '@/components/AssetPicker.vue'
+import ProductPicker from './components/ProductPicker.vue'
 import { buildEditorAssetMenuConf, insertAssetIntoEditor, getEditorCustomAlert, registerAssetLibraryMenu, setAssetMenuHandler, buildAssetToolbarConfig } from '@/utils/editorAssetUpload'
 
 export default {
   name: 'ProductEdit',
-  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, ProductSkuEditor, Editor, Toolbar, AssetPicker },
+  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, ProductSkuEditor, Editor, Toolbar, AssetPicker, ProductPicker },
   data() {
     return {
       form: {
@@ -424,6 +460,13 @@ export default {
         minVisibleLevel: '',
         isRecommended: 0,
         tags: '',
+        // 营养信息(可选,留空=未配置)
+        caloriesKcal: null,
+        proteinG: null,
+        fatG: null,
+        carbG: null,
+        fiberG: null,
+        nutritionTags: '',
         dimensionWeights: [],
         // 测评商品扩展
         assessmentType: 'single',
@@ -433,7 +476,7 @@ export default {
         sortOrder: 0,
         categoryId: null
       },
-      bundleItems: [], // 套餐组成: [{childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
+      bundleItems: [], // 套餐内容: [{childProductId, childSkuId, quantity}](挂套餐SKU级 product_combo_items)
       servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice}]
       approvedGuides: [], // 已入驻服务商列表
       selectedGuideId: '',
@@ -469,40 +512,9 @@ export default {
       assetPickerMode: 'single',
       assetPickerType: 'image',
       assetPickerTarget: 'content',
-    addBundleItem() {
-      this.bundleItems = this.bundleItems || []
-      this.bundleItems.push({
-        childProductId: null,
-        childSkuId: null,
-        quantity: 1,
-        productOptions: [],
-        skuOptions: []
-      })
-    },
     removeBundleItem(idx) {
       this.bundleItems.splice(idx, 1)
     },
-    async searchBundleProduct(query, idx) {
-      if (!query || query.length < 1) return
-      try {
-        var res = await getProductList({ keyword: query, page: 1, size: 20 })
-        if (res.code === 200 && res.data && Array.isArray(res.data.records)) {
-          this.bundleItems[idx].productOptions = res.data.records
-        }
-      } catch (e) { /* ignore */ }
-    },
-    async onBundleProductChange(productId, idx) {
-      var item = this.bundleItems[idx]
-      item.childSkuId = null
-      item.skuOptions = []
-      if (!productId) return
-      try {
-        var res = await getProductSkuList(productId)
-        if (res.code === 200 && Array.isArray(res.data)) {
-          item.skuOptions = res.data
-        }
-      } catch (e) { /* ignore */ }
-    },
     buildBundleItemPayload() {
       return this.bundleItems
         .filter(function(b) { return b.childProductId })
@@ -516,24 +528,36 @@ export default {
     },
     async loadBundleItems(productId) {
       try {
-        var res = await getBundleItems(productId)
+        const skuRes = await getProductSkuList(productId)
+        const skus = (skuRes && skuRes.code === 200 && Array.isArray(skuRes.data)) ? skuRes.data : []
+        if (!skus.length) {
+          this.bundleItems = []
+          return
+        }
+        const comboSkuId = skus[0].id
+        const res = await listComboItems(comboSkuId)
         if (res.code === 200 && Array.isArray(res.data)) {
           this.bundleItems = res.data.map(function(b) {
             return {
               childProductId: b.childProductId,
               childSkuId: b.childSkuId || null,
-              quantity: b.quantity || 1,
-              productOptions: [],
-              skuOptions: []
+              quantity: b.quantity || 1
             }
           })
-          this.bundleItems.forEach(function(item, idx) {
-            if (item.childProductId) {
-              this.onBundleProductChange(item.childProductId, idx)
-            }
-          }, this)
         }
       } catch (e) { /* ignore */ }
+    },
+    openComboPicker() {
+      this.$refs.comboPicker.open()
+    },
+    onComboPickerConfirm(items) {
+      this.bundleItems = (items || []).map(function(it) {
+        return {
+          childProductId: it.childProductId || it.id,
+          childSkuId: it.childSkuId || null,
+          quantity: it.quantity || 1
+        }
+      })
     }
     }
   },
@@ -541,6 +565,15 @@ export default {
     isEdit() {
       return !!this.$route.query.id
     },
+    comboPickerSelected() {
+      return this.bundleItems.map(function(it) {
+        return {
+          childProductId: it.childProductId,
+          childSkuId: it.childSkuId,
+          quantity: it.quantity
+        }
+      })
+    },
     categoryGroups() {
       const groups = []
       const map = {}
@@ -729,7 +762,14 @@ export default {
             validityDays: p.validityDays || 365,
             guideScope: p.guideScope || 'all',
             sortOrder: p.sortOrder != null ? p.sortOrder : 0,
-            categoryId: p.categoryId || null
+            categoryId: p.categoryId || null,
+            // 营养信息回显:null=未配置,保持空值让管理端显示为空
+            caloriesKcal: p.caloriesKcal != null ? Number(p.caloriesKcal) : null,
+            proteinG: p.proteinG != null ? Number(p.proteinG) : null,
+            fatG: p.fatG != null ? Number(p.fatG) : null,
+            carbG: p.carbG != null ? Number(p.carbG) : null,
+            fiberG: p.fiberG != null ? Number(p.fiberG) : null,
+            nutritionTags: p.nutritionTags || ''
           }
           if (p.images) {
             try {
@@ -1090,6 +1130,12 @@ async loadGiftRule(productId) {
       this.imageList.splice(idx, 1)
       this.form.images = JSON.stringify(this.imageList)
     },
+    // 营养数值输入归一化:空值/非法值统一为 null(未配置)
+    toNutritionNumber(value) {
+      if (value === null || value === undefined || value === '') return null
+      var n = Number(value)
+      return isNaN(n) ? null : n
+    },
     async handleSave() {
       this.$refs.form.validate(async (valid) => {
         if (!valid) return
@@ -1100,7 +1146,14 @@ async loadGiftRule(productId) {
             price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
             memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
             purchasePrice: (this.form.purchasePrice != null && this.form.purchasePrice > 0) ? Math.round(this.form.purchasePrice * 100) : 0,
-            images: JSON.stringify(this.imageList)
+            images: JSON.stringify(this.imageList),
+            // 营养数据:空值统一转 null(=未配置),避免发送空串导致后端反序列化失败
+            caloriesKcal: this.toNutritionNumber(this.form.caloriesKcal),
+            proteinG: this.toNutritionNumber(this.form.proteinG),
+            fatG: this.toNutritionNumber(this.form.fatG),
+            carbG: this.toNutritionNumber(this.form.carbG),
+            fiberG: this.toNutritionNumber(this.form.fiberG),
+            nutritionTags: (this.form.nutritionTags || '').trim()
           }
           // Extract dimensionWeights before sending to product API
           const dimensionWeights = productData.dimensionWeights
@@ -1191,14 +1244,22 @@ async loadGiftRule(productId) {
                 console.warn('保存线上服务配置失败', e)
               }
             }
-            // Save bundle items config(套餐商品组成)
-            if (productId) {
+            // Save 套餐内容(新模型:内容挂套餐SKU级 product_combo_items,锚点=首个套餐SKU)
+            if (productId && this.form.productType === 'bundle') {
               try {
-                var bundlePayload = this.buildBundleItemPayload()
-                await saveBundleItems(productId, bundlePayload)
+                var skuRes = await getProductSkuList(productId)
+                var skus = (skuRes && skuRes.code === 200 && Array.isArray(skuRes.data)) ? skuRes.data : []
+                if (!skus.length) {
+                  if (this.bundleItems.length) {
+                    this.$message.warning('请先添加套餐SKU(作为套餐售价/库存锚点),再保存套餐内容')
+                  }
+                } else {
+                  var bundlePayload = this.buildBundleItemPayload()
+                  await saveComboItems(skus[0].id, bundlePayload)
+                }
               } catch (e) {
-                this.$message.error('保存套餐组成失败')
-                console.warn('保存套餐组成失败', e)
+                this.$message.error('保存套餐内容失败')
+                console.warn('保存套餐内容失败', e)
               }
             }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
@@ -1251,6 +1312,8 @@ async loadGiftRule(productId) {
   gap: 8px;
   flex-wrap: wrap;
 }
+.bundle-item-name { font-size: 13px; }
+.bundle-item-spec { font-size: 12px; color: var(--adm-text-45); }
 .sp-opt-name {
   float: left;
 }

+ 17 - 188
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -8,10 +8,9 @@
         <div class="adm-table-wrap">
 <el-table :data="effectiveSkus" border stripe size="small" height="100%">
       <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column label="规格/组合" min-width="160">
+      <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>
+          <span>{{ parseSpecs(row.specs) }}</span>
         </template>
       </el-table-column>
       <el-table-column label="售价(元)" width="100">
@@ -49,7 +48,6 @@
       <el-form ref="skuForm" :model="skuForm" :rules="skuRules" label-width="80px">
         <el-radio-group v-model="skuMode" size="small" class="mb-16">
           <el-radio-button label="spec">规格变体</el-radio-button>
-          <el-radio-button label="combo">商品组合</el-radio-button>
         </el-radio-group>
 
         <!-- 规格变体模式(原有) -->
@@ -85,37 +83,6 @@
         </el-form-item>
         </template>
 
-        <!-- 商品组合模式 -->
-        <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>
 
         <el-form-item label="启用">
           <el-switch v-model="skuForm.enabled" :active-value="1" :inactive-value="0" />
@@ -127,15 +94,6 @@
       </span>
     </el-dialog>
 
-    <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"
-    />
 
     <!-- 素材库选择器 -->
     <AssetPicker
@@ -150,13 +108,11 @@
 
 <script>
 import { getProductSkuList, createProductSku, updateProductSku, deleteProductSku } from '@/api/admin'
-import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'
-import ProductPicker from './ProductPicker.vue'
 import AssetPicker from '@/components/AssetPicker.vue'
 
 export default {
   name: 'ProductSkuEditor',
-  components: { ProductPicker, AssetPicker },
+  components: { AssetPicker },
   props: {
     productId: {
       type: Number,
@@ -173,7 +129,6 @@ export default {
       saving: false,
       editingId: null,
       skuMode: 'spec',
-      comboItems: [],
       skuForm: {
         specs: '',
         price: 0,
@@ -246,65 +201,20 @@ export default {
     showDialog(sku) {
       if (sku) {
         this.editingId = sku.id
-        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
-        }))
-        if (sku.linkedProductId != null && !comboRaw.length) {
-          // 旧「关联商品」SKU → 转换为单条商品组合(保留关联,不丢数据)
-          this.skuMode = 'combo'
-          this.comboItems = [{
-            childProductId: sku.linkedProductId,
-            childSkuId: null,
-            quantity: 1,
-            name: sku.linkedProductName || ('商品#' + sku.linkedProductId),
-            coverImage: sku.linkedProductImage || '',
-            price: sku.linkedProductPrice || 0,
-            childSkuSpecs: ''
-          }]
-        } else {
-          this.skuMode = comboRaw.length ? 'combo' : 'spec'
-        }
-        if (this.skuMode === 'spec') {
-          this.skuForm = {
-            specs: this.formatSpecsForInput(sku.specs),
-            price: sku.price ? (sku.price / 100) : 0,
-            originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
-            stock: sku.stock || 0,
-            image: sku.image || '',
-            enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
-            label: '',
-            linkedProductId: null,
-            linkedProductName: '',
-            linkedProductPrice: 0,
-            linkedProductImage: '',
-            linkedProductStock: 0
-          }
-        } else {
-          this.skuForm = {
-            label: sku.label || '',
-            price: sku.price ? (sku.price / 100) : 0,
-            stock: sku.stock || 0,
-            enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
-            specs: '',
-            originalPrice: 0,
-            image: '',
-            linkedProductId: null,
-            linkedProductName: '',
-            linkedProductPrice: 0,
-            linkedProductImage: '',
-            linkedProductStock: 0
-          }
-          if (sku.linkedProductId != null && !comboRaw.length) {
-            if (!this.skuForm.price) this.skuForm.price = (sku.linkedProductPrice || 0) / 100
-            if (!this.skuForm.stock) this.skuForm.stock = sku.linkedProductStock || 0
-          }
+        this.skuMode = 'spec'
+        this.skuForm = {
+          specs: this.formatSpecsForInput(sku.specs),
+          price: sku.price ? (sku.price / 100) : 0,
+          originalPrice: sku.originalPrice ? (sku.originalPrice / 100) : 0,
+          stock: sku.stock || 0,
+          image: sku.image || '',
+          enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
+          label: '',
+          linkedProductId: null,
+          linkedProductName: '',
+          linkedProductPrice: 0,
+          linkedProductImage: '',
+          linkedProductStock: 0
         }
       } else {
         this.editingId = null
@@ -314,7 +224,6 @@ export default {
     },
     resetForm() {
       this.skuMode = 'spec'
-      this.comboItems = []
       this.skuForm = {
         specs: '',
         price: 0,
@@ -437,74 +346,7 @@ export default {
       if (!val) return '¥0.00'
       return '¥' + (parseFloat(val) / 100).toFixed(2)
     },
-    openProductPicker() {
-      this.$refs.productPicker.open()
-    },
-    onProductSelected(product) {
-      this.skuForm.linkedProductId = product.id
-      this.skuForm.linkedProductName = product.name
-      this.skuForm.linkedProductPrice = product.price
-      this.skuForm.linkedProductImage = product.coverImage || product.image || ''
-      this.skuForm.linkedProductStock = product.stock
-    },
-    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
-      }))
-    },
     async handleSave() {
-      // 商品组合模式:手填售价 + 组合明细(多商品+数量),不校验 specs
-      if (this.skuMode === 'combo') {
-        if (!this.comboItems.length) { this.$message.warning('请至少添加一个组合商品'); return }
-        if (!(this.skuForm.price > 0)) { this.$message.warning('请填写组合售价'); return }
-        this.saving = true
-        try {
-          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
-          }
-          if (!this.productId) {
-            payload.id = Date.now()
-            delete payload.productId
-            this.pendingSkus.push(payload)
-            this.dialogVisible = false
-            this.$message.success('添加成功,保存商品时将自动创建SKU')
-            this.saving = false
-            return
-          }
-          let res
-          if (this.editingId) {
-            payload.id = this.editingId
-            delete payload.productId
-            res = await updateProductSku(payload)
-          } else {
-            res = await createProductSku(payload)
-          }
-          if (res.code === 200) {
-            await this.loadSkus()
-            this.$message.success('保存成功')
-            this.dialogVisible = false
-          } else {
-            this.$message.error(res.message || '保存失败')
-          }
-        } catch (e) {
-          this.$message.error('保存失败')
-        } finally {
-          this.saving = false
-        }
-        return
-      }
       this.$refs.skuForm.validate(async (valid) => {
         if (!valid) return
         this.saving = true
@@ -517,7 +359,6 @@ export default {
             stock: this.skuForm.stock,
             image: this.skuForm.image,
             enabled: this.skuForm.enabled,
-            comboItems: [],
             linkedProductId: null
           }
           // 新建商品时productId为空,暂存到本地
@@ -617,17 +458,5 @@ export default {
 .sku-thumb { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; }
 .text-muted { color: var(--adm-text-45); }
 .mb-16 { margin-bottom: 16px; }
-.linked-card { margin-top: 8px; display: flex; align-items: center; gap: 10px; padding: 8px; background: var(--adm-bg-header); border-radius: 4px; }
-.linked-thumb { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; }
-.linked-info { font-size: 12px; color: var(--adm-text-45); }
 .full-width { width: 100%; }
-.combo-list { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
-.combo-row { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--adm-bg-header); border-radius: 4px; }
-.combo-thumb { width: 46px; height: 46px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
-.combo-thumb-placeholder { background: #f0f0f0; display: inline-block; }
-.combo-main { flex: 1; min-width: 0; font-size: 13px; }
-.combo-meta { font-size: 12px; color: var(--adm-text-45); display: flex; gap: 8px; }
-.combo-qty { width: 90px; }
-.combo-remove { color: #f56c6c; }
-.combo-empty { margin-top: 8px; padding: 12px; text-align: center; color: var(--adm-text-45); background: var(--adm-bg-header); border-radius: 4px; font-size: 12px; }
 </style>

File diff ditekan karena terlalu besar
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 0 - 55
docs/superpowers/api/API_REFERENCE.md

@@ -2074,61 +2074,6 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 
 ---
 
-### 4.58 团队服务指派(`SupplyTeamController`,前缀 `/api/admin/supply-team`)—— T34
-
-> **T34 规格 §3.4**:把服务性内容(售后/咨询/活动支持)指派给团队成员跟进;成员在「服务任务」中查看并推进状态(PENDING → PROCESSING → DONE)。售后建单时自动生成一条 `AFTER_SALE` 待指派任务(幂等:同一售后单不重复生成)。
-
-**数据表:** `supply_service_assignment`(迁移438,见 schema.sql)。字段:`system_id`(团队体系ID)/ `customer_relation_id`(客户归属关系ID,可空)/ `assignee_user_id`(被指派成员,可空=待指派)/ `service_type`(AFTER_SALE|CONSULT|ACTIVITY_SUPPORT)/ `ref_order_id` / `ref_request_id`(关联订单/售后单)/ `status`(PENDING|PROCESSING|DONE)/ `assigned_by` / `assigned_at` / `done_at` / `note`。
-
-**权限规则:**
-- 指派/候选人列表:当前用户须为体系 admin/成员(`supplySystemService.isMemberOrAdmin`);
-- 任务列表 `scope=team`:须为体系成员/管理员;`scope=mine`(默认):仅返回派给当前用户的任务;
-- 状态更新:被指派成员本人 或 团队管理员/成员。
-
-| 路径 | 说明 |
-|---|---|
-| `POST /api/admin/supply-team/service-assign` | 服务指派(body: `{systemId, customerRelationId?, assigneeUserId, serviceType, refOrderId?, refRequestId?, note?}`;serviceType 白名单 AFTER_SALE/CONSULT/ACTIVITY_SUPPORT;assigneeUserId 须为团队成员或商品服务人) |
-| `POST /api/admin/supply-team/service-tasks` | 服务任务列表(body: `{systemId?, scope?, status?, page?, size?}`,scope: mine/team,status: PENDING/PROCESSING/DONE,返回 `[{id, systemId, customerRelationId, assigneeUserId, serviceType, refOrderId, refRequestId, status, assignedBy, assignedAt, doneAt, note, familyId?, assigneeNickname?, assigneeAvatar?}]`) |
-| `POST /api/admin/supply-team/customer/{id}/services` | 某客户归属关系的全部服务记录(body: `{}`,path `{id}`=supply_customer_relation.id;须为体系成员/管理员) |
-| `POST /api/admin/supply-team/service-task/update` | 更新任务状态(body: `{id, status, note?}`,status 须为 PENDING/PROCESSING/DONE) |
-| `POST /api/admin/supply-team/service-candidates` | 指派候选人列表(body: `{systemId}`;团队成员(active) + 商品服务人(enabled),含 userId/nickname/avatar/source[MEMBER|SERVICE_PERSON]) |
-
-**售后自动联动:** `AfterSalesService.create()` 成功后调用 `SupplyServiceAssignmentService.createFromAfterSales(order, requestId)`,生成 `serviceType=AFTER_SALE`、`status=PENDING`、`assigneeUserId=product_orders.service_person_id`(可为空=待指派)的任务。
-
-*文档更新:2026-11-01(T34 团队服务指派:service_assignment 表、SupplyTeamController 5 个接口、售后自动联动)*
-
----
-
-### 4.59 供应商团队客户(`POST /api/admin/supply-team/customers`)—— T31/T32
-
-> **T31** 提供「我的团队」客户归属真实总数;**T32 修订**:客户列表每条额外返回**客户维度**购买明细(关联订单数 / 关联金额 / 最近订单),替代早期错误的 SKU 聚合口径。规格:`docs/superpowers/specs/2026-11-01-team-system-design.md` §3.2。
-
-**请求(body):** `{ systemId, parentId?, page?, size? }`(默认 `page=1, size=20`)
-
-**权限:** 当前用户须为该体系 admin/成员(`supplySystemService.isMemberOrAdmin(systemId, userId)`)。
-
-**返回:** `Result<{ list, total }>`;`list` 每项:
-
-| 字段 | 类型 | 说明 |
-|------|------|------|
-| `id` | Long | supply_customer_relation.id |
-| `familyId` | Long | 关联家庭ID |
-| `familyName` | String | 家庭显示名(首选登录成员昵称,未知家庭为「未知家庭」) |
-| `memberType` | String | `login`/`local` |
-| `loginMemberCount` | Long | 家庭下能登录用户数量 |
-| `buyerNickname` | String | 下单用户昵称(有 childUserId 时) |
-| `source` | String | 归属来源:manual/order/referral |
-| `createdAt` | Date | 加入时间 |
-| `orderCount` | Integer | 关联订单数(`product_orders` 中 `supplySystemId=? AND (buyerId=? OR familyId=?) AND fulfillStatus='completed'`,按 `orderNo` 去重) |
-| `totalAmountYuan` | BigDecimal | 关联金额(元,sum(totalAmount)/100,2 位 HALF_UP) |
-| `recentOrders` | Array | 最近 1-3 笔订单(按 paidAt 降序):`[{ orderNo, productName, amountYuan, paidAt }]`,`paidAt` 为 ISO 8601 `yyyy-MM-dd'T'HH:mm:ss` |
-
-> **口径提示(既有缺口,T33 范畴)**:`ProductOrderService` 下单时未写 `supply_system_id`,且 `fulfillStatus` 仅写 `pending/confirmed_by_vendor/received_by_user`(未写过 `'completed'`,完成态用 `status='completed'` 表示)。因此按规格字面 `fulfillStatus='completed'` 查询当前可能命中 0 行——属数据口径现状,未擅自修改。
-
-*文档更新:2026-11-01(T32 修订:客户维度购买明细 orderCount/totalAmountYuan/recentOrders)*
-
----
-
 ## 4.48 组织管理(机构/企业组织)
 
 平台角色(admin/operator/finance)可管理所有组织并查看全量统计;组织管理员(`users.roles` 含 `org_admin`)登录管理端后默认跳转自身组织统计大屏,仅能访问本组织数据。实现方案见规格 `docs/superpowers/specs/2026-10-22-org-admin-management-design.md`。

+ 2 - 4
docs/superpowers/specs/2026-11-01-team-product-performance-design.md

@@ -1,10 +1,8 @@
 # 团队页「关联订单 / 关联金额(元)」按商城商品-SKU 维度统计接口
 
-> **⚠️ 已废弃(2026-11-01)**:本规格前提有误——需求实际是**客户维度**购买明细(每个客户的关联订单数 / 关联金额 / 最近订单),而非 SKU 聚合。已由 `docs/superpowers/specs/2026-11-01-team-system-design.md` §3.2(T32 修订)取代,实现见 `SupplyCustomerRelationService.listCustomers()` 与 `POST /api/admin/supply-team/customers`。本文档仅作历史留档,**不得按此实现**。
-
 - 日期:2026-11-01
-- 状态:**已废弃**(前提错误,由 `2026-11-01-team-system-design.md` §3.2 取代)
-- 优先级:P1(已作废)
+- 状态:待派发(已入 tasks_backlog.json,等 hub 派发)
+- 优先级:P1
 - 模块:cfc-backend(仅后端接口,不动前端)
 - 需求方:廖总
 - 需求原文:小程序「我的团队」页(promotion/team.vue)要展示「关联订单」「关联金额(元)」,按商城商品/SKU 维度;本次只补充后台接口,前端页不动。

+ 2 - 2
playwright_smoke.log

@@ -50,7 +50,7 @@ Running 58 tests using 1 worker
 [44/58] [desktop] › e2e\api\nonfunc-perf-compat.spec.js:92:1 › [TC-P-N02/X-N01] 并发不串数据:并行 AI 请求各自独立响应
 [45/58] [desktop] › e2e\api\nonfunc-perf-compat.spec.js:106:1 › [响应时间基线] 已登录家庭列表单请求耗时冒烟 ≤ 8s
 [desktop] › e2e\api\nonfunc-perf-compat.spec.js:106:1 › [响应时间基线] 已登录家庭列表单请求耗时冒烟 ≤ 8s
-[耗时] 家庭列表 128ms
+[耗时] 家庭列表 103ms
 
 [46/58] [desktop] › e2e\api\nonfunc-perf-compat.spec.js:117:6 › [TC-C-N01/X-N04] 任务打卡幂等:重复提交不重复计分(需任务流夹具)
 [47/58] [desktop] › e2e\api\nonfunc-perf-compat.spec.js:122:6 › [TC-X-N03] 活动名额并发不超卖:100 名额 / 瞬时 500 请求(需活动夹具 + 压测授权)
@@ -107,4 +107,4 @@ Running 58 tests using 1 worker
     [desktop] › e2e\api\nonfunc-security.spec.js:75:3 › 非功能/安全接口冒烟 › [TC-A-N05] 非管理员访问后台接口被拒(401/403) 
     [desktop] › e2e\api\nonfunc-security.spec.js:121:3 › 非功能/安全接口冒烟 › [TC-P-N11] 报告上传非 PDF/恶意内容被拒(非 200) 
   5 skipped
-  51 passed (53.3s)
+  51 passed (1.0m)

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini