|
|
@@ -314,6 +314,9 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
/**
|
|
|
* 解析方案文本,为每个任务项创建当天的 Task 记录
|
|
|
*/
|
|
|
+ /** 方案周期缺省天数(D3) */
|
|
|
+ private static final int DEFAULT_PLAN_DAYS = 14;
|
|
|
+
|
|
|
private void generateDailyTasksFromPlan(HealthPlan plan) {
|
|
|
String content = plan.getPlanContent();
|
|
|
List<String> rawLines = new java.util.ArrayList<>();
|
|
|
@@ -336,12 +339,14 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
Long childId = familyMemberId;
|
|
|
Long executorId = familyMemberId;
|
|
|
|
|
|
- // 当天截止 23:59:59(严格落在今日,避免存成次日 00:00 导致今日任务查不到)
|
|
|
- Date deadline = buildTodayDeadline();
|
|
|
-
|
|
|
Date now = new Date();
|
|
|
|
|
|
- // 统一生成:structured 分支直接用草稿;regex 分支按行分类
|
|
|
+ // 方案周期终点(D3):由 health_plans.total_days 决定,缺省 14 天。
|
|
|
+ // 既作为周期任务模板的截止日期(其时分=每日截止时刻),也作为周期生成的终止边界。
|
|
|
+ int totalDays = (plan.getTotalDays() != null && plan.getTotalDays() > 0)
|
|
|
+ ? plan.getTotalDays() : DEFAULT_PLAN_DAYS;
|
|
|
+ Date planEnd = buildPlanEnd(now, totalDays);
|
|
|
+
|
|
|
List<TaskDraft> drafts = new java.util.ArrayList<>();
|
|
|
if (usedStructured) {
|
|
|
drafts = structuredDrafts;
|
|
|
@@ -354,95 +359,145 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 购买→使用子任务链的父任务ID(一次性购买任务完成后派生每日使用/服用)
|
|
|
+ // 1) 先落一次性购买任务,作为服务任务的依赖锚点(D1)
|
|
|
+ // 依赖必须"逐项配对":每个使用任务绑定它前面最近的购买任务。
|
|
|
+ // 若所有使用任务共用首个采购任务,多商品场景闸门语义就是错的(E2E 实跑发现)。
|
|
|
Long buyTaskId = null;
|
|
|
-
|
|
|
+ List<Long> draftAnchors = new java.util.ArrayList<Long>();
|
|
|
for (TaskDraft draft : drafts) {
|
|
|
- // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
|
|
|
- Calendar todayStart = Calendar.getInstance();
|
|
|
- todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
|
|
|
- todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
|
|
|
- Calendar todayEnd = (Calendar) todayStart.clone();
|
|
|
- todayEnd.add(Calendar.DAY_OF_MONTH, 1);
|
|
|
-
|
|
|
- boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
|
|
|
- .eq("family_id", familyId)
|
|
|
- .eq("source_type", "health_plan")
|
|
|
- .eq("source_id", plan.getId())
|
|
|
- .eq("title", draft.title)
|
|
|
- .ge("deadline", todayStart.getTime())
|
|
|
- .lt("deadline", todayEnd.getTime())) > 0;
|
|
|
- if (exists) {
|
|
|
- if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
|
|
|
- Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
|
|
|
- .eq("family_id", familyId)
|
|
|
- .eq("source_type", "health_plan")
|
|
|
- .eq("source_id", plan.getId())
|
|
|
- .eq("title", draft.title)
|
|
|
- .eq("category", "购买任务")
|
|
|
- .last("LIMIT 1"));
|
|
|
- if (existingBuy != null) buyTaskId = existingBuy.getId();
|
|
|
- }
|
|
|
+ if (!"购买任务".equals(draft.categoryLabel)) {
|
|
|
+ // 继承最近的购买锚点,供紧随其后的使用任务绑定
|
|
|
+ draftAnchors.add(buyTaskId);
|
|
|
continue;
|
|
|
}
|
|
|
-
|
|
|
- String repeatType = "once".equals(draft.frequency) ? "none" : "daily";
|
|
|
- String taskType = "once".equals(draft.frequency) ? "onetime" : "recurring";
|
|
|
- String freq = "once".equals(draft.frequency) ? null : "daily";
|
|
|
-
|
|
|
- Task task = new Task();
|
|
|
- task.setFamilyId(familyId);
|
|
|
- task.setFamilyMemberId(familyMemberId);
|
|
|
- task.setChildId(childId);
|
|
|
- task.setExecutorType("child"); // 非登录用户(不可登录成员)
|
|
|
- task.setExecutorId(executorId);
|
|
|
- task.setCreatorId(executorId != null ? executorId : 0L);
|
|
|
- task.setTitle(draft.title);
|
|
|
- task.setDescription("");
|
|
|
- task.setPoints(10);
|
|
|
- task.setDeadline(deadline);
|
|
|
+ Task existing = findPlanTask(plan, familyId, draft.title, "购买任务", 0);
|
|
|
+ if (existing != null) {
|
|
|
+ buyTaskId = existing.getId();
|
|
|
+ draftAnchors.add(existing.getId());
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
|
|
|
+ task.setRepeatType("none");
|
|
|
+ task.setTaskType("onetime");
|
|
|
+ task.setFrequency(null);
|
|
|
+ task.setIsTemplate(0);
|
|
|
+ task.setCategory(draft.categoryLabel);
|
|
|
+ task.setDimensionCode(draft.dimensionCode);
|
|
|
+ task.setDeadline(buildTodayDeadline());
|
|
|
task.setStatus("pending");
|
|
|
- task.setSourceType("health_plan");
|
|
|
- task.setSourceId(plan.getId());
|
|
|
- task.setNeedReview(1);
|
|
|
- task.setCreatedAt(now);
|
|
|
- task.setUpdatedAt(now);
|
|
|
-
|
|
|
- if ("购买任务".equals(draft.categoryLabel)) {
|
|
|
- task.setRepeatType(repeatType);
|
|
|
- task.setTaskType(taskType);
|
|
|
- task.setFrequency(freq);
|
|
|
- task.setCategory(draft.categoryLabel);
|
|
|
- task.setDimensionCode(draft.dimensionCode);
|
|
|
- taskMapper.insert(task);
|
|
|
- buyTaskId = task.getId();
|
|
|
- log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
|
|
|
- } else if ("使用任务".equals(draft.categoryLabel)) {
|
|
|
- task.setRepeatType(repeatType);
|
|
|
- task.setTaskType(taskType);
|
|
|
- task.setFrequency(freq);
|
|
|
- task.setCategory(draft.categoryLabel);
|
|
|
- task.setDimensionCode(draft.dimensionCode);
|
|
|
- task.setParentTaskId(buyTaskId != null ? buyTaskId : draft.parentTaskId);
|
|
|
- taskMapper.insert(task);
|
|
|
- log.info("方案{}生成每日使用子任务: [{}] {} (parent={})",
|
|
|
- plan.getId(), draft.categoryLabel, draft.title, task.getParentTaskId());
|
|
|
- } else {
|
|
|
- task.setRepeatType(repeatType);
|
|
|
- task.setTaskType(taskType);
|
|
|
- task.setFrequency(freq);
|
|
|
+ taskMapper.insert(task);
|
|
|
+ buyTaskId = task.getId();
|
|
|
+ draftAnchors.add(task.getId());
|
|
|
+ log.info("方案{}生成购买任务(一次性): [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 再落其余任务:周期任务写成模板(D2),一次性任务仍是当天实例
|
|
|
+ for (int i = 0; i < drafts.size(); i++) {
|
|
|
+ TaskDraft draft = drafts.get(i);
|
|
|
+ if ("购买任务".equals(draft.categoryLabel)) continue;
|
|
|
+ Long draftAnchor = (i < draftAnchors.size()) ? draftAnchors.get(i) : buyTaskId;
|
|
|
+ boolean once = "once".equals(draft.frequency);
|
|
|
+
|
|
|
+ if (once) {
|
|
|
+ if (findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 0) != null) continue;
|
|
|
+ Task task = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
|
|
|
+ task.setRepeatType("none");
|
|
|
+ task.setTaskType("onetime");
|
|
|
+ task.setFrequency(null);
|
|
|
+ task.setIsTemplate(0);
|
|
|
task.setCategory(draft.categoryLabel);
|
|
|
task.setDimensionCode(draft.dimensionCode);
|
|
|
+ task.setDeadline(buildTodayDeadline());
|
|
|
+ task.setStatus("pending");
|
|
|
taskMapper.insert(task);
|
|
|
- log.info("方案{}生成任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
|
|
|
+ log.info("方案{}生成一次性任务: [{}] {}", plan.getId(), draft.categoryLabel, draft.title);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 周期任务 -> 模板行(is_template=1),交 RepeatTaskGenerator 按日生成实例。
|
|
|
+ // 模板 deadline=planEnd(未来日期),因此不会出现在"今日任务"列表里(列表按 deadline=当天过滤),
|
|
|
+ // 用户只会看到生成出来的实例,天然避免模板/实例重复展示。
|
|
|
+ Task existingTpl = findPlanTask(plan, familyId, draft.title, draft.categoryLabel, 1);
|
|
|
+ if (existingTpl != null) {
|
|
|
+ // 模板已存在:仅在依赖缺失时补写(修复存量方案已生成的模板)
|
|
|
+ if ("使用任务".equals(draft.categoryLabel)
|
|
|
+ && existingTpl.getPrerequisiteTaskId() == null && draftAnchor != null) {
|
|
|
+ existingTpl.setPrerequisiteTaskId(draftAnchor);
|
|
|
+ existingTpl.setParentTaskId(draftAnchor);
|
|
|
+ taskMapper.updateById(existingTpl);
|
|
|
+ log.info("方案{}补写使用任务依赖: tplId={} -> buy={}",
|
|
|
+ plan.getId(), existingTpl.getId(), draftAnchor);
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ Task tpl = newPlanTask(plan, familyId, familyMemberId, childId, executorId, draft, now);
|
|
|
+ tpl.setRepeatType("daily");
|
|
|
+ tpl.setTaskType("recurring");
|
|
|
+ tpl.setFrequency("daily");
|
|
|
+ tpl.setIsTemplate(1);
|
|
|
+ tpl.setCategory(draft.categoryLabel);
|
|
|
+ tpl.setDimensionCode(draft.dimensionCode);
|
|
|
+ tpl.setDeadline(planEnd);
|
|
|
+ tpl.setStatus("pending");
|
|
|
+ if ("使用任务".equals(draft.categoryLabel)) {
|
|
|
+ Long anchor = (draftAnchor != null) ? draftAnchor : draft.parentTaskId;
|
|
|
+ // D1: 真正的依赖闸门。生成器按前置完成状态决定实例 locked/pending,
|
|
|
+ // 完成后 TaskService.unlockPrerequisiteTasks 解锁 -> "采购完成自动激活服务任务"
|
|
|
+ tpl.setPrerequisiteTaskId(anchor);
|
|
|
+ tpl.setParentTaskId(anchor);
|
|
|
}
|
|
|
+ taskMapper.insert(tpl);
|
|
|
+ log.info("方案{}生成周期任务模板: [{}] {} (freq=daily, 截止={}, prereq={})",
|
|
|
+ plan.getId(), draft.categoryLabel, draft.title, planEnd, tpl.getPrerequisiteTaskId());
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 从 plan_json.sections[].tasks 构建任务草稿。返回 tasks 草稿列表;
|
|
|
- * 若某 section 无 tasks,则用行分类逻辑兜底并回填 plan_json(懒加载),落库。
|
|
|
- */
|
|
|
+ /** 方案周期终点:今天 + totalDays 的 23:59:59 */
|
|
|
+ private Date buildPlanEnd(Date now, int totalDays) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(now);
|
|
|
+ cal.add(Calendar.DAY_OF_MONTH, totalDays);
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 23);
|
|
|
+ cal.set(Calendar.MINUTE, 59);
|
|
|
+ cal.set(Calendar.SECOND, 59);
|
|
|
+ cal.set(Calendar.MILLISECOND, 0);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 幂等查找:同一方案下的同名任务,templateOnly=true 只找模板行 */
|
|
|
+ private Task findPlanTask(HealthPlan plan, Long familyId, String title, String category, int templateOnly) {
|
|
|
+ QueryWrapper<Task> qw = new QueryWrapper<Task>()
|
|
|
+ .eq("family_id", familyId)
|
|
|
+ .eq("source_type", "health_plan")
|
|
|
+ .eq("source_id", plan.getId())
|
|
|
+ .eq("title", title)
|
|
|
+ .eq("is_template", templateOnly);
|
|
|
+ if (category != null) qw.eq("category", category);
|
|
|
+ return taskMapper.selectOne(qw.last("LIMIT 1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 方案任务公共字段 */
|
|
|
+ private Task newPlanTask(HealthPlan plan, Long familyId, Long familyMemberId, Long childId,
|
|
|
+ Long executorId, TaskDraft draft, Date now) {
|
|
|
+ Task task = new Task();
|
|
|
+ task.setFamilyId(familyId);
|
|
|
+ task.setFamilyMemberId(familyMemberId);
|
|
|
+ task.setChildId(childId);
|
|
|
+ task.setExecutorType("child");
|
|
|
+ task.setExecutorId(executorId);
|
|
|
+ task.setCreatorId(executorId != null ? executorId : 0L);
|
|
|
+ task.setTitle(draft.title);
|
|
|
+ task.setDescription("");
|
|
|
+ task.setPoints(10);
|
|
|
+ task.setSourceType("health_plan");
|
|
|
+ task.setSourceId(plan.getId());
|
|
|
+ task.setNeedReview(1);
|
|
|
+ task.setCreatedAt(now);
|
|
|
+ task.setUpdatedAt(now);
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
|
|
|
List<TaskDraft> drafts = new java.util.ArrayList<>();
|
|
|
String planJsonStr = plan.getPlanJson();
|
|
|
@@ -514,6 +569,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
case "checkin": cat = "打卡任务"; dim = "mind"; freq = "daily"; break;
|
|
|
case "diet": cat = "饮食任务"; dim = "body"; freq = "daily"; break;
|
|
|
case "activity": cat = "活动任务"; dim = "action"; freq = "daily"; break;
|
|
|
+ // D6: 结构化方案必须能表达"商品服务任务",否则购买→使用依赖链在结构化路径彻底丢失
|
|
|
+ case "use": cat = "使用任务"; dim = "body"; freq = "daily"; break;
|
|
|
default: return null;
|
|
|
}
|
|
|
// dimension/frequency 显式值优先覆盖默认
|
|
|
@@ -534,6 +591,8 @@ public class HealthPlanServiceImpl implements HealthPlanService {
|
|
|
case "打卡任务": return "checkin";
|
|
|
case "饮食任务": return "diet";
|
|
|
case "活动任务": return "activity";
|
|
|
+ // D6: 懒回填必须保真,否则 action_type="" 会让使用任务在下次解析时被丢弃
|
|
|
+ case "使用任务": return "use";
|
|
|
default: return "";
|
|
|
}
|
|
|
}
|