Przeglądaj źródła

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

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

+ 1 - 1
INTERFACE_TEST_REPORT.md

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

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

@@ -3406,14 +3406,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移437: T35 活动管理团队 —— activities.team_id + activity_team/activity_team_member/service_assignment 表
         migrateActivityTeam();
-        // 迁移440: 套餐内容明细表(套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级)
-        migrateProductComboItems();
 
-        // 迁移441: 删除旧 SKU 组合明细表(新模型:套餐内容在 product_combo_items,0数据安全删)
-        dropTableSkuCombo();
-
-        // 迁移442: 删除旧 bundle 机制明细表(新模型:套餐=独立商品,内容在 product_combo_items,0数据安全删)
-        dropTableBundleItems();
+        // 迁移438: 创建 supply_service_assignment 表(团队服务指派,规格 2026-11-01-team-system-design.md §3.4 / T34)
+        migrateSupplyServiceAssignment();
     }
 
     /**
@@ -3620,52 +3615,62 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
     }
 
     /**
-     * 迁移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 dropTableSkuCombo() {
-        try {
-            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。
+     * 迁移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>
      */
-    private void dropTableBundleItems() {
+    private void migrateSupplyServiceAssignment() {
         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());
+            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());
         }
     }
 
@@ -15038,7 +15043,19 @@ 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) {

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

@@ -1,8 +1,7 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.ProductComboItem;
-import com.etotem.cfc.service.ComboService;
+import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.service.InventoryService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,8 +21,6 @@ public class AdminInventoryController {
 
     @Resource
     private InventoryService inventoryService;
-    @Resource
-    private ComboService comboService;
 
     // ========== 入库单 ==========
 
@@ -153,31 +150,32 @@ public class AdminInventoryController {
 
     // ========== 套餐商品 ==========
 
-    @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不能为空");
+    @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不能为空");
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
-        try {
-            comboService.saveComboItems(comboSkuId, items);
-            return Result.success("ok");
-        } catch (IllegalArgumentException e) {
-            return Result.error(e.getMessage());
-        }
+        return inventoryService.saveBundleItems(bundleProductId, items);
     }
 
-    @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());
-        }
+    @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);
+    }
+
+    @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);
     }
 
     // ========== 手动出库 ==========

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

@@ -6,6 +6,7 @@ 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;
@@ -25,6 +26,9 @@ 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;
@@ -66,8 +70,19 @@ 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());

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

@@ -1,7 +1,9 @@
 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.*;
 
@@ -20,6 +22,9 @@ public class SupplyTeamController {
     @Resource
     private SupplySystemService supplySystemService;
 
+    @Resource
+    private SupplyServiceAssignmentService supplyServiceAssignmentService;
+
     /**
      * 查询上级供应商下的客户列表
      */
@@ -91,4 +96,90 @@ 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));
+    }
 }

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

@@ -3,7 +3,6 @@ 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;
@@ -12,7 +11,6 @@ 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;
@@ -68,32 +66,4 @@ 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;
-    }
 }

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

@@ -5,6 +5,7 @@ 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;
@@ -119,6 +120,47 @@ 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) {

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

@@ -20,6 +20,40 @@ 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 {
         /** 送会员商品原价合计(分) */
@@ -36,4 +70,33 @@ 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;
+    }
 }

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

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

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

@@ -4,7 +4,6 @@ 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;
@@ -33,18 +32,6 @@ 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=全部可见
@@ -78,11 +65,11 @@ public class ProductDTO {
     // 配送与履约配置(按 deliveryMethod 附带 pickupLocations/servicePersons/onlineSlots)
     private Map<String, Object> deliveryConfig;
 
-    /** 套餐内容(新模型:套餐=独立商品,内容=子商品SKU×数量,挂套餐SKU级) */
-    private List<ComboItemVO> comboItems;
+    // 套餐商品组成(仅 productType=bundle 时非空)
+    private List<BundleItemVO> bundleItems;
 
     @Data
-    public static class ComboItemVO {
+    public static class BundleItemVO {
         private Long childProductId;
         private String childProductName;
         private String coverImage;
@@ -134,12 +121,6 @@ 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();

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

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

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

@@ -22,6 +22,7 @@ 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;
@@ -75,6 +76,7 @@ public class ProductSkuDTO {
         dto.setLinkedProductPrice(sku.getLinkedProductPrice());
         dto.setLinkedProductImage(sku.getLinkedProductImage());
         dto.setLinkedProductStock(sku.getLinkedProductStock());
+        dto.setComboItems(sku.getComboItems());
         return dto;
     }
 }

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

@@ -45,24 +45,6 @@ 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;
 

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

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

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

@@ -9,6 +9,12 @@ 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 {
@@ -16,22 +22,25 @@ public class ProductComboItem implements Serializable {
     @TableId(type = IdType.AUTO)
     private Long id;
 
-    /** 套餐SKU ID(内容挂套餐SKU级) */
+    /** 套餐 SKU id(内容归属) */
     private Long comboSkuId;
-    /** 子商品ID */
+
+    /** 子商品 */
     private Long childProductId;
-    /** 子商品SKU ID(精确到规格,必填) */
+
+    /** 子商品 SKU(必填,精确到规格) */
     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;
-}
+}

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

@@ -31,6 +31,9 @@ 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)

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

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

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

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

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

@@ -0,0 +1,9 @@
+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> {
-}
+}

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

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

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

@@ -0,0 +1,9 @@
+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> {
+}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -6,6 +6,7 @@ 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;
@@ -24,6 +25,9 @@ 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) {
@@ -60,6 +64,10 @@ 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);
     }
 

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

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

