Browse Source

merge(HL-05): 产品多选一购买任务+购买完成生成执行任务 合入 cfclub 主线

Agnes Agent 3 days ago
parent
commit
56b327edd8

+ 75 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 import com.etotem.cfc.util.ParamUtils;
@@ -205,6 +206,80 @@ public class HealthPlanController {
         return Result.success(tasks);
         return Result.success(tasks);
     }
     }
 
 
+    // ===== HL-05/R5+R6:产品多选一购买任务 + 购买后执行任务 =====
+
+    @Operation(summary = "方案产品多选一选项(R5:每类产品按效果评分+价格给出多选一)")
+    @PostMapping("/product-options")
+    public Result<List<Map<String, Object>>> productOptions(@RequestBody Map<String, Object> params,
+                                                            @RequestAttribute("userId") Long userId,
+                                                            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        if (familyId == null) return Result.error("familyId不能为空");
+        List<Map<String, Object>> groups;
+        try {
+            groups = healthPlanService.getPlanProductGroups(planId, familyId, userId);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
+        if (groups == null) {
+            return Result.error("无权查看该方案产品");
+        }
+        return Result.success(groups);
+    }
+
+    @Operation(summary = "确认产品选择 → 生成购买任务(本平台产品直接加入购物车)")
+    @PostMapping("/product-select")
+    @SuppressWarnings("unchecked")
+    public Result<Map<String, Object>> productSelect(@RequestBody Map<String, Object> params,
+                                                     @RequestAttribute("userId") Long userId,
+                                                     @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        if (familyId == null) return Result.error("familyId不能为空");
+        List<Map<String, Object>> selections = new ArrayList<>();
+        Object rawSelections = params.get("selections");
+        if (rawSelections instanceof List) {
+            for (Object item : (List<?>) rawSelections) {
+                if (item instanceof Map) {
+                    selections.add((Map<String, Object>) item);
+                }
+            }
+        }
+        if (selections.isEmpty()) return Result.error("selections不能为空");
+        Map<String, Object> result;
+        try {
+            result = healthPlanService.confirmProductSelection(planId, familyId, userId, selections);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
+        if (result == null) {
+            return Result.error("无权操作该方案");
+        }
+        return Result.success(result);
+    }
+
+    @Operation(summary = "购买完成 → 按方案建议生成执行任务(R6)")
+    @PostMapping("/purchase-complete")
+    public Result<Map<String, Object>> purchaseComplete(@RequestBody Map<String, Object> params,
+                                                        @RequestAttribute("userId") Long userId,
+                                                        @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long planId = ParamUtils.getLong(params.get("planId"));
+        if (planId == null) return Result.error("planId不能为空");
+        if (familyId == null) return Result.error("familyId不能为空");
+        Long purchaseTaskId = ParamUtils.getLong(params.get("purchaseTaskId"));
+        Map<String, Object> result;
+        try {
+            result = healthPlanService.completePurchaseAndGenerateTasks(planId, familyId, purchaseTaskId);
+        } catch (IllegalStateException e) {
+            return Result.error(e.getMessage());
+        }
+        if (result == null) {
+            return Result.error("无权操作该方案");
+        }
+        return Result.success(result);
+    }
+
     @Operation(summary = "删除健康方案(级联删除方案产生的全部任务,含每日实例)")
     @Operation(summary = "删除健康方案(级联删除方案产生的全部任务,含每日实例)")
     @PostMapping("/delete")
     @PostMapping("/delete")
     public Result<Map<String, Object>> deletePlan(@RequestBody Map<String, Object> params,
     public Result<Map<String, Object>> deletePlan(@RequestBody Map<String, Object> params,

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -99,6 +99,36 @@ public interface HealthPlanService {
     /** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
     /** 查询方案分解后的任务列表(source_type=health_plan, source_id=planId) */
     List<Task> getPlanTasks(Long planId);
     List<Task> getPlanTasks(Long planId);
 
 
+    /**
+     * HL-05/R5:方案「产品推荐组」——按方案营养板块的每个产品品类,给出多选一选项
+     * (每项含效果评分 effectScore 与价格 price/priceYuan,平台无匹配时附「自选渠道」项)。
+     *
+     * @param familyId 归属校验家庭ID
+     * @param userId   当前登录用户(供应商优先级/会员价上下文,可空)
+     * @return 产品组列表;方案不存在抛异常,归属不匹配返回 null
+     */
+    List<Map<String, Object>> getPlanProductGroups(Long planId, Long familyId, Long userId);
+
+    /**
+     * HL-05/R5:确认产品选择 → 生成购买任务;本平台产品直接加入购物车。
+     * 每位方案成员各生成一条购买任务(category=购买任务,actionType=cart,actionConfig 含 productId),
+     * 选择结果持久化到 plan_json.productSelection(不新增表/列)。
+     *
+     * @param selections [{category, productId?, name?, quantity?, skuId?}]
+     * @return {planId, purchaseTasks, cartItems, selections};归属不匹配返回 null
+     */
+    Map<String, Object> confirmProductSelection(Long planId, Long familyId, Long userId,
+                                                List<Map<String, Object>> selections);
+
+    /**
+     * HL-05/R6:购买完成 → 按方案建议生成执行任务。
+     * 购买任务置为 completed 并写入 successorTaskIds;执行任务以购买任务为前置(prerequisiteTaskId)建立流程链路。
+     * purchaseTaskId 为空时处理该方案下全部购买任务;方案下无购买任务时直接按方案建议生成执行任务。
+     *
+     * @return {planId, purchaseTaskIds, executionTasks};归属不匹配返回 null
+     */
+    Map<String, Object> completePurchaseAndGenerateTasks(Long planId, Long familyId, Long purchaseTaskId);
+
     /** 基于自检低分维度自动生成 draft 计划 */
     /** 基于自检低分维度自动生成 draft 计划 */
     Long generateFromSelfCheck(Long userId, Long checkId);
     Long generateFromSelfCheck(Long userId, Long checkId);
 
 

+ 688 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
@@ -19,10 +20,14 @@ import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.HealthPlanMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.CartService;
 import com.etotem.cfc.service.HealthPlanService;
 import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.service.ProductRecommendationService;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.ResponseEntity;
@@ -35,11 +40,16 @@ import java.util.ArrayList;
 import java.util.Calendar;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.regex.Pattern;
+import com.alibaba.fastjson.JSONObject;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -80,6 +90,17 @@ public class HealthPlanServiceImpl implements HealthPlanService {
     @Resource
     @Resource
     private com.etotem.cfc.service.AiGateway aiGateway;
     private com.etotem.cfc.service.AiGateway aiGateway;
 
 
+    /** HL-05/R5:产品推荐(效果评分/价格/会员价) */
+    @Resource
+    private ProductRecommendationService productRecommendationService;
+
+    /** HL-05/R5:本平台产品直接加入购物车 */
+    @Resource
+    private CartService cartService;
+
+    @Resource
+    private ProductMapper productMapper;
+
     @Value("${langgraph.base-url:}")
     @Value("${langgraph.base-url:}")
     private String langgraphBaseUrl;
     private String langgraphBaseUrl;
 
 
@@ -220,6 +241,673 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         return taskMapper.selectList(wrapper);
         return taskMapper.selectList(wrapper);
     }
     }
 
 
+    // ==================== HL-05/R5+R6:产品多选一购买任务 + 购买后执行任务 ====================
+
+    /** 方案购买任务分类标签(与既有"购买锚点"任务口径一致) */
+    private static final String PLAN_PURCHASE_CATEGORY = "购买任务";
+
+    /** 每个品类最多给出的候选数(多选一选项) */
+    private static final int PRODUCT_OPTIONS_PER_GROUP = 3;
+
+    /** 推荐候选池大小(跨品类分配,避免每类重复同一批商品) */
+    private static final int PRODUCT_POOL_SIZE = 12;
+
+    @Override
+    public List<Map<String, Object>> getPlanProductGroups(Long planId, Long familyId, Long userId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) {
+            throw new IllegalStateException("方案不存在");
+        }
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return null;
+        }
+        return buildProductGroups(plan, familyId, userId);
+    }
+
+    /** 组装「产品推荐组」:每类产品按效果评分(matchScore)+价格给出多选一选项 */
+    private List<Map<String, Object>> buildProductGroups(HealthPlan plan, Long familyId, Long userId) {
+        List<String> categories = collectNutritionCategories(plan);
+        if (categories.isEmpty()) {
+            categories.add("营养补充");
+        }
+        String dimensionCode = firstDimension(plan);
+        Long memberId = firstPlanMemberId(plan);
+
+        List<Map<String, Object>> pool = new ArrayList<>();
+        try {
+            pool = productRecommendationService.getDimensionRecommendations(
+                    dimensionCode, plan.getFamilyId(), memberId, null, PRODUCT_POOL_SIZE, userId);
+        } catch (Exception e) {
+            log.warn("方案{}产品推荐失败,降级为空选项: {}", plan.getId(), e.getMessage());
+        }
+        if (pool == null) {
+            pool = new ArrayList<>();
+        }
+
+        Map<String, List<Map<String, Object>>> byCategory = new LinkedHashMap<>();
+        for (String category : categories) {
+            byCategory.put(category, new ArrayList<Map<String, Object>>());
+        }
+        Set<Object> usedProductIds = new HashSet<>();
+
+        // 第一轮:按品类关键词匹配候选商品(同款商品只归入一个品类)
+        for (String category : categories) {
+            List<Map<String, Object>> options = byCategory.get(category);
+            for (Map<String, Object> candidate : pool) {
+                if (options.size() >= PRODUCT_OPTIONS_PER_GROUP) break;
+                Object productId = productIdOf(candidate);
+                if (productId == null || usedProductIds.contains(productId)) continue;
+                String name = str(candidate.get("name"));
+                if (!productMatchesCategory(name, category)) continue;
+                options.add(buildProductOption(candidate));
+                usedProductIds.add(productId);
+            }
+        }
+        // 第二轮:关键词未命中的品类用剩余候选补齐(保证每类都有可选平台商品)
+        for (String category : categories) {
+            List<Map<String, Object>> options = byCategory.get(category);
+            for (Map<String, Object> candidate : pool) {
+                if (options.size() >= PRODUCT_OPTIONS_PER_GROUP) break;
+                Object productId = productIdOf(candidate);
+                if (productId == null || usedProductIds.contains(productId)) continue;
+                options.add(buildProductOption(candidate));
+                usedProductIds.add(productId);
+            }
+        }
+        // 第三轮:兜底「自选渠道」项(平台无匹配时用户仍可确认购买)
+        for (String category : categories) {
+            byCategory.get(category).add(buildExternalOption(category));
+        }
+
+        List<Map<String, Object>> groups = new ArrayList<>();
+        for (Map.Entry<String, List<Map<String, Object>>> entry : byCategory.entrySet()) {
+            Map<String, Object> group = new LinkedHashMap<>();
+            group.put("category", entry.getKey());
+            group.put("categoryLabel", entry.getKey());
+            group.put("dimensionCode", dimensionCode);
+            group.put("options", entry.getValue());
+            groups.add(group);
+        }
+        return groups;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> confirmProductSelection(Long planId, Long familyId, Long userId,
+                                                       List<Map<String, Object>> selections) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) {
+            throw new IllegalStateException("方案不存在");
+        }
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return null;
+        }
+        if (selections == null || selections.isEmpty()) {
+            throw new IllegalStateException("请先为每类产品选择一项");
+        }
+
+        List<Long> targetMemberIds = planMemberIds(plan);
+        if (targetMemberIds.isEmpty()) {
+            throw new IllegalStateException("方案未关联家庭成员");
+        }
+        String dimensionCode = firstDimension(plan);
+        Date now = new Date();
+
+        List<Map<String, Object>> purchaseTasks = new ArrayList<>();
+        List<Map<String, Object>> cartItems = new ArrayList<>();
+        List<Map<String, Object>> savedSelections = new ArrayList<>();
+
+        for (Map<String, Object> selection : selections) {
+            if (selection == null) continue;
+            String category = str(selection.get("category"));
+            if (category == null) category = str(selection.get("categoryLabel"));
+            if (category == null || category.trim().isEmpty()) continue;
+            String quantityRaw = str(selection.get("quantity"));
+            int quantity = 1;
+            if (quantityRaw != null) {
+                try {
+                    quantity = Math.max(1, Integer.parseInt(quantityRaw.trim()));
+                } catch (NumberFormatException ignored) {
+                    quantity = 1;
+                }
+            }
+            Long productId = toLong(selection.get("productId"));
+            Long skuId = toLong(selection.get("skuId"));
+            Product product = productId != null ? productMapper.selectById(productId) : null;
+            if (productId != null && product == null) {
+                log.warn("方案{}产品选择跳过:商品不存在 productId={}", planId, productId);
+                continue;
+            }
+            String productName = str(selection.get("name"));
+            if ((productName == null || productName.trim().isEmpty()) && product != null) {
+                productName = product.getName();
+            }
+            if (productName == null || productName.trim().isEmpty()) {
+                productName = category;
+            }
+
+            // 幂等判定:该方案下每位方案成员是否已有同名购买任务
+            boolean alreadyGenerated = true;
+            for (Long memberTarget : targetMemberIds) {
+                if (findPlanTask(plan, plan.getFamilyId(), memberTarget,
+                        "购买:" + productName, PLAN_PURCHASE_CATEGORY, 0) == null) {
+                    alreadyGenerated = false;
+                    break;
+                }
+            }
+            // 本平台产品直接加入购物车(仅首次确认时加购,避免重复累加数量)
+            if (product != null && userId != null && !alreadyGenerated) {
+                try {
+                    CartItemDTO cartItem = cartService.addToCart(userId, productId, quantity, skuId);
+                    if (cartItem != null) {
+                        Map<String, Object> ci = new LinkedHashMap<>();
+                        ci.put("productId", productId);
+                        ci.put("name", productName);
+                        ci.put("quantity", quantity);
+                        cartItems.add(ci);
+                    }
+                } catch (Exception e) {
+                    log.warn("方案{}产品加购失败(不影响购买任务生成): productId={}, error={}",
+                            planId, productId, e.getMessage());
+                }
+            }
+
+            // 每位方案成员各生成一条购买任务(流程链路按成员隔离,与既有购买锚点口径一致)
+            for (Long memberTarget : targetMemberIds) {
+                String title = "购买:" + productName;
+                Task existing = findPlanTask(plan, plan.getFamilyId(), memberTarget,
+                        title, PLAN_PURCHASE_CATEGORY, 0);
+                if (existing != null) {
+                    purchaseTasks.add(taskBrief(existing));
+                    continue;
+                }
+                Task task = new Task();
+                task.setFamilyId(plan.getFamilyId());
+                task.setFamilyMemberId(memberTarget);
+                task.setMemberId(memberTarget);
+                task.setExecutorType("child");
+                task.setExecutorId(memberTarget);
+                task.setCreatorId(userId != null ? userId : 0L);
+                task.setTitle(title);
+                task.setDescription("来自健康方案的产品购买任务(方案ID " + planId + ",品类:" + category + ")");
+                task.setPoints(10);
+                task.setCategory(PLAN_PURCHASE_CATEGORY);
+                task.setSourceType("health_plan");
+                task.setSourceId(planId);
+                task.setNeedReview(0);
+                task.setReviewByCategory(0);
+                task.setRepeatType("none");
+                task.setTaskType("onetime");
+                task.setFrequency(null);
+                task.setIsTemplate(0);
+                task.setStatus("pending");
+                task.setDeadline(buildTodayDeadline());
+                task.setDimensionCode(dimensionCode);
+                task.setMemberOnly(0);
+                task.setIsFlowTask(1);
+                if (product != null) {
+                    task.setActionType("cart");
+                    JSONObject config = new JSONObject();
+                    config.put("productId", productId);
+                    config.put("quantity", quantity);
+                    if (skuId != null) config.put("skuId", skuId);
+                    config.put("planId", planId);
+                    config.put("category", category);
+                    task.setActionConfig(config.toJSONString());
+                }
+                task.setCreatedAt(now);
+                task.setUpdatedAt(now);
+                taskMapper.insert(task);
+                purchaseTasks.add(taskBrief(task));
+                log.info("方案{}生成产品购买任务: [{}] memberId={} taskId={}",
+                        planId, productName, memberTarget, task.getId());
+            }
+
+            Map<String, Object> saved = new LinkedHashMap<>();
+            saved.put("category", category);
+            saved.put("productId", productId);
+            saved.put("name", productName);
+            saved.put("quantity", quantity);
+            if (product != null) {
+                saved.put("price", product.getPrice());
+                saved.put("memberPrice", product.getMemberPrice());
+            } else {
+                saved.put("inPlatform", false);
+            }
+            savedSelections.add(saved);
+        }
+
+        if (purchaseTasks.isEmpty()) {
+            throw new IllegalStateException("未生成购买任务,请重新选择产品");
+        }
+
+        // 选择结果持久化到 plan_json.productSelection(不新增表/列)
+        persistProductSelection(plan, savedSelections, purchaseTasks, now);
+        plan.setStatus("published");
+        plan.setUpdatedAt(now);
+        healthPlanMapper.updateById(plan);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("planId", planId);
+        result.put("purchaseTasks", purchaseTasks);
+        result.put("cartItems", cartItems);
+        result.put("selections", savedSelections);
+        result.put("cartAddCount", cartItems.size());
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> completePurchaseAndGenerateTasks(Long planId, Long familyId, Long purchaseTaskId) {
+        HealthPlan plan = healthPlanMapper.selectById(planId);
+        if (plan == null) {
+            throw new IllegalStateException("方案不存在");
+        }
+        if (familyId != null && plan.getFamilyId() != null && !familyId.equals(plan.getFamilyId())) {
+            return null;
+        }
+
+        List<Task> purchaseTasks = new ArrayList<>();
+        if (purchaseTaskId != null) {
+            Task one = taskMapper.selectById(purchaseTaskId);
+            if (one == null || !planId.equals(one.getSourceId())
+                    || !"health_plan".equals(one.getSourceType())) {
+                throw new IllegalStateException("购买任务不存在或不属于该方案");
+            }
+            purchaseTasks.add(one);
+        } else {
+            purchaseTasks = taskMapper.selectList(new QueryWrapper<Task>()
+                    .eq("source_type", "health_plan")
+                    .eq("source_id", planId)
+                    .eq("category", PLAN_PURCHASE_CATEGORY)
+                    .isNull("template_id")
+                    .orderByAsc("id"));
+        }
+
+        Date now = new Date();
+        int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
+                ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
+        Date planEnd = buildPlanEnd(now, totalDays);
+
+        List<Map<String, Object>> executionTasks = new ArrayList<>();
+        List<Long> handledPurchaseTaskIds = new ArrayList<>();
+
+        // 有购买任务:逐条完成并生成其后置执行任务;无购买任务(用户跳过购买):按方案建议直接生成
+        List<Long> memberTargets = new ArrayList<>();
+        if (!purchaseTasks.isEmpty()) {
+            for (Task purchase : purchaseTasks) {
+                if (purchase.getFamilyMemberId() != null) {
+                    memberTargets.add(purchase.getFamilyMemberId());
+                }
+            }
+        } else {
+            memberTargets.addAll(planMemberIds(plan));
+        }
+
+        for (Task purchase : purchaseTasks) {
+            Long memberTarget = purchase.getFamilyMemberId();
+            if (memberTarget == null) continue;
+            List<Task> created = insertExecutionTasksForMember(
+                    plan, plan.getFamilyId(), memberTarget, draftsForPlanMember(plan, memberTarget),
+                    purchase.getId(), now, planEnd);
+            for (Task task : created) {
+                executionTasks.add(taskBrief(task));
+            }
+            if (!created.isEmpty()) {
+                List<Long> successorIds = new ArrayList<>();
+                for (Task task : created) {
+                    successorIds.add(task.getId());
+                }
+                try {
+                    purchase.setSuccessorTaskIds(objectMapper.writeValueAsString(successorIds));
+                } catch (Exception e) {
+                    log.warn("方案{}购买任务后置链写入失败: taskId={}", planId, purchase.getId());
+                }
+            }
+            purchase.setIsFlowTask(1);
+            if (!"completed".equals(purchase.getStatus())) {
+                purchase.setStatus("completed");
+                purchase.setCompletedAt(now);
+            }
+            purchase.setUpdatedAt(now);
+            taskMapper.updateById(purchase);
+            handledPurchaseTaskIds.add(purchase.getId());
+        }
+
+        // 无购买任务的降级路径:直接按方案建议为各成员生成执行任务(无前置)
+        if (purchaseTasks.isEmpty()) {
+            for (Long memberTarget : memberTargets) {
+                List<Task> created = insertExecutionTasksForMember(
+                        plan, plan.getFamilyId(), memberTarget, draftsForPlanMember(plan, memberTarget),
+                        null, now, planEnd);
+                for (Task task : created) {
+                    executionTasks.add(taskBrief(task));
+                }
+            }
+        }
+
+        if (!"published".equals(plan.getStatus())) {
+            plan.setStatus("published");
+            plan.setUpdatedAt(now);
+            healthPlanMapper.updateById(plan);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("planId", planId);
+        result.put("purchaseTaskIds", handledPurchaseTaskIds);
+        result.put("executionTasks", executionTasks);
+        result.put("executionTaskCount", executionTasks.size());
+        return result;
+    }
+
+    /** 按方案建议(plan_json.sections[].tasks)为单个成员生成执行任务,绑定购买任务为前置(HL-05/R6) */
+    private List<Task> insertExecutionTasksForMember(HealthPlan plan, Long familyId, Long familyMemberId,
+                                                     List<TaskDraft> drafts, Long anchorTaskId,
+                                                     Date now, Date planEnd) {
+        List<Task> created = new ArrayList<>();
+        if (drafts == null || drafts.isEmpty()) {
+            return created;
+        }
+        for (TaskDraft draft : drafts) {
+            // 购买类草稿由 R5 购买任务承载,这里只生成执行类任务
+            if (PLAN_PURCHASE_CATEGORY.equals(draft.categoryLabel)) continue;
+            boolean once = "once".equals(draft.frequency);
+            int templateOnly = once ? 0 : 1;
+            if (findPlanTask(plan, familyId, familyMemberId, draft.title, draft.categoryLabel, templateOnly) != null) {
+                continue;
+            }
+            Task task = newPlanTask(plan, familyId, familyMemberId, familyMemberId, familyMemberId, draft, now);
+            task.setMemberOnly(0);
+            task.setIsFlowTask(1);
+            task.setPrerequisiteTaskId(anchorTaskId);
+            task.setParentTaskId(anchorTaskId);
+            if (once) {
+                task.setRepeatType("none");
+                task.setTaskType("onetime");
+                task.setFrequency(null);
+                task.setIsTemplate(0);
+                task.setStatus("pending");
+                task.setDeadline(buildTodayDeadline());
+            } else {
+                task.setRepeatType("daily");
+                task.setTaskType("recurring");
+                task.setFrequency("daily");
+                task.setIsTemplate(1);
+                task.setStatus("pending");
+                task.setDeadline(planEnd);
+            }
+            taskMapper.insert(task);
+            created.add(task);
+            log.info("方案{}生成执行任务: [{}] memberId={} prereq={} taskId={}",
+                    plan.getId(), draft.title, familyMemberId, anchorTaskId, task.getId());
+        }
+        return created;
+    }
+
+    /** 取指定方案成员的任务草稿:多人方案=共性 + 该成员个性;单人/旧结构=root.sections */
+    private List<TaskDraft> draftsForPlanMember(HealthPlan plan, Long familyMemberId) {
+        JsonNode root = parsePlanJson(plan.getPlanJson());
+        if (root != null && root.has("common") && root.has("individual")) {
+            boolean[] backfilled = new boolean[]{false};
+            List<TaskDraft> drafts = new ArrayList<>(
+                    extractDraftsFromSections(root.path("common").path("sections"), backfilled));
+            String rawKey = null;
+            String memberIds = plan.getMemberIds();
+            if (memberIds != null) {
+                for (String raw : memberIds.split(",")) {
+                    if (raw == null || raw.trim().isEmpty()) continue;
+                    try {
+                        Long resolved = resolveFamilyMemberId(Long.valueOf(raw.trim()), plan.getFamilyId());
+                        if (resolved != null && resolved.equals(familyMemberId)) {
+                            rawKey = raw.trim();
+                            break;
+                        }
+                    } catch (NumberFormatException ignored) {
+                        // 忽略非法成员ID
+                    }
+                }
+            }
+            if (rawKey != null) {
+                drafts.addAll(extractDraftsFromSections(
+                        root.path("individual").path(rawKey).path("sections"), backfilled));
+            }
+            if (backfilled[0]) {
+                try {
+                    plan.setPlanJson(objectMapper.writeValueAsString(root));
+                    healthPlanMapper.updateById(plan);
+                } catch (Exception e) {
+                    log.warn("方案{}执行任务懒回填写库失败: {}", plan.getId(), e.getMessage());
+                }
+            }
+            return drafts;
+        }
+        return buildDraftsFromPlanJson(plan);
+    }
+
+    /** 产品选择结果写入 plan_json.productSelection(无新增表/列,随方案一起持久化) */
+    private void persistProductSelection(HealthPlan plan, List<Map<String, Object>> selections,
+                                         List<Map<String, Object>> purchaseTasks, Date now) {
+        try {
+            JsonNode parsed = parsePlanJson(plan.getPlanJson());
+            ObjectNode root = parsed instanceof ObjectNode
+                    ? (ObjectNode) parsed
+                    : objectMapper.createObjectNode();
+            ObjectNode selectionNode = objectMapper.createObjectNode();
+            selectionNode.put("confirmedAt", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(now));
+            ArrayNode selectionsArray = objectMapper.createArrayNode();
+            for (Map<String, Object> selection : selections) {
+                ObjectNode node = objectMapper.createObjectNode();
+                node.put("category", str(selection.get("category")));
+                Object productId = selection.get("productId");
+                if (productId instanceof Number) {
+                    node.put("productId", ((Number) productId).longValue());
+                } else {
+                    node.putNull("productId");
+                }
+                node.put("name", str(selection.get("name")));
+                Object quantity = selection.get("quantity");
+                node.put("quantity", quantity instanceof Number ? ((Number) quantity).intValue() : 1);
+                Object price = selection.get("price");
+                if (price instanceof Number) node.put("price", ((Number) price).longValue());
+                selectionsArray.add(node);
+            }
+            selectionNode.set("selections", selectionsArray);
+            ArrayNode taskIds = objectMapper.createArrayNode();
+            for (Map<String, Object> task : purchaseTasks) {
+                Object id = task.get("id");
+                if (id instanceof Number) taskIds.add(((Number) id).longValue());
+            }
+            selectionNode.set("purchaseTaskIds", taskIds);
+            root.set("productSelection", selectionNode);
+            plan.setPlanJson(objectMapper.writeValueAsString(root));
+        } catch (Exception e) {
+            log.warn("方案{}产品选择结果写入 plan_json 失败: {}", plan.getId(), e.getMessage());
+        }
+    }
+
+    /** 方案营养板块的产品品类(来自 plan_json sections[].items[].name,去重保序) */
+    private List<String> collectNutritionCategories(HealthPlan plan) {
+        LinkedHashSet<String> categories = new LinkedHashSet<>();
+        JsonNode root = parsePlanJson(plan.getPlanJson());
+        if (root == null) {
+            return new ArrayList<>(categories);
+        }
+        // 单人/旧结构:root.sections;多人结构:common.sections + individual.<member>.sections
+        collectNutritionCategoriesFromPart(root, categories);
+        if (root.has("common")) {
+            collectNutritionCategoriesFromPart(root.path("common"), categories);
+        }
+        JsonNode individual = root.path("individual");
+        if (individual.isObject()) {
+            Iterator<String> names = individual.fieldNames();
+            while (names.hasNext()) {
+                collectNutritionCategoriesFromPart(individual.path(names.next()), categories);
+            }
+        }
+        return new ArrayList<>(categories);
+    }
+
+    private void collectNutritionCategoriesFromPart(JsonNode part, Set<String> categories) {
+        JsonNode sections = part.path("sections");
+        if (!sections.isArray()) return;
+        for (JsonNode section : sections) {
+            String key = section.path("key").asText("");
+            // 只取营养补充板块;无 key 的旧数据按内容兜底
+            if (!key.isEmpty() && !"nutrition".equals(key)) continue;
+            JsonNode items = section.path("items");
+            if (!items.isArray()) continue;
+            for (JsonNode item : items) {
+                String name = item.path("name").asText("").trim();
+                if (name.isEmpty() || name.length() > 24) continue;
+                categories.add(name);
+            }
+        }
+    }
+
+    /** 商品名与品类关键词匹配(益生菌/益生元/维生素/矿物质/蛋白/鱼油 等常见品类) */
+    private boolean productMatchesCategory(String productName, String category) {
+        if (productName == null || category == null) return false;
+        String lowerName = productName.toLowerCase();
+        if (lowerName.contains(category.toLowerCase())) return true;
+        String[] keywords;
+        if (category.contains("益生菌")) {
+            keywords = new String[]{"益生菌", "菌粉", "probiotic"};
+        } else if (category.contains("益生元")) {
+            keywords = new String[]{"益生元", "低聚", "膳食纤维", "prebiotic"};
+        } else if (category.contains("维生素")) {
+            keywords = new String[]{"维生素", "维c", "vc", "多维", "vitamin"};
+        } else if (category.contains("钙") || category.contains("矿物质")) {
+            keywords = new String[]{"钙", "矿物质", "镁", "锌"};
+        } else if (category.contains("蛋白")) {
+            keywords = new String[]{"蛋白", "肽"};
+        } else if (category.contains("鱼油") || category.toLowerCase().contains("dha")
+                || category.toLowerCase().contains("omega")) {
+            keywords = new String[]{"鱼油", "dha", "omega", "欧米伽"};
+        } else if (category.contains("水") || category.contains("饮")) {
+            keywords = new String[]{"水", "饮"};
+        } else {
+            keywords = new String[]{category};
+        }
+        for (String keyword : keywords) {
+            if (lowerName.contains(keyword.toLowerCase())) return true;
+        }
+        return false;
+    }
+
+    private Map<String, Object> buildProductOption(Map<String, Object> candidate) {
+        Map<String, Object> option = new LinkedHashMap<>();
+        Object productId = productIdOf(candidate);
+        option.put("productId", productId);
+        option.put("name", candidate.get("name"));
+        option.put("coverImage", candidate.get("coverImage"));
+        option.put("price", candidate.get("price"));
+        option.put("memberPrice", candidate.get("memberPrice"));
+        option.put("priceYuan", formatYuan(candidate.get("price")));
+        option.put("memberPriceYuan", formatYuan(candidate.get("memberPrice")));
+        Object effectScore = candidate.get("matchScore") != null
+                ? candidate.get("matchScore") : candidate.get("match_score");
+        option.put("effectScore", effectScore);
+        option.put("reason", candidate.get("reason"));
+        option.put("productType", candidate.get("productType"));
+        option.put("inPlatform", true);
+        option.put("quantity", 1);
+        return option;
+    }
+
+    /** 平台无匹配产品时的兜底选项:线下/其他渠道自选(仍可生成购买任务) */
+    private Map<String, Object> buildExternalOption(String category) {
+        Map<String, Object> option = new LinkedHashMap<>();
+        option.put("productId", null);
+        option.put("name", category + "(线下/其他渠道自选)");
+        option.put("coverImage", null);
+        option.put("price", null);
+        option.put("memberPrice", null);
+        option.put("priceYuan", null);
+        option.put("memberPriceYuan", null);
+        option.put("effectScore", null);
+        option.put("reason", "平台暂无匹配商品,可自行购买后确认完成");
+        option.put("productType", null);
+        option.put("inPlatform", false);
+        option.put("quantity", 1);
+        return option;
+    }
+
+    private Object productIdOf(Map<String, Object> candidate) {
+        if (candidate == null) return null;
+        Object id = candidate.get("productId");
+        if (id == null) id = candidate.get("id");
+        return id;
+    }
+
+    private String formatYuan(Object cents) {
+        if (!(cents instanceof Number)) return null;
+        double yuan = ((Number) cents).doubleValue() / 100.0;
+        return String.format(java.util.Locale.US, "%.2f", yuan);
+    }
+
+    private Map<String, Object> taskBrief(Task task) {
+        Map<String, Object> brief = new LinkedHashMap<>();
+        brief.put("id", task.getId());
+        brief.put("title", task.getTitle());
+        brief.put("status", task.getStatus());
+        brief.put("category", task.getCategory());
+        brief.put("actionType", task.getActionType());
+        brief.put("actionConfig", task.getActionConfig());
+        brief.put("familyMemberId", task.getFamilyMemberId());
+        brief.put("prerequisiteTaskId", task.getPrerequisiteTaskId());
+        brief.put("isFlowTask", task.getIsFlowTask());
+        return brief;
+    }
+
+    /** 方案成员ID列表(family_members.id,去重保序) */
+    private List<Long> planMemberIds(HealthPlan plan) {
+        List<Long> memberIds = new ArrayList<>();
+        String csv = plan.getMemberIds();
+        if (csv == null || csv.trim().isEmpty()) {
+            return memberIds;
+        }
+        for (String raw : csv.split(",")) {
+            if (raw == null || raw.trim().isEmpty()) continue;
+            try {
+                Long resolved = resolveFamilyMemberId(Long.valueOf(raw.trim()), plan.getFamilyId());
+                if (resolved != null && !memberIds.contains(resolved)) {
+                    memberIds.add(resolved);
+                }
+            } catch (NumberFormatException ignored) {
+                // 忽略非法成员ID
+            }
+        }
+        return memberIds;
+    }
+
+    private Long firstPlanMemberId(HealthPlan plan) {
+        List<Long> memberIds = planMemberIds(plan);
+        return memberIds.isEmpty() ? null : memberIds.get(0);
+    }
+
+    private String firstDimension(HealthPlan plan) {
+        String dimensions = plan.getDimensions();
+        if (dimensions == null || dimensions.trim().isEmpty()) {
+            return "body";
+        }
+        String first = dimensions.split(",")[0].trim();
+        return first.isEmpty() ? "body" : first;
+    }
+
+    private String str(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+
+    private Long toLong(Object value) {
+        if (value == null) return null;
+        if (value instanceof Number) return ((Number) value).longValue();
+        try {
+            return Long.valueOf(String.valueOf(value).trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
     @Override
     @Override
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public Map<String, Object> deletePlanCascade(Long planId, Long familyId) {
     public Map<String, Object> deletePlanCascade(Long planId, Long familyId) {

+ 475 - 0
cfc-frontend/pages/health/components/ProductSelectionFlow.vue

@@ -0,0 +1,475 @@
+<template>
+  <view class="product-flow">
+    <view class="product-flow-head">
+      <view class="product-flow-head-left">
+        <text class="product-flow-title">🛒 产品多选一</text>
+        <text class="product-flow-sub">每类产品按效果评分与价格对比,确认后生成购买任务</text>
+      </view>
+      <text v-if="cartCount > 0" class="product-cart-badge" @click="goCart">购物车 {{ cartCount }}</text>
+    </view>
+
+    <view v-if="loading" class="product-hint">正在加载产品推荐...</view>
+    <view v-else-if="loadError" class="product-hint product-hint-error">{{ loadError }}</view>
+
+    <view v-else class="product-groups">
+      <view v-for="(g, gi) in groups" :key="gi" class="product-group">
+        <view class="product-group-head">
+          <text class="product-group-title">{{ g.categoryLabel }}</text>
+          <text class="product-group-count">多选一</text>
+        </view>
+        <view v-for="(o, oi) in g.options" :key="oi" class="product-option"
+              :class="{ 'product-option-on': selectedMap[g.category] === oi }"
+              @click="selectOption(g.category, oi)">
+          <view class="product-option-body">
+            <text class="product-option-name">{{ o.name }}</text>
+            <view class="product-option-tags">
+              <text v-if="o.effectScore !== null && o.effectScore !== undefined" class="product-tag tag-effect">效果 {{ o.effectScore }} 分</text>
+              <text v-if="o.priceYuan" class="product-tag tag-price">¥{{ o.priceYuan }}</text>
+              <text v-if="!o.inPlatform" class="product-tag tag-external">自选渠道</text>
+            </view>
+            <text v-if="o.reason" class="product-option-reason">{{ o.reason }}</text>
+          </view>
+          <view class="product-radio" :class="{ 'product-radio-on': selectedMap[g.category] === oi }"></view>
+        </view>
+      </view>
+
+      <view v-if="groups.length > 0 && purchaseTasks.length === 0" class="product-actions">
+        <button class="product-btn product-btn-primary" :disabled="confirming" @click="confirmSelection">
+          {{ confirming ? '生成中...' : '确认选择并生成购买任务' }}
+        </button>
+      </view>
+    </view>
+
+    <!-- 购买任务(R5) -->
+    <view v-if="purchaseTasks.length > 0" class="purchase-block">
+      <view class="purchase-head">
+        <text class="purchase-title">购买任务</text>
+        <text class="purchase-sub">本平台商品已加入购物车</text>
+      </view>
+      <view v-for="(t, ti) in purchaseTasks" :key="ti" class="purchase-item">
+        <view class="purchase-item-main">
+          <text class="purchase-item-name">{{ t.title }}</text>
+          <text class="purchase-item-tip">{{ t.actionType === 'cart' ? '已加入购物车,去结算完成购买' : '线下/其他渠道购买' }}</text>
+        </view>
+        <text class="purchase-item-status" :class="{ 'purchase-item-status-done': t.status === 'completed' }">
+          {{ t.status === 'completed' ? '已完成' : '待购买' }}
+        </text>
+      </view>
+      <view class="purchase-actions">
+        <button class="product-btn product-btn-ghost" @click="goCart">去购物车</button>
+        <button class="product-btn product-btn-primary" :disabled="completing" @click="completePurchase">
+          {{ completing ? '生成中...' : '我已完成购买' }}
+        </button>
+      </view>
+    </view>
+
+    <!-- 执行任务(R6) -->
+    <view v-if="executionTasks.length > 0" class="execution-block">
+      <view class="execution-head">
+        <text class="purchase-title">执行任务(按方案建议)</text>
+        <text class="purchase-sub">共 {{ executionTasks.length }} 项,购买任务完成后自动解锁</text>
+      </view>
+      <view v-for="(t, ti) in executionTasks" :key="ti" class="execution-item">
+        <text class="execution-item-name">{{ t.title }}</text>
+        <text v-if="t.category" class="execution-item-cat">{{ t.category }}</text>
+      </view>
+      <view class="execution-tip">可在「首页 - 任务」中查看并打卡</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getHealthPlanProductOptions, selectHealthPlanProducts, completeHealthPlanPurchase, cartCount as getCartCount } from '@/utils/api.js'
+
+export default {
+  name: 'ProductSelectionFlow',
+  props: {
+    planId: {
+      type: [Number, String],
+      default: null
+    }
+  },
+  data: function() {
+    return {
+      loading: false,
+      loadError: '',
+      groups: [],
+      // category -> 选中的选项下标(避免模板内调用带参方法)
+      selectedMap: {},
+      confirming: false,
+      completing: false,
+      purchaseTasks: [],
+      executionTasks: [],
+      cartCount: 0
+    }
+  },
+  watch: {
+    planId: function(val) {
+      if (val) {
+        this.loadOptions()
+      }
+    }
+  },
+  mounted: function() {
+    if (this.planId) {
+      this.loadOptions()
+    }
+    this.refreshCartCount()
+  },
+  methods: {
+    loadOptions: function() {
+      var self = this
+      if (!self.planId || self.loading) return
+      self.loading = true
+      self.loadError = ''
+      getHealthPlanProductOptions({ planId: self.planId }).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var groups = res.data instanceof Array ? res.data : []
+          self.groups = groups
+          self.defaultSelect(groups)
+        } else {
+          self.loadError = (res && res.message) || '产品推荐加载失败'
+        }
+      }).catch(function() {
+        self.loading = false
+        self.loadError = '产品推荐加载失败,请稍后重试'
+      })
+    },
+    defaultSelect: function(groups) {
+      var map = {}
+      for (var i = 0; i < groups.length; i++) {
+        var options = (groups[i] && groups[i].options) || []
+        map[groups[i].category] = options.length > 0 ? 0 : -1
+      }
+      this.selectedMap = map
+    },
+    // Vue 2 无法侦测对象新增属性,整体替换 selectedMap 保证视图刷新
+    selectOption: function(category, index) {
+      var map = {}
+      for (var key in this.selectedMap) {
+        if (Object.prototype.hasOwnProperty.call(this.selectedMap, key)) {
+          map[key] = this.selectedMap[key]
+        }
+      }
+      map[category] = index
+      this.selectedMap = map
+    },
+    buildSelections: function() {
+      var out = []
+      for (var i = 0; i < this.groups.length; i++) {
+        var group = this.groups[i]
+        var options = (group && group.options) || []
+        var idx = this.selectedMap[group.category]
+        if (idx === undefined || idx === null || idx < 0 || idx >= options.length) continue
+        var option = options[idx]
+        out.push({
+          category: group.category,
+          productId: option.productId,
+          name: option.name,
+          quantity: option.quantity || 1
+        })
+      }
+      return out
+    },
+    confirmSelection: function() {
+      var self = this
+      if (self.confirming) return
+      var selections = self.buildSelections()
+      if (selections.length === 0) {
+        uni.showToast({ title: '请为每类产品选择一项', icon: 'none' })
+        return
+      }
+      self.confirming = true
+      selectHealthPlanProducts({ planId: self.planId, selections: selections }).then(function(res) {
+        self.confirming = false
+        if (res && res.code === 200 && res.data) {
+          self.purchaseTasks = res.data.purchaseTasks || []
+          var added = res.data.cartAddCount || 0
+          self.cartCount = added
+          uni.showToast({
+            title: added > 0 ? '已加入购物车,请完成购买' : '购买任务已生成',
+            icon: 'none',
+            duration: 2000
+          })
+          self.$emit('purchase-created', res.data)
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成购买任务失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.confirming = false
+        uni.showToast({ title: '请求失败', icon: 'none' })
+      })
+    },
+    completePurchase: function() {
+      var self = this
+      if (self.completing) return
+      self.completing = true
+      uni.showLoading({ title: '生成执行任务...' })
+      completeHealthPlanPurchase({ planId: self.planId }).then(function(res) {
+        uni.hideLoading()
+        self.completing = false
+        if (res && res.code === 200 && res.data) {
+          self.executionTasks = res.data.executionTasks || []
+          for (var i = 0; i < self.purchaseTasks.length; i++) {
+            self.purchaseTasks[i].status = 'completed'
+          }
+          uni.showToast({
+            title: '执行任务已生成(' + (res.data.executionTaskCount || 0) + ' 项)',
+            icon: 'none',
+            duration: 2000
+          })
+          self.$emit('purchase-complete', res.data)
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成执行任务失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        self.completing = false
+        uni.showToast({ title: '请求失败', icon: 'none' })
+      })
+    },
+    refreshCartCount: function() {
+      var self = this
+      getCartCount().then(function(res) {
+        if (res && res.code === 200) {
+          self.cartCount = res.data || 0
+        }
+      }).catch(function() {})
+    },
+    goCart: function() {
+      uni.navigateTo({ url: '/pages/shop/cart/cart' })
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.product-flow {
+  margin-top: 24rpx;
+  padding: 24rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+}
+.product-flow-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+.product-flow-head-left {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+}
+.product-flow-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+.product-flow-sub {
+  margin-top: 6rpx;
+  font-size: 22rpx;
+  color: #6b7280;
+}
+.product-cart-badge {
+  padding: 6rpx 16rpx;
+  font-size: 22rpx;
+  color: #ffffff;
+  background: #f97316;
+  border-radius: 999rpx;
+}
+.product-hint {
+  padding: 32rpx 0;
+  font-size: 24rpx;
+  color: #6b7280;
+  text-align: center;
+}
+.product-hint-error {
+  color: #ef4444;
+}
+.product-groups {
+  margin-top: 16rpx;
+}
+.product-group {
+  margin-bottom: 24rpx;
+}
+.product-group-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12rpx;
+}
+.product-group-title {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #374151;
+}
+.product-group-count {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+.product-option {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx;
+  margin-bottom: 12rpx;
+  background: #f9fafb;
+  border: 2rpx solid #e5e7eb;
+  border-radius: 12rpx;
+}
+.product-option-on {
+  background: #fff7ed;
+  border-color: #f97316;
+}
+.product-option-body {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  margin-right: 16rpx;
+}
+.product-option-name {
+  font-size: 26rpx;
+  color: #1f2937;
+}
+.product-option-tags {
+  display: flex;
+  align-items: center;
+  margin-top: 8rpx;
+}
+.product-tag {
+  margin-right: 12rpx;
+  padding: 4rpx 12rpx;
+  font-size: 20rpx;
+  border-radius: 6rpx;
+}
+.tag-effect {
+  color: #2563eb;
+  background: #eff6ff;
+}
+.tag-price {
+  color: #dc2626;
+  background: #fef2f2;
+}
+.tag-external {
+  color: #6b7280;
+  background: #f3f4f6;
+}
+.product-option-reason {
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+.product-radio {
+  width: 32rpx;
+  height: 32rpx;
+  border: 2rpx solid #d1d5db;
+  border-radius: 50%;
+}
+.product-radio-on {
+  border-color: #f97316;
+  background: #f97316;
+}
+.product-actions {
+  display: flex;
+  align-items: center;
+  margin-top: 8rpx;
+}
+.product-btn {
+  flex: 1;
+  font-size: 26rpx;
+  border-radius: 12rpx;
+}
+.product-btn-primary {
+  color: #ffffff;
+  background: #f97316;
+}
+.product-btn-ghost {
+  color: #f97316;
+  background: #fff7ed;
+  border: 2rpx solid #f97316;
+}
+.purchase-block {
+  margin-top: 24rpx;
+  padding-top: 24rpx;
+  border-top: 2rpx solid #f3f4f6;
+}
+.purchase-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+}
+.purchase-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+.purchase-sub {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+.purchase-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16rpx 0;
+}
+.purchase-item-main {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  margin-right: 16rpx;
+}
+.purchase-item-name {
+  font-size: 26rpx;
+  color: #374151;
+}
+.purchase-item-tip {
+  margin-top: 4rpx;
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+.purchase-item-status {
+  font-size: 22rpx;
+  color: #f97316;
+}
+.purchase-item-status-done {
+  color: #10b981;
+}
+.purchase-actions {
+  display: flex;
+  align-items: center;
+  margin-top: 16rpx;
+}
+.execution-block {
+  margin-top: 24rpx;
+  padding-top: 24rpx;
+  border-top: 2rpx solid #f3f4f6;
+}
+.execution-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+}
+.execution-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14rpx 0;
+}
+.execution-item-name {
+  flex: 1;
+  font-size: 26rpx;
+  color: #374151;
+  margin-right: 16rpx;
+}
+.execution-item-cat {
+  font-size: 22rpx;
+  color: #6b7280;
+}
+.execution-tip {
+  margin-top: 12rpx;
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+</style>

+ 96 - 13
cfc-frontend/pages/health/health-plan-summary.vue

@@ -298,6 +298,9 @@
         <button class="btn-confirm full-btn" :disabled="loading" @click="submitPlan">{{ loading ? '保存中...' : '确认保存' }}</button>
         <button class="btn-confirm full-btn" :disabled="loading" @click="submitPlan">{{ loading ? '保存中...' : '确认保存' }}</button>
         <button class="btn-teacher" @click="goToTeacherService">选择规划师</button>
         <button class="btn-teacher" @click="goToTeacherService">选择规划师</button>
       </view>
       </view>
+      <!-- HL-05/R5:先保存方案(draft)→ 直接在此选择产品 → 生成购买任务 → 购买完成生成执行任务 -->
+      <view v-if="!planId" class="back-link" @click="savePlanForPurchase">先保存方案,立即选择产品 ›</view>
+      <product-selection-flow v-else :plan-id="planId" @purchase-created="onPurchaseCreated" @purchase-complete="onPurchaseComplete" />
       <view class="back-link" @click="startEdit">← 继续修改</view>
       <view class="back-link" @click="startEdit">← 继续修改</view>
     </view>
     </view>
 
 
@@ -317,11 +320,16 @@
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
         <text class="plan-preview-text">{{ planDetail.planContent }}</text>
       </view>
       </view>
 
 
+      <!-- HL-05/R5+R6:产品多选一(效果+价格)→ 购买任务/加购物车 → 购买完成 → 执行任务 -->
+      <product-selection-flow v-if="showProductFlow" :plan-id="planId"
+                              @purchase-created="onPurchaseCreated" @purchase-complete="onPurchaseComplete" />
+
       <!-- 待确认:点「确定」分解任务(任务在首页/任务中心查看,不在方案下方展示);「申请规划师」预留 -->
       <!-- 待确认:点「确定」分解任务(任务在首页/任务中心查看,不在方案下方展示);「申请规划师」预留 -->
       <view class="btn-pair plan-confirm-actions" v-if="needsTaskConfirm">
       <view class="btn-pair plan-confirm-actions" v-if="needsTaskConfirm">
         <button class="btn-confirm" :disabled="confirmingTasks" @click="confirmPlanTasks">{{ confirmingTasks ? '处理中...' : '确定' }}</button>
         <button class="btn-confirm" :disabled="confirmingTasks" @click="confirmPlanTasks">{{ confirmingTasks ? '处理中...' : '确定' }}</button>
         <button class="btn-teacher" @click="applyForTeacher">申请规划师</button>
         <button class="btn-teacher" @click="applyForTeacher">申请规划师</button>
       </view>
       </view>
+      <view v-if="needsTaskConfirm" class="product-skip-hint">也可使用上方「产品多选一」:逐类选产品 → 生成购买任务 → 购买完成后再生成执行任务</view>
     </view>
     </view>
 
 
     <!-- 成功遮罩 -->
     <!-- 成功遮罩 -->
@@ -341,13 +349,14 @@ import { generateHealthPlan, generatePlanAttribution, regenerateSection, adjustP
 import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership, saveHealthSignature } from '../../utils/api'
 import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership, saveHealthSignature } from '../../utils/api'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
 import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
 import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'
 import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'
+import ProductSelectionFlow from '@/pages/health/components/ProductSelectionFlow.vue'
 import { setSignaturePrefill } from '@/utils/signature-bus.js'
 import { setSignaturePrefill } from '@/utils/signature-bus.js'
 import { normalizeBowelForm } from '@/utils/bowel-form.js'
 import { normalizeBowelForm } from '@/utils/bowel-form.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
   mixins: [fontScaleMixin],
   mixins: [fontScaleMixin],
-  components: { MemberChipPicker, ComprehensiveSurvey },
+  components: { MemberChipPicker, ComprehensiveSurvey, ProductSelectionFlow },
   data() {
   data() {
     return {
     return {
       // 步骤控制
       // 步骤控制
@@ -405,7 +414,9 @@ export default {
       followUpAnswerInput: '',
       followUpAnswerInput: '',
       followUpVisible: false,
       followUpVisible: false,
       pendingFollowUpAnswers: null,
       pendingFollowUpAnswers: null,
-      followUpHandled: false
+      followUpHandled: false,
+      // HL-05/R5:产品多选一购买任务流程是否已启动(发布后仍展示购买/执行任务面板)
+      productStarted: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -418,6 +429,11 @@ export default {
     needsTaskConfirm: function() {
     needsTaskConfirm: function() {
       return !!(this.planDetail && this.planDetail.status === 'draft')
       return !!(this.planDetail && this.planDetail.status === 'draft')
     },
     },
+    // HL-05/R5+R6:草案方案或已进入购买流程时展示产品多选一/购买/执行任务面板
+    showProductFlow: function() {
+      if (!this.planId || !this.planDetail) return false
+      return this.planDetail.status === 'draft' || this.productStarted
+    },
     steps: function() { return ['选人', '问卷', '签名', '方案'] },
     steps: function() { return ['选人', '问卷', '签名', '方案'] },
     sectionKeys: function() { return ['nutrition', 'diet', 'exercise'] },
     sectionKeys: function() { return ['nutrition', 'diet', 'exercise'] },
     sectionTitles: function() { return ['营养补充建议', '饮食建议', '运动计划'] },
     sectionTitles: function() { return ['营养补充建议', '饮食建议', '运动计划'] },
@@ -1450,6 +1466,27 @@ selectedMembers: function() {
     submitPlan: function() {
     submitPlan: function() {
       var self = this
       var self = this
       self.loading = true
       self.loading = true
+      var saveData = self.buildSavePayload('pending_review')
+      // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
+      saveHealthPlan(saveData).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200) {
+          // 清理本次规划师选择缓存,避免下次生成方案时误用上一次的 teacherId
+          uni.removeStorageSync('healthPlan_teacherId')
+          uni.removeStorageSync('healthPlan_serviceType')
+          uni.showToast({ title: '方案已生成', icon: 'none', duration: 2000 })
+          setTimeout(function() { uni.navigateBack() }, 1200)
+          self.taskSubmitted = true
+        } else {
+          uni.showToast({ title: res && res.message ? res.message : '保存失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.loading = false
+        uni.showToast({ title: '请求失败', icon: 'none' })
+      })
+    },
+    buildSavePayload: function(status) {
+      var self = this
       var planJson = self.multiMemberPlanJson
       var planJson = self.multiMemberPlanJson
         ? JSON.stringify(self.multiMemberPlanJson)
         ? JSON.stringify(self.multiMemberPlanJson)
         : JSON.stringify({
         : JSON.stringify({
@@ -1460,32 +1497,62 @@ selectedMembers: function() {
         })
         })
       // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
       // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
       var teacherId = uni.getStorageSync('healthPlan_teacherId') || null
       var teacherId = uni.getStorageSync('healthPlan_teacherId') || null
-      var saveData = {
+      return {
+        planId: self.planId || null,
         memberIds: self.selectedMemberIds.join(','),
         memberIds: self.selectedMemberIds.join(','),
         dimensions: 'body',
         dimensions: 'body',
         goal: self.userGoal.trim(),
         goal: self.userGoal.trim(),
         planContent: self.buildPlanContent(),
         planContent: self.buildPlanContent(),
         planJson: planJson,
         planJson: planJson,
-        status: 'pending_review',
+        status: status || 'pending_review',
         teacherId: teacherId
         teacherId: teacherId
       }
       }
-      saveHealthPlan(saveData).then(function(res) {
+    },
+    // HL-05/R5:先保存为 draft 拿到 planId,页面内直接进入产品多选一(不跳转、不影响既有"确认保存")
+    savePlanForPurchase: function() {
+      var self = this
+      if (self.loading) return
+      self.loading = true
+      saveHealthPlan(self.buildSavePayload('draft')).then(function(res) {
         self.loading = false
         self.loading = false
-        if (res && res.code === 200) {
-          // 清理本次规划师选择缓存,避免下次生成方案时误用上一次的 teacherId
-          uni.removeStorageSync('healthPlan_teacherId')
-          uni.removeStorageSync('healthPlan_serviceType')
-          uni.showToast({ title: '方案已生成', icon: 'none', duration: 2000 })
-          setTimeout(function() { uni.navigateBack() }, 1200)
-          self.taskSubmitted = true
+        if (res && res.code === 200 && res.data) {
+          self.planId = res.data
+          self.productStarted = true
+          uni.showToast({ title: '方案已保存,请选择产品', icon: 'none' })
         } else {
         } else {
-          uni.showToast({ title: res && res.message ? res.message : '保存失败', icon: 'none' })
+          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
         }
         }
       }).catch(function() {
       }).catch(function() {
         self.loading = false
         self.loading = false
         uni.showToast({ title: '请求失败', icon: 'none' })
         uni.showToast({ title: '请求失败', icon: 'none' })
       })
       })
     },
     },
+    // HL-05/R5:购买任务已生成(含加购物车结果)
+    onPurchaseCreated: function(data) {
+      this.productStarted = true
+      var planMemberId = this.getPlanMemberId(null)
+      if (planMemberId) {
+        uni.setStorageSync('currentMemberId', planMemberId)
+        uni.setStorageSync('currentChildId', planMemberId)
+      }
+      if (this.viewMode) {
+        this.loadPlanDetail()
+      }
+    },
+    // HL-05/R6:购买完成 → 执行任务已生成
+    onPurchaseComplete: function(data) {
+      this.productStarted = true
+      if (this.viewMode) {
+        this.loadPlanDetail()
+      }
+      var count = (data && data.executionTaskCount) || 0
+      uni.showModal({
+        title: '提示',
+        content: count > 0 ? ('已生成 ' + count + ' 项执行任务,请前往首页-任务中打卡') : '执行任务已生成,请前往首页-任务中打卡',
+        showCancel: false,
+        confirmText: '知道了'
+      })
+    },
     buildPlanContent: function() {
     buildPlanContent: function() {
       var sb = []
       var sb = []
       if (this.planData.overview) {
       if (this.planData.overview) {
@@ -1510,6 +1577,15 @@ selectedMembers: function() {
       getHealthPlanDetail({ planId: self.planId }).then(function(res) {
       getHealthPlanDetail({ planId: self.planId }).then(function(res) {
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           self.planDetail = res.data
           self.planDetail = res.data
+          // HL-05/R5:已进入购买流程(存在购买任务)→ 继续展示购买/执行任务面板
+          if (res.data.tasks && res.data.tasks.length > 0) {
+            for (var ti = 0; ti < res.data.tasks.length; ti++) {
+              if (res.data.tasks[ti] && res.data.tasks[ti].category === '购买任务') {
+                self.productStarted = true
+                break
+              }
+            }
+          }
           self.userGoal = res.data.goal != null ? String(res.data.goal) : ''
           self.userGoal = res.data.goal != null ? String(res.data.goal) : ''
             if (res.data.memberIds) {
             if (res.data.memberIds) {
             self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id }).map(function(id) { return String(parseInt(id) || id) })
             self.selectedMemberIds = res.data.memberIds.split(',').filter(function(id) { return id }).map(function(id) { return String(parseInt(id) || id) })
@@ -2027,6 +2103,13 @@ selectedMembers: function() {
 .plan-confirm-actions {
 .plan-confirm-actions {
   margin-bottom: 20rpx;
   margin-bottom: 20rpx;
 }
 }
+/* HL-05/R5:产品多选一与「确定」直接分解任务并存时的提示 */
+.product-skip-hint {
+  margin-top: 8rpx;
+  font-size: calc(22rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  line-height: 1.6;
+}
 
 
 /* 成功遮罩 */
 /* 成功遮罩 */
 .success-overlay {
 .success-overlay {

+ 4 - 0
cfc-frontend/utils/api.js

@@ -2657,6 +2657,10 @@ export const getHealthPlanList = (data) => request('/api/health/plan/list', 'POS
 export const getHealthPlanDetail = (data) => request('/api/health/plan/detail', 'POST', data)
 export const getHealthPlanDetail = (data) => request('/api/health/plan/detail', 'POST', data)
 export const getHealthPlanTasks = (data) => request('/api/health/plan/tasks', 'POST', data)
 export const getHealthPlanTasks = (data) => request('/api/health/plan/tasks', 'POST', data)
 export const deleteHealthPlan = (data) => request('/api/health/plan/delete', 'POST', data)
 export const deleteHealthPlan = (data) => request('/api/health/plan/delete', 'POST', data)
+// HL-05/R5+R6:产品多选一 → 购买任务(平台产品加购物车)→ 购买完成生成执行任务
+export const getHealthPlanProductOptions = (data) => request('/api/health/plan/product-options', 'POST', data)
+export const selectHealthPlanProducts = (data) => request('/api/health/plan/product-select', 'POST', data)
+export const completeHealthPlanPurchase = (data) => request('/api/health/plan/purchase-complete', 'POST', data)
 
 
 // ===== 閭€璇烽噷绋嬬 =====
 // ===== 閭€璇烽噷绋嬬 =====
 export const getInviteMilestones = () => {
 export const getInviteMilestones = () => {

File diff suppressed because it is too large
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


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

@@ -1957,6 +1957,22 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 
 *文档更新:2026-11-08(HL-03 R2+R3 归因方案 + 不确定点对话补充,见 `specs/2026-11-07-health-plan-flow-rework-change.md`)*
 *文档更新:2026-11-08(HL-03 R2+R3 归因方案 + 不确定点对话补充,见 `specs/2026-11-07-health-plan-flow-rework-change.md`)*
 
 
+### 4.49.3 方案产品多选一 → 购买任务 → 执行任务(`/api/health/plan/product-options`、`/product-select`、`/purchase-complete`)
+
+需求来源 `specs/2026-11-07-health-plan-flow-rework-change.md`(R5/R6,任务号 HL-05)。产品选择结果持久化在 `health_plans.plan_json.productSelection`,**不新增表/列**。
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/health/plan/product-options` | 登录用户(JWT),按 `familyId` 校验方案归属 | `{planId}` | `Result<List<group>>`,`group={category,categoryLabel,dimensionCode,options[]}`;`options[]={productId,name,coverImage,price,memberPrice,priceYuan,memberPriceYuan,effectScore,reason,inPlatform,quantity}` |
+| `POST /api/health/plan/product-select` | 同上 | `{planId, selections:[{category,productId?,name?,quantity?,skuId?}]}` | `Result<{planId,purchaseTasks[],cartItems[],selections[],cartAddCount}>` |
+| `POST /api/health/plan/purchase-complete` | 同上 | `{planId, purchaseTaskId?}` | `Result<{planId,purchaseTaskIds[],executionTasks[],executionTaskCount}>` |
+
+- 品类来自 `plan_json.sections[key=nutrition].items[].name`(多人方案取 `common` + `individual.<memberId>`);平台无匹配商品时该组追加「线下/其他渠道自选」兜底项(`inPlatform=false`)。选项的价格/效果分取自 `ProductRecommendationService.getDimensionRecommendations`(`price` 分为单位、`matchScore` 即效果分)。
+- `product-select`:每位方案成员各生成一条 `category=购买任务`、`title=购买:<产品名>`、`sourceType=health_plan`、`isFlowTask=1`、`needReview=0` 的任务;本平台商品首次确认时调用 `CartService.addToCart`(幂等:同名购买任务已存在则不再加购);方案状态置 `published`。
+- `purchase-complete`:购买任务置 `completed` 并写 `successorTaskIds`,按方案建议为各成员生成执行任务(`prerequisiteTaskId`/`parentTaskId` 指向购买任务;一次性任务写当天实例,周期任务写 `is_template=1` 模板行由 `RepeatTaskGenerator` 生成实例);不传 `purchaseTaskId` 时处理该方案下全部购买任务,方案下无购买任务则直接按方案生成执行任务。
+
+*文档更新:2026-11-08(HL-05 R5+R6 产品多选一购买任务 + 购买完成生成执行任务)*
+
 ### 4.50 活动海报模板(`/api/admin/poster-template/*` + `/api/poster-template/*`)
 ### 4.50 活动海报模板(`/api/admin/poster-template/*` + `/api/poster-template/*`)
 
 
 Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 表),小程序端活动详情页「生成海报」拉取可用模板渲染画布。管理端接口校验 `@RequestAttribute("role")`,仅 `admin`/`operator` 可访问;`templateKey` 限定 `gradient`(渐变)/`minimal`(深色)/`card`(白底卡片),`name` 全局唯一。
 Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 表),小程序端活动详情页「生成海报」拉取可用模板渲染画布。管理端接口校验 `@RequestAttribute("role")`,仅 `admin`/`operator` 可访问;`templateKey` 限定 `gradient`(渐变)/`minimal`(深色)/`card`(白底卡片),`name` 全局唯一。

Some files were not shown because too many files changed in this diff