diff --git a/app/Models/ProjectTask.php b/app/Models/ProjectTask.php index f279fe3a4..544cbb234 100644 --- a/app/Models/ProjectTask.php +++ b/app/Models/ProjectTask.php @@ -1177,8 +1177,10 @@ class ProjectTask extends AbstractModel $this->loop_at = null; break; default: - if (Base::isNumber($this->loop)) { - $this->loop_at = $base->addDays($this->loop); + // 自定义天数须为正整数:Carbon 3 的 addDays 不接受字符串,0 天会让新任务立刻再次命中周期 + $days = Base::isNumber($this->loop) ? (int)$this->loop : 0; + if ($days > 0) { + $this->loop_at = $base->addDays($days); } else { $this->loop_at = null; $success = false; diff --git a/app/Tasks/LoopTask.php b/app/Tasks/LoopTask.php index 28dc6accb..50a751bf2 100644 --- a/app/Tasks/LoopTask.php +++ b/app/Tasks/LoopTask.php @@ -2,6 +2,8 @@ namespace App\Tasks; +use App\Exceptions\ApiException; +use App\Models\AbstractModel; use App\Module\ProjectTaskHandoffRecord; use App\Models\ProjectFlow; use App\Models\ProjectFlowItem; @@ -21,7 +23,9 @@ class LoopTask extends AbstractTask public function start() { - ProjectTask::whereBetween('loop_at', [ + // 只处理本轮开始前已存在的任务,本轮新生成的任务不会在同一轮里被再次扫到 + $maxId = (int)ProjectTask::withTrashed()->max('id'); + ProjectTask::where('id', '<=', $maxId)->whereBetween('loop_at', [ Carbon::now()->subMinutes(10), Carbon::now() ])->whereHas('project', function ($query) { @@ -35,54 +39,7 @@ class LoopTask extends AbstractTask continue; } try { - $task = $item->copyTask(); - ProjectTaskHandoffRecord::track($task, 'recurring', function () use ($task, $item) { - // 工作流 - $projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first(); - if ($projectFlow) { - $projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get(); - // 赋一个开始状态 - foreach ($projectFlowItem as $flowItem) { - if ($flowItem->status == 'start') { - $task->flow_item_id = $flowItem->id; - $task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color; - if ($flowItem->userids) { - $userids = array_values(array_unique($flowItem->userids)); - foreach ($userids as $uid) { - ProjectTaskUser::updateInsert([ - 'task_id' => $task->id, - 'userid' => $uid, - ], [ - 'project_id' => $task->project_id, - 'task_pid' => $task->id, - 'owner' => 1, - ]); - } - } - break; - } - } - } - // 新任务时间、周期 - if ($task->start_at) { - $diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true); - $task->start_at = Carbon::parse($task->loop_at); - $task->end_at = $task->start_at->clone()->addSeconds($diffSecond); - } - // 处理子任务 - $item->copySubTasks($task, [ - 'reset_complete' => true, - 'sync_time' => true, - ]); - // - $task->refreshLoop(true); - $task->addLog("创建任务来自周期任务ID:{$item->id}", [], $task->userid); - }); - // 清空旧周期 - $item->loop = ''; - $item->loop_at = null; - $item->save(); - $item->addLog("已创建新的周期任务ID:{$task->id},此任务关闭周期", [], $task->userid); + $this->createLoopTask($item); } catch (\Throwable $e) { $item->addLog("生成重复任务失败:" . $e->getMessage(), [], $item->userid); } @@ -90,6 +47,75 @@ class LoopTask extends AbstractTask }); } + /** + * 按周期生成新任务并关闭原任务周期 + * 复制、刷新周期、关闭旧周期在同一事务内,任一步失败整体回滚,不会留下 loop_at 仍在窗口内的残留副本 + * @param ProjectTask $item + */ + private function createLoopTask(ProjectTask $item) + { + AbstractModel::transaction(function () use ($item) { + // 锁定并复核原任务:已被并发或上一轮处理(loop_at 已变化/清空)则跳过 + $source = ProjectTask::whereKey($item->id)->lockForUpdate()->first(); + if (!$source + || $source->parent_id > 0 + || !$source->loop_at + || Carbon::parse($source->loop_at)->toDateTimeString() !== Carbon::parse($item->loop_at)->toDateTimeString()) { + return; + } + $task = $source->copyTask(); + ProjectTaskHandoffRecord::track($task, 'recurring', function () use ($task, $source) { + // 工作流 + $projectFlow = ProjectFlow::whereProjectId($task->project_id)->orderByDesc('id')->first(); + if ($projectFlow) { + $projectFlowItem = ProjectFlowItem::whereFlowId($projectFlow->id)->orderBy('sort')->get(); + // 赋一个开始状态 + foreach ($projectFlowItem as $flowItem) { + if ($flowItem->status == 'start') { + $task->flow_item_id = $flowItem->id; + $task->flow_item_name = $flowItem->status . "|" . $flowItem->name . "|" . $flowItem->color; + if ($flowItem->userids) { + $userids = array_values(array_unique($flowItem->userids)); + foreach ($userids as $uid) { + ProjectTaskUser::updateInsert([ + 'task_id' => $task->id, + 'userid' => $uid, + ], [ + 'project_id' => $task->project_id, + 'task_pid' => $task->id, + 'owner' => 1, + ]); + } + } + break; + } + } + } + // 新任务时间、周期 + if ($task->start_at) { + $diffSecond = (int)Carbon::parse($task->start_at)->diffInSeconds(Carbon::parse($task->end_at), true); + $task->start_at = Carbon::parse($task->loop_at); + $task->end_at = $task->start_at->clone()->addSeconds($diffSecond); + } + // 处理子任务 + $source->copySubTasks($task, [ + 'reset_complete' => true, + 'sync_time' => true, + ]); + // 刷新新任务的下个周期,失败则抛出使整体回滚(否则副本会带着旧 loop_at 被再次复制) + if (!$task->refreshLoop(true)) { + throw new ApiException('重复周期设置无效'); + } + $task->addLog("创建任务来自周期任务ID:{$source->id}", [], $task->userid); + }); + // 清空旧周期 + $source->loop = ''; + $source->loop_at = null; + $source->save(); + $source->addLog("已创建新的周期任务ID:{$task->id},此任务关闭周期", [], $task->userid); + }); + } + public function end() { diff --git a/language/original-api.txt b/language/original-api.txt index f3da919f7..6ce4db35e 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -1081,3 +1081,4 @@ WebDAV 未启用或你没有使用权限 流转设置选项无效 指派成功 任务负责人最多不能超过10个 +重复周期设置无效 diff --git a/resources/ai-kb/zh/concept/task/deadline.md b/resources/ai-kb/zh/concept/task/deadline.md index 48e02e30c..8e5299faf 100644 --- a/resources/ai-kb/zh/concept/task/deadline.md +++ b/resources/ai-kb/zh/concept/task/deadline.md @@ -16,9 +16,9 @@ related_pages: [task_detail, calendar] prerequisites: [] negative: - DooTask 任务没有 time_estimate / time_log 字段,不能记录工时 - - 不支持只设开始时间不设结束时间(end_at 是计算到期与循环周期的依赖) + - 不支持只设开始时间不设结束时间(end_at 是计算到期的依赖) - 跨时区显示按用户所在时区,但服务端统一存 UTC -last_verified: v1.7.90 +last_verified: v1.9.36 --- # 任务计划时间(start_at / end_at) @@ -28,7 +28,7 @@ DooTask 任务用两个字段描述时间: - `start_at`:计划开始时间 - `end_at`:计划截止时间(俗称 deadline) -两者都可空。**填入 end_at 才能启用循环重复、到期提醒、日历展示。** +两者都可空。**填入 end_at 才能启用到期提醒、日历展示。** 重复循环不要求填 end_at。 ## 在哪里能看到 - 任务详情页 → 「计划时间」字段 @@ -42,12 +42,11 @@ DooTask 任务用两个字段描述时间: - 全天任务则 end_at 自动设为当天 23:59:59 ## 与其他字段联动 -- **重复循环**([[task.recurring.howto]])依赖 end_at 计算下一次 loop_at +- **重复循环**([[task.recurring.howto]])以 start_at 为基准计算下一次 loop_at,不依赖 end_at;新任务沿用原任务的时长 - **到期提醒**(ProjectTaskPushLog)按 end_at 触发推送(默认到期日推送、超期再推送) - **日历视图** 只显示有 start_at 或 end_at 的任务 - **完成时间**(complete_at)与计划时间无关,是否超期由 complete_at vs end_at 比较 ## 不支持 -- 不能只设 start_at 不设 end_at 来启用循环 - 不能录工时(time_estimate / time_log 字段不存在) - 修改 end_at 不会回填已生成的循环任务实例 diff --git a/resources/ai-kb/zh/howto/task/complete.md b/resources/ai-kb/zh/howto/task/complete.md index 2cdaaaf32..ee3eddad0 100644 --- a/resources/ai-kb/zh/howto/task/complete.md +++ b/resources/ai-kb/zh/howto/task/complete.md @@ -19,8 +19,8 @@ prerequisites: negative: - 父任务完成不会自动完成所有子任务,需要手动或 [[task.subtask.howto.create]] 后逐个处理 - 完成不等于归档,归档要走 [[task.archive.howto]] - - 重复任务在完成的瞬间生成下一份,详见 [[task.recurring.howto]] -last_verified: v1.7.90 + - 完成任务不会触发生成下一份重复任务,重复任务按 loop_at 时间点自动生成,详见 [[task.recurring.howto]] +last_verified: v1.9.36 --- # 标记任务完成 / 取消完成 @@ -37,7 +37,6 @@ last_verified: v1.7.90 1. 点击复选框 / 完成按钮 2. 任务 `complete_at` 写入当前时间,整个卡片显示删除线 / 灰化 3. 项目动态记一条「X 完成了任务 Y」日志 -4. 若该任务带 `loop`(循环),同时生成下一份新任务 ## 取消完成 1. 在已完成任务上再次点击复选框 diff --git a/resources/ai-kb/zh/howto/task/recurring.md b/resources/ai-kb/zh/howto/task/recurring.md index e2ffb9f10..e53b1f0fa 100644 --- a/resources/ai-kb/zh/howto/task/recurring.md +++ b/resources/ai-kb/zh/howto/task/recurring.md @@ -11,22 +11,25 @@ aliases: - 每天的任务 - 周报任务自动生成 - 怎么让任务自动重复 -related_tools: [create_task, update_task, complete_task] + - 重复周期 +related_tools: [create_task, update_task] related_pages: [task_detail] prerequisites: - - 任务已设置 end_at(截止时间),否则无法计算下一周期 + - 只能在顶级任务上设置,子任务不支持 negative: - 重复任务不能在子任务上独立设置(subtask 不支持 loop) - - 重复任务的下一份在「上一份被标记完成时」生成,不是按日历预生成 + - 下一份不是在任务被标记完成时生成,而是到 loop_at 时间点由系统定时任务自动生成,与原任务是否完成无关 + - 只在 loop_at 之后的 10 分钟内生成;服务停机超过 10 分钟错过的周期不会补生成 + - 自定义天数必须是正整数,0 或非数字会被拒绝 - 修改循环周期不会回填已生成的实例 - 所属项目已被删除或已归档时,不再自动生成新的周期任务 -last_verified: v1.8.64 +last_verified: v1.9.36 --- # 重复 / 循环任务 ## 是什么 -DooTask 任务支持循环重复(字段 `loop` + `loop_at`),常用于「每日站会」「每周周报」类型任务。每次任务被标记完成时,系统按 `loop` 自动生成下一份新任务,复制原任务字段并按 `loop_at` 推下一次计划时间。 +DooTask 任务支持循环重复(字段 `loop` 重复周期 + `loop_at` 下个周期时间),常用于「每日站会」「每周周报」类型任务。系统定时任务每分钟检查一次,到达 `loop_at` 时自动创建下一份新任务,原任务是否已完成不影响生成。 ## 支持的周期 - `day` 每天 @@ -35,21 +38,22 @@ DooTask 任务支持循环重复(字段 `loop` + `loop_at`),常用于「 - `twoweeks` 双周 - `month` 每月 - `year` 每年 -- 自定义天数(输入数字,如 3 表示每 3 天) +- 自定义天数(输入正整数,如 3 表示每 3 天;0 或非数字会被拒绝,提示「重复周期选择错误」) - `never` 不重复(默认) ## 入口 -- 桌面端:任务详情页 → 计划时间区 → 「循环」下拉 -- 必须先填 end_at(截止时间)才能启用循环 +- 桌面端:任务详情页 →「重复周期」项(未设置时在添加项菜单里选「重复周期」),选预设周期或「自定义」 +- 设置后鼠标悬停周期文字可看到「下个周期」时间 +- 不要求填截止时间:以开始时间为基准计算,未设开始时间则以当天 0 点为基准 ## 工作原理 -1. 标记 [[task.complete.howto]] 完成当前任务 -2. 服务端基于 `loop` 与当前 `end_at` 计算下一个 `loop_at` -3. 自动创建新任务并复制:标题、描述、负责人、协作者、标签、优先级、可见性、子任务 -4. 新任务的状态为未完成,按新计划时间出现在日历视图 +1. 设置或修改周期、计划时间时,服务端按 `loop` 和开始时间算出 `loop_at` +2. 到 `loop_at` 后,系统创建新任务,复制:标题、描述、负责人和协助人员、附件、优先级、可见性、子任务 +3. 新任务开始时间等于原任务的 `loop_at`,时长与原任务相同;状态为未完成(项目有工作流则回到起始状态),子任务也重置为未完成 +4. 新任务自己算出下一个 `loop_at`;原任务的周期被关闭(`loop` 清空),并记一条日志 +5. 创建失败时整体回滚,不会留下半成品,任务日志记「生成重复任务失败:原因」,到期后 10 分钟内每分钟重试 ## 不支持 - 不能为同一任务并行启用多个周期 -- 循环不复制附件 - 子任务不能独立设置循环 - 所属项目已被删除或已归档的任务不再自动生成新周期任务;这类残留任务可在任务详情顶部横幅点「删除任务」清理