+ 219 - 132
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,7 +25,6 @@ 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;
@@ -42,6 +41,9 @@ public class CartService {
     @Resource
     private ProductMapper productMapper;
 
+    @Resource
+    private ProductBundleItemMapper bundleItemMapper;
+
     @Resource
     private ProductSkuService productSkuService;
 
@@ -52,10 +54,10 @@ public class CartService {
     private SysConfigService sysConfigService;
 
     @Resource
-    private MemberPriceResolver memberPriceResolver;
+    private BundlePricingService bundlePricingService;
 
     @Resource
-    private ComboOfferService comboOfferService;
+    private MemberPriceResolver memberPriceResolver;
 
     @Transactional
     public CartItemDTO addToCart(Long userId, Long productId, Integer quantity, Long skuId) {
@@ -124,17 +126,24 @@ 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;
     }
 
@@ -143,6 +152,144 @@ 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>()
@@ -211,7 +358,11 @@ public class CartService {
         if (cart.getSkuId() != null) {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
             if (sku != null) {
-                if (sku.getLinkedProductId() != 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) {
                     Product linkedProduct = productMapper.selectById(sku.getLinkedProductId());
                     if (linkedProduct != null) {
                         dto.setProductId(linkedProduct.getId());
@@ -278,132 +429,31 @@ 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);
-    }
-
     /**
-     * 一键替换为套餐:后端按勾选行 (productId, skuId) 重算组数(忽略前端传入),
-     * 逐行扣减子商品数量(不足整体失败回滚),删除数量为0的行,upsert 套餐 SKU 行(存在则合并数量)。
+     * 订单套餐自动定价优惠:扫描购物车商品,按下单结算口径(会员/SKU/linked 价)聚合,
+     * 匹配 product_bundle_items 中 bundle_sku_id 非空的定价套餐,贪心选省最多,返回 bundlePromo。
+     * 展示口径与下单一致(MemberPriceResolver),保证页面提示=实际扣款。
      */
-    @Transactional
-    public Result<Void> replaceCartWithCombo(Long userId, List<Long> checkedCartItemIds, Long comboSkuId) {
-        if (checkedCartItemIds == null || checkedCartItemIds.isEmpty()) {
-            return Result.error("请选择要替换为套餐的购物车商品");
-        }
-        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);
+    private CartItemDTO.BundlePromo computeBundlePromo(List<Cart> cartList, Map<Long, Product> productMap, Long userId) {
+        if (cartList == null || cartList.isEmpty()) {
+            return emptyBundlePromo();
         }
-        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;
-                }
+        List<LineItem> lines = new ArrayList<>();
+        for (Cart cart : cartList) {
+            if (cart == null || cart.getProductId() == null || cart.getQuantity() == null || cart.getQuantity() <= 0) {
+                continue;
             }
-            if (remaining > 0) {
-                throw new RuntimeException("购物车商品数量不足,替换套餐失败");
+            Product product = productMap.get(cart.getProductId());
+            if (product == null) {
+                continue;
             }
-        }
-        // 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;
+            ProductSku sku = null;
+            if (cart.getSkuId() != null) {
+                sku = productSkuService.getById(cart.getSkuId());
+            }
+            // 解析后身份 + 下单口径单价(linked 转发商品、SKU 价、否则会员价)
             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) {
@@ -415,11 +465,48 @@ public class CartService {
             }
             lines.add(new LineItem(resolvedProductId, cart.getSkuId(), cart.getQuantity(), unitPrice));
         }
-        return lines;
+        if (lines.isEmpty()) {
+            return emptyBundlePromo();
+        }
+        BundleDiscountResult result = bundlePricingService.compute(lines);
+        return toBundlePromo(result);
     }
 
-    private String offerKey(Long productId, Long skuId) {
-        return productId + "_" + (skuId == null ? "" : skuId);
+    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;
     }
-}
 
+    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;
+    }
+}

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

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

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

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

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

@@ -26,6 +26,8 @@ public class InventoryService {
     @Resource
     private InventoryCountingRecordMapper countingRecordMapper;
     @Resource
+    private ProductBundleItemMapper bundleItemMapper;
+    @Resource
     private ProductMapper productMapper;
     @Resource
     private ProductSkuMapper productSkuMapper;
@@ -432,9 +434,83 @@ 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

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

@@ -12,9 +12,11 @@ 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;
@@ -40,7 +42,10 @@ 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;
@@ -94,9 +99,15 @@ 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;
 
@@ -235,6 +246,13 @@ 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()) {
@@ -258,7 +276,12 @@ public class ProductOrderService {
         order.setPointsUsed(dto.getPointsUsed() != null ? dto.getPointsUsed() : 0);
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
-        int discountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0);
+        // 套餐自动定价优惠(服务端权威):按下单最终单价(含履约覆盖价)计算;前端 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;
         // 优惠券抵扣:校验并计入优惠金额(orderType=PRODUCT)
         Long appliedCouponId = null;
         if (dto.getCouponId() != null) {
@@ -390,6 +413,12 @@ 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() + "件");
@@ -727,9 +756,16 @@ 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());
@@ -737,21 +773,44 @@ public class ProductOrderService {
             if (!"on_shelf".equals(product.getStatus())) {
                 return Result.error("商品已下架: " + product.getName());
             }
-            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查
-            if ("physical".equals(product.getProductType())
+            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查(组合商品按子商品校验)
+            if (!comboItem && "physical".equals(product.getProductType())
                     && product.getStock() != null
                     && product.getStock() < item.getQuantity()) {
                 return Result.error("库存不足: " + product.getName());
             }
-            // 套餐商品:校验套餐SKU库存(只扣套餐SKU,不扣子商品)
-            if ("bundle".equals(product.getProductType())
-                    && itemSku != null
-                    && !productSkuService.hasStock(itemSku.getId(), 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()));
+                        }
+                    }
+                }
             }
             int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
             if (itemSku != null) {
-                if (itemSku.getLinkedProductId() != null) {
+                if (comboItem) {
+                    if (itemSku.getPrice() != null) unitPrice = itemSku.getPrice();
+                    if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
+                        return Result.error(product.getName() + " 该组合最低需购买" + itemSku.getMinQuantity() + "件");
+                    }
+                } else if (itemSku.getLinkedProductId() != null) {
                     // Linked SKU: 转发到关联商品
                     Product linkedProduct = productMapper.selectById(itemSku.getLinkedProductId());
                     if (linkedProduct != null) {
@@ -788,11 +847,14 @@ public class ProductOrderService {
             itemRecord.setSkuId(item.getSkuId());
             itemRecord.setProductName(product.getName());
             itemRecord.setProductImage(product.getCoverImage());
-            itemRecord.setSkuSpecs(item.getSpecOptionIds());
+            itemRecord.setSkuSpecs(comboItem
+                    ? productSkuService.buildComboSummary(itemSku.getComboItems())
+                    : 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);
@@ -801,7 +863,7 @@ public class ProductOrderService {
         order.setProductId(firstProduct.getId());
         order.setProductName(firstProduct.getName() + "等多件");
         order.setProductType(firstProduct.getProductType());
-        order.setSkuId(firstItem.getSkuId());
+        order.setSkuId(comboSkuId != null ? comboSkuId : firstItem.getSkuId());
         order.setBuyerId(buyerId);
         order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
         order.setQuantity(totalQuantity);
@@ -812,7 +874,8 @@ public class ProductOrderService {
         int pointsCost = dto.getPointsUsed() != null ? dto.getPointsUsed() : 0;
         order.setPointsCost(pointsCost);
         // 套餐自动定价优惠(服务端权威):前端 discountAmount 仅含规则优惠,套餐部分由服务端重算加入
-        int multiDiscountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0);
+        int bundleDiscount = bundlePricingService.compute(bundleLines).getDiscount();
+        int multiDiscountAmount = (dto.getDiscountAmount() != null ? dto.getDiscountAmount() : 0) + bundleDiscount;
         // 优惠券抵扣:校验并计入优惠金额(orderType=PRODUCT)
         Long appliedCouponId = null;
         if (dto.getCouponId() != null) {
@@ -1016,7 +1079,9 @@ public class ProductOrderService {
         // 仅对实物商品扣减库存;虚拟商品/优惠券无库存概念
         Product paidProduct = productMapper.selectById(order.getProductId());
         ProductSku paidSku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
-        if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
+        if (productSkuService.hasComboItems(paidSku)) {
+            deductComboStock(paidSku, order, orderNo);
+        } else if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
             boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
             if (!stockOk) {
                 log.error("库存扣减失败: orderNo={}, productId={}, quantity={}",
@@ -1029,19 +1094,29 @@ public class ProductOrderService {
             // 记录出库流水
             inventoryService.recordOutbound(order.getProductId(), null, order.getQuantity(),
                     "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("套餐库存不足");
+        } 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);
+                }
             }
-            inventoryService.recordOutbound(order.getProductId(), paidSku.getId(), order.getQuantity(),
-                    "order", order.getId(), null, "订单支付:" + orderNo);
         }
         order.setStatus("paid");
         order.setTransactionId(transactionId);
@@ -1215,6 +1290,73 @@ 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));
@@ -1234,15 +1376,13 @@ public class ProductOrderService {
         releaseCouponForCancelledOrder(order.getCouponId());
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本,幂等 refId = 商品订单 ID)
         refundOrderPoints(order, "取消订单退还CF值:" + orderNo);
-        // Restore stock on cancel
-        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
-            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
-        } else {
+        // Restore stock on cancel(组合订单回补子商品,非组合回补父商品)
+        if (!restockComboOrder(order)) {
             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());
@@ -1687,13 +1827,11 @@ public class ProductOrderService {
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
-            productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
-        } else {
+        if (!restockComboOrder(order)) {
             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 账本)
@@ -1909,9 +2047,7 @@ public class ProductOrderService {
             return Result.error("该订单无法关闭");
         }
         if ("paid".equals(currentStatus) || "pending_receipt".equals(currentStatus)) {
-            if ("bundle".equals(order.getProductType()) && order.getSkuId() != null) {
-                productSkuService.increaseStock(order.getSkuId(), order.getQuantity());
-            } else {
+            if (!restockComboOrder(order)) {
                 productService.increaseStock(order.getProductId(), order.getQuantity());
             }
         }

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

@@ -14,11 +14,12 @@ 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,9 +72,10 @@ public class ProductService {
     private ProductDeliveryConfigService productDeliveryConfigService;
 
     @Resource
-    private ProductSkuMapper productSkuMapper;
+    private ProductBundleItemMapper bundleItemMapper;
+
     @Resource
-    private ComboService comboService;
+    private ProductSkuMapper productSkuMapper;
 
     @Resource
     private FiveDimensionScoreService fiveDimensionScoreService;
@@ -223,10 +225,6 @@ 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);
@@ -293,27 +291,28 @@ wrapper.in(Product::getId, productIds)
             return Result.error("商品未上架");
         }
         ProductDTO dto = ProductDTO.from(product);
-        // 套餐商品:套餐内容(新模型:内容=子商品SKU×数量,挂套餐SKU级;下单只扣套餐SKU库存)
+        // 套餐商品:填充套餐组成(子商品名称/封面/SKU规格/数量)
         if ("bundle".equals(product.getProductType())) {
-            // 套餐内容(新模型:内容=子商品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);
-                    }
+            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());
                 }
+                bundleItems.add(vo);
             }
-            dto.setComboItems(comboItems);
+            dto.setBundleItems(bundleItems);
         }
         ProductGiftRule giftRule = productGiftRuleService.getActiveByProductId(id);
         if (giftRule != null) {
@@ -620,23 +619,9 @@ 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
-                || clearCalories || clearProtein || clearFat || clearCarb || clearFiber
-                || clearNutritionTags) {
+        if (clearMinVisibleLevel || clearCategory || clearDistributionSystem) {
             LambdaUpdateWrapper<Product> clearWrapper = new LambdaUpdateWrapper<Product>()
                     .eq(Product::getId, product.getId());
             if (clearMinVisibleLevel) {
@@ -648,24 +633,6 @@ 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())));
@@ -764,12 +731,6 @@ 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);

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

