|
|
@@ -10,6 +10,7 @@ import com.etotem.cfc.entity.FamilyMember;
|
|
|
import com.etotem.cfc.entity.FiveDimensionSelfCheck;
|
|
|
import com.etotem.cfc.entity.HealthPlan;
|
|
|
import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.entity.Product;
|
|
|
import com.etotem.cfc.entity.Task;
|
|
|
import com.etotem.cfc.entity.Family;
|
|
|
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.HealthPlanMapper;
|
|
|
import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
+import com.etotem.cfc.mapper.ProductMapper;
|
|
|
import com.etotem.cfc.mapper.TaskMapper;
|
|
|
import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.dto.CartItemDTO;
|
|
|
import com.etotem.cfc.service.AttributionAnalysisService;
|
|
|
+import com.etotem.cfc.service.CartService;
|
|
|
import com.etotem.cfc.service.HealthPlanService;
|
|
|
+import com.etotem.cfc.service.ProductRecommendationService;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
import org.springframework.http.ResponseEntity;
|
|
|
@@ -35,11 +40,16 @@ import java.util.ArrayList;
|
|
|
import java.util.Calendar;
|
|
|
import java.util.Date;
|
|
|
import java.util.HashMap;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.Iterator;
|
|
|
import java.util.LinkedHashMap;
|
|
|
+import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
+import java.util.Set;
|
|
|
import java.util.regex.Matcher;
|
|
|
import java.util.regex.Pattern;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
|
|
@@ -80,6 +90,17 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
@Resource
|
|
|
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:}")
|
|
|
private String langgraphBaseUrl;
|
|
|
|
|
|
@@ -220,6 +241,673 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
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
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
public Map<String, Object> deletePlanCascade(Long planId, Long familyId) {
|