先说结论
Odoo 的周期任务并不是“到了时间自动再复制一份”那么粗糙。
从 /home/ubuntu/odoo-temp/addons/project/models/project_task.py 和 addons/project/models/project_task_recurrence.py 来看,真正的逻辑是:
- 任务先挂上
recurrence_id - 当当前这张任务进入 closed state 时,
_inverse_state才会检查是否该生成下一张 - 只有这一组周期里最新的一张 被关闭,才会触发下一次生成
- 生成时不是无脑整条记录克隆,而是只保留部分字段、顺延部分字段、重置部分状态
- 如果设置了
repeat_type = until,还要判断下一次截止日期是否仍落在结束日期之内
所以它更像一个“关闭驱动的下一次实例生成器”,而不是定时器。
第一层:触发点不是 cron,而是“最后一张任务被关闭”
很多实施顾问第一次看到周期任务,直觉会以为后台有个定时作业按天生成。
但这里最关键的触发点,其实在 project.task 的 _inverse_state:
- 先根据
recurrence_id找出每个周期组里id:max的任务 - 再过滤出那些已经进入 CLOSED_STATES 的任务
- 只有“当前周期组里最新那张,而且已经关闭”的任务,才会传给
project.task.recurrence._create_next_occurrences()
这意味着两个实现细节非常重要:
1. 不是任意一张历史周期任务关闭都会继续生成
如果一个周期组里已经有多张历史任务,你再去补关旧任务,不会继续往后生。
2. 不是靠日期到点自动长出来
真正的业务语义是:
这次工作真正完成了,系统才创建下一次要做的工作。
这很符合项目任务场景。因为很多重复性任务并不是“时间到了就出现”,而是“上一轮收口后,下一轮才有意义”。
第二层:Odoo 为什么一定要先找“这一组里最新的一张”
project.task.recurrence 里有一个 _get_last_task_id_per_recurrence_id(),它通过 _read_group 为每个 recurrence_id 找 id:max。
这个设计非常聪明,因为它解决了周期任务最容易失控的两个问题:
防止重复生成
如果用户多次编辑状态、多人并发操作,系统至少先把“谁才是当前尾部实例”这件事统一下来。
防止历史任务补操作把链条重启
历史任务只是历史记录。只有尾部节点有资格决定要不要长出下一张。
从建模上看,这其实是在把周期任务看成一条链,而不是一堆彼此平行的复制品。
第三层:下一张任务到底复制什么,不复制什么
_create_next_occurrences_values() 比很多人想象得细。
它不是直接 copy() 完事,而是先:
copy_data()拿一份基础拷贝值_get_recurring_fields_to_copy()明确保留哪些周期字段_get_recurring_fields_to_postpone()明确哪些日期类字段需要整体顺延- 再手工补一些“新实例默认值”
源码里默认要复制的关键字段里,最重要的是:
recurrence_id
默认要顺延的字段里,最重要的是:
date_deadline
除此之外,Odoo 还会显式改掉几项内容:
priority被重置成'0'stage_id会尽量回到项目的第一个阶段,而不是沿用上一张关闭时的阶段child_ids会递归生成新的子任务
这几个点加起来,就说明 Odoo 的目标不是“复制一个历史快照”,而是“生成一个新的待办实例”。
也正因为这样,如果你把很多一次性业务字段、审批痕迹、聊天痕迹都指望自动延续到下一轮,通常会失望。周期任务的默认设计,更偏向保留结构,重置执行态。
第四层:为什么子任务也会一起复制
很多团队第一次用 recurring task,会惊讶:
为什么主任务下一轮生成时,子任务结构也一起被带出来了?
原因直接写在 _create_next_occurrences_values():
child_ids不是简单引用旧子任务- 而是对每个
task.child_ids再递归调用_create_next_occurrences_values() - 然后用
Command.create(vals)生成一套全新的子任务树
这意味着 Odoo 把“任务模板结构”也视为周期的一部分。
适合的场景是:
- 每周巡检固定就要做 3 个子步骤
- 每月对账固定就要做 5 个检查点
- 每季度复盘总是有一组稳定 checklist
但它也提醒你:如果上一轮子任务里塞了很多临时脏数据,下一轮并不会继承执行结果,只会继承结构轮廓。
第五层:until 为什么不是“只看今天有没有到结束日”
should_create_occurrence() 这一段很容易被误读。
如果 repeat_type != 'until',当然可以一直生成。
但如果是 until,逻辑不是简单判断“今天是否小于结束日”,而是更细:
- 当前任务必须有
date_deadline - 系统先拿当前任务截止日期加上 recurrence delta
- 再判断“下一次截止日期 是否
<= repeat_until”
也就是说,决定能不能继续生成的,不是当前实例本身,而是下一次实例的截止日期是否还合法。
这就解释了很多现场疑问:
- 明明当前任务还在结束日期前,为什么关掉后没有下一张?
- 因为下一张的
date_deadline推过去以后,已经越界了。
这是一种很合理的边界控制。因为系统关心的是“下一轮是否还在计划范围内”,而不是“这一轮是否曾经在范围内”。
第六层:为什么有的周期任务看起来配置正常,却不再生成
实战里最常见的排错顺序,我建议按下面来:
1. 先看是不是最新一张
旧任务关闭,不会触发下一张。先确认你操作的是这一组里最后一张。
2. 再看状态是不是真的进了 closed set
不是所有“看起来完成”的 UI 状态都等于 closed semantics。要确认任务真正进入 CLOSED_STATES。
3. 看 repeat_until 和 date_deadline
如果当前任务没有截止日期,或者推算后的下一次截止日期超出 repeat_until,就不会生成。
4. 看项目阶段是否让你误以为“复制失败”
新任务生成后,stage_id 往往会落到项目初始阶段,而不是你刚关掉的 Done 列。很多人其实不是没生成,而是去错列找了。
5. 看是不是有人把周期关掉了
_is_recurrence_valid() 会校验 repeat_interval > 0,并检查 until 日期是否仍然有效。配置非法时,这条链本来就不会健康运行。
第七层:实施上最该注意的设计边界
不要把 recurring task 当完整模板引擎
它适合重复任务,不适合复杂流程模板编排。特别是需要审批流、负责人矩阵、动态依赖时,最好另做更明确的建模。
要给周期任务设清晰的截止日期策略
因为 until 的停止判断直接依赖 date_deadline 顺延,没有 deadline 的周期任务,很容易让用户对停止边界产生误解。
子任务结构要稳定,不要混入大量一次性字段
否则每一轮都会复制出一套“看起来像模板,实际上夹着历史包袱”的结构。
最后一句
Odoo 周期任务最值得理解的,不是“它会复制”,而是:
- 什么时候复制:关闭最新实例时
- 复制谁:当前周期组尾部实例
- 复制什么:结构和必要字段
- 何时停止:下一次截止日期越过
repeat_until时
把它理解成“关闭驱动的下一轮任务生成机制”,你就会发现它既不神秘,也不失控。
DISCUSSION
评论区