@@ -1,8 +1,11 @@
 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;
@@ -26,18 +29,26 @@ 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;
     }
 
@@ -58,12 +69,18 @@ public class ProductSkuService {
         if (sku.getSkuCode() == null || sku.getSkuCode().isEmpty()) {
             sku.setSkuCode("SKU-" + sku.getProductId() + "-" + System.currentTimeMillis());
         }
+        validateComboItems(sku.getComboItems());
         skuMapper.insert(sku);
+        saveComboItems(sku.getId(), sku.getComboItems());
         return sku;
     }
 
     @Transactional
     public void update(ProductSku sku) {
+        validateComboItems(sku.getComboItems());
+        if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+            sku.setLinkedProductId(null);
+        }
         if (sku.getLinkedProductId() == null) {
             // Legacy SKU: 需要 specs
             if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
@@ -72,6 +89,7 @@ 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())
@@ -81,6 +99,8 @@ public class ProductSkuService {
 
     @Transactional
     public void delete(Long id) {
+        comboItemMapper.delete(new LambdaQueryWrapper<ProductSkuComboItem>()
+                .eq(ProductSkuComboItem::getSkuId, id));
         skuMapper.deleteById(id);
     }
 
@@ -216,4 +236,90 @@ 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();
+    }
 }

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

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

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

@@ -3,16 +3,21 @@ 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;
 
@@ -31,10 +36,14 @@ public class SupplyCustomerRelationService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
     /**
      * 查询某上级供应商下的客户列表
-     * 返回:{ list: [...], total: N } —— list 为分页后的客户(家庭ID、家庭名称、成员昵称、加入时间、归属来源),
-     *       total 为该体系/上级下的客户总数(团队数据「客户归属」的真实总数,T31)
+     * 返回:{ list: [...], total: N } —— list 为分页后的客户(家庭ID、家庭名称、成员昵称、加入时间、归属来源
+     *       + 客户维度购买情况 orderCount / totalAmountYuan / recentOrders),
+     *       total 为该体系/上级下的客户总数(团队数据「客户归属」的真实总数,T31;T32 修订:客户维度购买明细,非 SKU 聚合)
      */
     public Map<String, Object> listCustomers(Long systemId, Long parentId, Integer page, Integer size) {
         // parentId 为空时返回整个体系的客户列表(团队维度「团队客户」,T31);否则按指定上级过滤
@@ -52,6 +61,9 @@ 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<>();
@@ -93,6 +105,18 @@ 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<>();
@@ -101,6 +125,143 @@ 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);
+    }
+
     /**
      * 绑定客户归属关系(人工绑定)
      */

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

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

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

@@ -1180,12 +1180,6 @@ 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',
@@ -5631,17 +5625,30 @@ 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='系统提示词版本历史';
--- 套餐内容明细表(套餐=独立商品 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级',
+-- 套餐商品组成明细表(套餐 = 商品 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);空=仅出库联动',
     child_product_id BIGINT NOT NULL COMMENT '子商品ID',
-    child_sku_id BIGINT NOT NULL COMMENT '子商品SKU ID(精确到规格,必填)',
-    quantity INT NOT NULL DEFAULT 1 COMMENT '该子商品数量',
+    child_sku_id BIGINT DEFAULT NULL COMMENT '子SKU ID(为空时直接扣 product.stock)',
+    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×数量)';
+    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组合明细';
 
 -- 联系人表(cfclub 联系人管理)
 CREATE TABLE IF NOT EXISTS contacts (
@@ -6348,6 +6355,28 @@ 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',

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,149 +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.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());
-    }
-}

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

@@ -1,198 +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.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());
-    }
-}

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

@@ -6,8 +6,6 @@ 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.*;
 
 /**
@@ -28,42 +26,4 @@ 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());
-    }
 }

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

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

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

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

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

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

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

@@ -13,7 +13,6 @@ 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;
@@ -97,92 +96,4 @@ 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);
-    }
 }

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

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

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

@@ -1,44 +1,58 @@
 /**
  * Tests for cfc-frontend utils/bundle-pricing.js
  *
- * 验证购物车套餐offer展示辅助:comboOfferOf 命中/未激活语义;formatComboOfferText 提示文案;
- * 总价不再被自动扣减(纯展示,权威计算在后端 POST /api/cart/combo-offer)。
+ * 验证套餐优惠的纯函数语义:未命中/未激活 → 0;active=true + discount>0 才生效。
  */
 const {
-  comboOfferOf,
-  formatComboOfferText
+  bundleDiscountOf,
+  formatBundleLines,
+  applyBundleDiscount
 } = require('@/utils/bundle-pricing')
 
-describe('comboOfferOf', () => {
-  it('null/undefined → null', () => {
-    expect(comboOfferOf(null)).toBeNull()
-    expect(comboOfferOf(undefined)).toBeNull()
+describe('bundleDiscountOf', () => {
+  it('null/undefined → 0', () => {
+    expect(bundleDiscountOf(null)).toBe(0)
+    expect(bundleDiscountOf(undefined)).toBe(0)
   })
-  it('active 非 true → null', () => {
-    expect(comboOfferOf({ active: false, comboName: '套餐X' })).toBeNull()
-    expect(comboOfferOf({})).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 → 返回 offer(纯展示,不篡改总价)', () => {
-    const offer = { active: true, comboName: '套餐X', comboPrice: 15000, saving: 2000, groups: 1 }
-    expect(comboOfferOf(offer)).toBe(offer)
+  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)
   })
 })
 
-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('省')
+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)
   })
 })

+ 6 - 0
cfc-frontend/pages.json

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

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

@@ -125,20 +125,6 @@
         </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" />
@@ -149,9 +135,9 @@
         <mp-html :content="product.description" :domain="apiBaseUrl" :tag-style="contentTagStyle" />
       </view>
 
-      <!-- 套餐内含清单(bundle商品展示子商品SKU×数量) -->
+      <!-- 套餐包含(bundle商品展示子商品组成) -->
       <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">
@@ -385,40 +371,6 @@ 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) {
@@ -561,7 +513,7 @@ export default {
         that.loading = false
         if (res.code === 200 && res.data) {
           that.product = res.data
-          that.bundleItems = (res.data && res.data.comboItems) || []
+          that.bundleItems = (res.data && res.data.bundleItems) || []
           // 计算可得CF值:floor(原价/100) × pointsMultiplier
           if (res.data.price && res.data.pointsMultiplier) {
             that.cfEarnedValue = Math.floor(res.data.price / 100) * res.data.pointsMultiplier;
@@ -1082,47 +1034,6 @@ 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;

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

@@ -107,18 +107,27 @@
       <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>
@@ -148,8 +157,8 @@
 
 <script>
 import config from '@/config.js'
-import { getMyMembership, getGiftMembershipProgress, queryComboOffer, replaceCartWithCombo } from '@/utils/api.js'
-import { formatComboOfferText } from '@/utils/bundle-pricing.js'
+import { getMyMembership, getGiftMembershipProgress } from '@/utils/api.js'
+import { applyBundleDiscount } from '@/utils/bundle-pricing.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -164,8 +173,7 @@ export default {
       isMember: false,
       giftProgress: null,  // 赠会员累计进度
       giftPromo: null,     // 送会员一口价促销信息(已废弃,后端恒返回非激活)
-      comboOffer: null,    // 勾选子集套餐offer(后端权威下发,纯展示提示)
-      offerTimer: null     // 勾选变化防抖定时器
+      bundlePromo: null    // 订单套餐自动定价优惠(后端权威下发)
     }
   },
   computed: {
@@ -190,7 +198,8 @@ export default {
           total = total + (this.items[i].subtotal || this.items[i].unitPrice * this.items[i].quantity)
         }
       }
-      return total
+      // 套餐自动定价优惠(后端权威),命中则扣减
+      return applyBundleDiscount(total, this.bundlePromo)
     },
     originalTotalAmount() {
       var total = 0
@@ -202,13 +211,6 @@ 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++) {
@@ -221,7 +223,8 @@ export default {
           total = total + (price * this.items[i].quantity)
         }
       }
-      return total
+      // 套餐自动定价优惠(后端权威),命中则扣减
+      return applyBundleDiscount(total, this.bundlePromo)
     },
     hasGiftItems() {
       for (var i = 0; i < this.items.length; i++) {
@@ -294,44 +297,6 @@ 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' })
     },
@@ -358,8 +323,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() {
@@ -378,7 +343,6 @@ 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++) {
@@ -387,7 +351,6 @@ export default {
           break
         }
       }
-      this.scheduleOfferCheck()
     },
     increaseQty(item) {
       item.quantity = item.quantity + 1
@@ -469,6 +432,7 @@ 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) {
@@ -872,29 +836,4 @@ 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>

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

@@ -238,6 +238,24 @@
         </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">
@@ -486,6 +504,7 @@ 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'
@@ -538,6 +557,7 @@ export default {
       giftProgress: null,   // 赠会员累计进度
       discountInfo: null,
       giftPromo: null,       // 送会员一口价促销信息(已废弃,后端恒返回非激活)
+      bundlePromo: null,    // 订单套餐自动定价优惠(后端权威下发)
       taskId: null,          // 购买任务打卡:透传至支付页
       // 限时特价(扫码闪购):由 product-detail 经 storage 一次性带入,onLoad 消费后即清除
       checkoutSpecialDealId: null,
@@ -577,6 +597,10 @@ export default {
       if (!this.giftPromo || this.giftPromo.active !== true) return 0
       return this.giftPromo.discount || 0
     },
+    // 订单套餐自动定价优惠(后端权威),命中则展示与扣减
+    bundlePromoDiscount() {
+      return bundleDiscountOf(this.bundlePromo)
+    },
     // 限时特价(扫码闪购):特价单且活动禁止优惠券时隐藏优惠券选择区。
     // 活动信息未返回(加载中/失败)时默认视为不可用券(特价单保守处理)。
     isSpecialCouponDisabled() {
@@ -597,6 +621,7 @@ 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
     },
@@ -646,8 +671,10 @@ 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()

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

@@ -35,31 +35,63 @@
 
         <!-- 列表区域 -->
         <view class="customer-item" v-for="customer in filteredCustomers" :key="customer.id">
-          <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-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 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="source-tag" :class="'tag-' + (customer.source || 'default')">
+                <text>{{ getSourceTagLabel(customer.source) }}</text>
+              </view>
             </view>
           </view>
 
-          <view class="customer-right">
-            <view class="order-count">
-              <text>{{ customer.loginMemberCount || 0 }}</text>
-              <text>成员</text>
+          <!-- 客户维度购买情况(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>
+          </view>
 
-            <view class="source-tag" :class="'tag-' + (customer.source || 'default')">
-              <text>{{ getSourceTagLabel(customer.source) }}</text>
+          <!-- 下钻:最近购买明细(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>
           </view>
         </view>
@@ -79,7 +111,7 @@
 <script>
 import fontScaleMixin from '@/mixins/font-scale.js'
 import { getVendorTeamCustomers } from '@/utils/api.js'
-import { parseDate } from '@/utils/format.js'
+import { parseDate, formatDateTime } from '@/utils/format.js'
 export default {
   mixins: [fontScaleMixin],
   data() {
@@ -91,6 +123,7 @@ export default {
       loading: false,
       loadingMore: false,
       refreshing: false,
+      expandedCustomerIds: {}, // 已展开购买明细的客户 id 集合(T32)
 
       // 数据
       customers: [],         // 所有客户列表
@@ -148,6 +181,7 @@ 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++
@@ -169,6 +203,66 @@ 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
@@ -274,6 +368,10 @@ 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;
 }
@@ -378,6 +476,109 @@ 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;

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

@@ -0,0 +1,658 @@
+<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>

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

@@ -136,16 +136,40 @@
       </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 } from '@/utils/api.js'
+import { getVendorSystemInfo, getVendorHierarchyInfo, getVendorTeamMembers, getVendorTeamCustomers, getVendorTeamList, getServiceTasks } from '@/utils/api.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -163,7 +187,8 @@ export default {
       },
       teamMembers: [],
       teamCustomers: [],
-      customerTotal: 0   // 「客户归属」真实总数(后端 total,避免分页截断)
+      customerTotal: 0,  // 「客户归属」真实总数(后端 total,避免分页截断)
+      serviceTasks: []   // 服务指派任务列表(T34)
     }
   },
 
@@ -212,6 +237,7 @@ export default {
           this.hierarchyInfo = null
           this.teamMembers = []
           this.teamCustomers = []
+          this.serviceTasks = []
           this.updateStats()
           return
         }
@@ -219,11 +245,12 @@ export default {
         // 2. 并发加载当前团队数据(竞态防护:切换后旧响应不覆盖新团队)
         var seq = (this._teamLoadSeq || 0) + 1
         this._teamLoadSeq = seq
-        const [systemRes, hierarchyRes, membersRes, customersRes] = await Promise.all([
+        const [systemRes, hierarchyRes, membersRes, customersRes, servicesRes] = await Promise.all([
           this.fetchSystemInfo(),
           this.fetchHierarchyInfo(),
           this.fetchTeamMembers(),
-          this.fetchTeamCustomers()
+          this.fetchTeamCustomers(),
+          this.fetchServiceTasks()
         ])
         if (seq !== this._teamLoadSeq) return
 
@@ -240,6 +267,9 @@ export default {
           this.teamCustomers = customersRes.list || []
           this.customerTotal = customersRes.total || 0
         }
+        if (servicesRes) {
+          this.serviceTasks = servicesRes
+        }
 
         // 更新统计
         this.updateStats()
@@ -256,6 +286,7 @@ export default {
       this.hierarchyInfo = null
       this.teamMembers = []
       this.teamCustomers = []
+      this.serviceTasks = []
       this.loadTeamData()
     },
 
@@ -265,11 +296,12 @@ export default {
       var seq = (this._teamLoadSeq || 0) + 1
       this._teamLoadSeq = seq
       try {
-        const [systemRes, hierarchyRes, membersRes, customersRes] = await Promise.all([
+        const [systemRes, hierarchyRes, membersRes, customersRes, servicesRes] = await Promise.all([
           this.fetchSystemInfo(),
           this.fetchHierarchyInfo(),
           this.fetchTeamMembers(),
-          this.fetchTeamCustomers()
+          this.fetchTeamCustomers(),
+          this.fetchServiceTasks()
         ])
         if (seq !== this._teamLoadSeq) return
         if (systemRes) {
@@ -285,6 +317,9 @@ export default {
           this.teamCustomers = customersRes.list || []
           this.customerTotal = customersRes.total || 0
         }
+        if (servicesRes) {
+          this.serviceTasks = servicesRes
+        }
         this.updateStats()
       } catch (error) {
         console.error('加载团队数据失败:', error)
@@ -343,11 +378,34 @@ 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({
@@ -746,6 +804,68 @@ 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;
@@ -759,7 +879,7 @@ export default {
   box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.05);
 }
 
-.btn-manage, .btn-customers {
+.btn-manage, .btn-customers, .btn-services {
   flex: 1;
   height: 80rpx;
   line-height: 80rpx;
@@ -778,4 +898,9 @@ export default {
   color: $brand-primary;
   border: 2rpx solid $brand-primary;
 }
+
+.btn-services {
+  background-color: #EEF8FF;
+  color: #2F5496;
+}
 </style>

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

@@ -606,10 +606,6 @@ 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')
 }
@@ -1638,11 +1634,9 @@ export const vendorInfo = () => {
  * 获取供应商团队客户列表
  * 后端接口:POST /api/admin/supply-team/customers
  * 参数:systemId, parentId, page, size
- * 返回:records + hasMore
+ * 返回:{ list, total };list 每项含客户维度购买情况 orderCount / totalAmountYuan / recentOrders
+ * 规范入口:getVendorTeamCustomers(见本文件下方供应商团队区),请勿重复封装同一 URL。
  */
-export const getTeamCustomers = (params) => {
-  return request('/api/admin/supply-team/customers', 'POST', params)
-}
 
 //  幸福邻里部落 - 商品系统 
 
@@ -3486,6 +3480,27 @@ 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) =>

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

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

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-9ae1a698e39622f0b5292a9229901a3b3406903b
+fad43d8702b711cb9dc66087eca3f62a622a3c5f

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

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

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1936",
+  "version": "1.0.1933",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 0 - 30
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,36 +4,6 @@
 
 ---
 
-## v1.0.1936 (2026-11-01)
-
-### Bug 修复
-- 迁移编号统一为440/441/442(cfclub合入后与原437冲突顺延一致化)
-
-### 新功能
-- 购物车套餐「一键替换为套餐」提示(替代自动优惠)
-- 套餐内容编辑改挂商品级、SKU编辑器移除组合模式
-- 套餐下单仅扣套餐SKU库存(校验/扣减/回补,不扣子商品)
-- 购物车套餐offer检测(勾选子集/节省额>0)与一键替换接口
-- 套餐内容保存/列表接口(combo/items)含子SKU校验与详情清单挂载
-- 新增套餐内容表 product_combo_items(迁移437)与实体/Mapper
-
-### 重构
-- 删除bundle计价机制(迁移439删product_bundle_items+定价/库存/购物车/详情引用清理)
-- 移除SKU级组合机制(迁移438删表+库存/订单/编辑/详情引用清理)
-
-
-## v1.0.1935 (2026-11-01)
-
-### Bug 修复
-- 接口测试闭环提交/推送守卫,杜绝'假成功'(unmerged 时 commit 被拒仍报 push 成功)
-
-
-## v1.0.1934 (2026-11-01)
-
-### 测试
-- 接口冒烟闭环 2026-11-01 14:28 — 通过率 44% (fix=0 reg=0); 测试用例 360/387 完成(文档化202)
-
-
 ## v1.0.1933 (2026-11-01)
 
 ### 新功能

+ 1 - 31
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1936
+> 当前版本: v1.0.1933
 
 ## 历史版本
 
@@ -8,36 +8,6 @@
 
 ---
 
-## v1.0.1936 (2026-11-01)
-
-### Bug 修复
-- 迁移编号统一为440/441/442(cfclub合入后与原437冲突顺延一致化)
-
-### 新功能
-- 购物车套餐「一键替换为套餐」提示(替代自动优惠)
-- 套餐内容编辑改挂商品级、SKU编辑器移除组合模式
-- 套餐下单仅扣套餐SKU库存(校验/扣减/回补,不扣子商品)
-- 购物车套餐offer检测(勾选子集/节省额>0)与一键替换接口
-- 套餐内容保存/列表接口(combo/items)含子SKU校验与详情清单挂载
-- 新增套餐内容表 product_combo_items(迁移437)与实体/Mapper
-
-### 重构
-- 删除bundle计价机制(迁移439删product_bundle_items+定价/库存/购物车/详情引用清理)
-- 移除SKU级组合机制(迁移438删表+库存/订单/编辑/详情引用清理)
-
-
-## v1.0.1935 (2026-11-01)
-
-### Bug 修复
-- 接口测试闭环提交/推送守卫,杜绝'假成功'(unmerged 时 commit 被拒仍报 push 成功)
-
-
-## v1.0.1934 (2026-11-01)
-
-### 测试
-- 接口冒烟闭环 2026-11-01 14:28 — 通过率 44% (fix=0 reg=0); 测试用例 360/387 完成(文档化202)
-
-
 ## v1.0.1933 (2026-11-01)
 
 ### 新功能

+ 2 - 3
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,8 +24,7 @@ describe("ProductSkuEditor 模式选择", () => {
     await wrapper.vm.$nextTick()
     const buttons = wrapper.findAll(".el-radio-button").wrappers.map(w => w.text())
     expect(buttons).not.toContain("关联商品")
-    expect(buttons).not.toContain("商品组合")
-    expect(buttons).toEqual(["规格变体"])
+    expect(buttons).toEqual(expect.arrayContaining(["规格变体", "商品组合"]))
     wrapper.destroy()
   })
 })

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

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

+ 4 - 9
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 sectionList(sec.key)" :key="i">
+            <div class="dwell-item" v-for="(row, i) in this[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="!sectionList(sec.key).length" class="dwell-empty">
+            <div v-if="!((this[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 sectionList(sec.key)" :key="i">
+            <div class="hot-item" v-for="(row, i) in this[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="!sectionList(sec.key).length" class="hot-empty">暂无数据</div>
+            <div v-if="!((this[sec.key] || []).length)" class="hot-empty">暂无数据</div>
           </div>
         </div>
       </el-col>
@@ -410,11 +410,6 @@ 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

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

@@ -48,44 +48,6 @@
           <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="测评类型">
@@ -117,20 +79,23 @@
           </el-row>
         </template>
 
-        <!-- 套餐商品扩展信息(新模型:内容=子商品SKU×数量,挂套餐SKU级 product_combo_items) -->
+        <!-- 套餐商品扩展信息 -->
         <template v-if="form.productType === 'bundle'">
-          <SectionTitle title="套餐内容" />
-          <el-form-item label="套餐内容">
-            <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openComboPicker">选择子商品SKU</el-button>
+          <SectionTitle title="套餐组成" />
+          <el-form-item label="组成商品">
             <div v-for="(item, idx) in bundleItems" :key="'bi' + idx" class="bundle-row">
-              <span class="bundle-item-name">商品 #{{ item.childProductId }}</span>
-              <span v-if="item.childSkuId" class="bundle-item-spec">· 规格 #{{ item.childSkuId }}</span>
+              <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>
               <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>
-            <div class="form-hint-mt8">套餐内容挂套餐SKU级;下单/支付只扣套餐SKU库存,不扣子商品库存(需先添加套餐SKU作锚点)。</div>
+            <el-button type="primary" size="mini" plain @click="addBundleItem()">+ 添加子商品</el-button>
+            <div class="form-hint-mt8">套餐出库时自动完成所有组成商品出库(数量 × 套餐数量)</div>
           </el-form-item>
-          <ProductPicker ref="comboPicker" :multiple="true" :cross-category="true" :selected="comboPickerSelected" @confirm="onComboPickerConfirm" />
         </template>
 
         <!-- 关联成长档案 -->
@@ -423,7 +388,7 @@
 </template>
 
 <script>
-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 { 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 { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ContentDimensionThresholdEditor from '@/components/ContentDimensionThresholdEditor.vue'
@@ -431,12 +396,11 @@ 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, ProductPicker },
+  components: { DimensionWeightPicker, ContentDimensionThresholdEditor, ProductSkuEditor, Editor, Toolbar, AssetPicker },
   data() {
     return {
       form: {
@@ -460,13 +424,6 @@ export default {
         minVisibleLevel: '',
         isRecommended: 0,
         tags: '',
-        // 营养信息(可选,留空=未配置)
-        caloriesKcal: null,
-        proteinG: null,
-        fatG: null,
-        carbG: null,
-        fiberG: null,
-        nutritionTags: '',
         dimensionWeights: [],
         // 测评商品扩展
         assessmentType: 'single',
@@ -476,7 +433,7 @@ export default {
         sortOrder: 0,
         categoryId: null
       },
-      bundleItems: [], // 套餐内容: [{childProductId, childSkuId, quantity}](挂套餐SKU级 product_combo_items)
+      bundleItems: [], // 套餐组成: [{childProductId, childSkuId, quantity, productOptions:[], skuOptions:[]}]
       servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice}]
       approvedGuides: [], // 已入驻服务商列表
       selectedGuideId: '',
@@ -512,9 +469,40 @@ 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 })
@@ -528,36 +516,24 @@ export default {
     },
     async loadBundleItems(productId) {
       try {
-        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)
+        var res = await getBundleItems(productId)
         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
+              quantity: b.quantity || 1,
+              productOptions: [],
+              skuOptions: []
             }
           })
+          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
-        }
-      })
     }
     }
   },
@@ -565,15 +541,6 @@ 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 = {}
@@ -762,14 +729,7 @@ export default {
             validityDays: p.validityDays || 365,
             guideScope: p.guideScope || 'all',
             sortOrder: p.sortOrder != null ? p.sortOrder : 0,
-            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 || ''
+            categoryId: p.categoryId || null
           }
           if (p.images) {
             try {
@@ -1130,12 +1090,6 @@ 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
@@ -1146,14 +1100,7 @@ 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),
-            // 营养数据:空值统一转 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()
+            images: JSON.stringify(this.imageList)
           }
           // Extract dimensionWeights before sending to product API
           const dimensionWeights = productData.dimensionWeights
@@ -1244,22 +1191,14 @@ async loadGiftRule(productId) {
                 console.warn('保存线上服务配置失败', e)
               }
             }
-            // Save 套餐内容(新模型:内容挂套餐SKU级 product_combo_items,锚点=首个套餐SKU)
-            if (productId && this.form.productType === 'bundle') {
+            // Save bundle items config(套餐商品组成)
+            if (productId) {
               try {
-                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)
-                }
+                var bundlePayload = this.buildBundleItemPayload()
+                await saveBundleItems(productId, bundlePayload)
               } catch (e) {
-                this.$message.error('保存套餐内容失败')
-                console.warn('保存套餐内容失败', e)
+                this.$message.error('保存套餐组成失败')
+                console.warn('保存套餐组成失败', e)
               }
             }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
@@ -1312,8 +1251,6 @@ 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;
 }

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

@@ -8,9 +8,10 @@
         <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>{{ parseSpecs(row.specs) }}</span>
+          <span v-if="row.comboItems && row.comboItems.length">组合:{{ formatComboSummary(row.comboItems) }}</span>
+          <span v-else>{{ parseSpecs(row.specs) }}</span>
         </template>
       </el-table-column>
       <el-table-column label="售价(元)" width="100">
@@ -48,6 +49,7 @@
       <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>
 
         <!-- 规格变体模式(原有) -->
@@ -83,6 +85,37 @@
         </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" />
@@ -94,6 +127,15 @@
       </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
@@ -108,11 +150,13 @@
 
 <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: { AssetPicker },
+  components: { ProductPicker, AssetPicker },
   props: {
     productId: {
       type: Number,
@@ -129,6 +173,7 @@ export default {
       saving: false,
       editingId: null,
       skuMode: 'spec',
+      comboItems: [],
       skuForm: {
         specs: '',
         price: 0,
@@ -201,20 +246,65 @@ export default {
     showDialog(sku) {
       if (sku) {
         this.editingId = sku.id
-        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
+        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
+          }
         }
       } else {
         this.editingId = null
@@ -224,6 +314,7 @@ export default {
     },
     resetForm() {
       this.skuMode = 'spec'
+      this.comboItems = []
       this.skuForm = {
         specs: '',
         price: 0,
@@ -346,7 +437,74 @@ 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
@@ -359,6 +517,7 @@ export default {
             stock: this.skuForm.stock,
             image: this.skuForm.image,
             enabled: this.skuForm.enabled,
+            comboItems: [],
             linkedProductId: null
           }
           // 新建商品时productId为空,暂存到本地
@@ -458,5 +617,17 @@ 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>

Plik diff jest za duży
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


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

@@ -2074,6 +2074,61 @@ 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`。

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

@@ -1,8 +1,10 @@
 # 团队页「关联订单 / 关联金额(元)」按商城商品-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
-- 状态:待派发(已入 tasks_backlog.json,等 hub 派发)
-- 优先级:P1
+- 状态:**已废弃**(前提错误,由 `2026-11-01-team-system-design.md` §3.2 取代)
+- 优先级: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
-[耗时] 家庭列表 103ms
+[耗时] 家庭列表 128ms
 
 [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 (1.0m)
+  51 passed (53.3s)

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików