先说结论
很多人第一次看到 Odoo 项目里的 Update / Status Update,直觉会以为它是:
- 任务变化后自动生成的一条日报
- 或者项目主页上的一个实时状态字段
- 再或者只是管理层写备注的富文本框
但源码真正表达的意思其实更清楚:
project.update不是实时看板,也不是纯备注,而是一张“带上下文的项目快照”。
这个快照里既有人工填写的判断,也有系统在创建那一刻帮你抓取的上下文,比如:
- 当前项目状态
status - 当前进度
progress - 当时的任务总数 / 已关闭任务数
- 默认生成的描述模板
- 里程碑变化摘要
- 在有权限时可见的盈利摘要
所以它更像一次阶段性汇报,而不是持续自刷新的监控面板。
先看源码位置:这是一套独立机制
这条链路主要在这些文件里:
addons/project/models/project_update.pyaddons/project/models/project_project.pyaddons/project/views/project_update_views.xmladdons/project/views/project_update_templates.xml
从模型定义就能看出来,Odoo 没把“项目更新”塞进 project.project 的几行字段里,而是单独建了一个模型:
class ProjectUpdate(models.Model):
_name = 'project.update'
_description = 'Project Update'
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
这有两个很重要的信号:
- 它是独立记录,不是项目表上的一段历史文本。
- 它带协同属性。 因为继承了
mail.thread.cc和mail.activity.mixin,所以 update 本身是可以参与沟通、跟进、安排活动的。
这说明官方不是把它设计成“系统自动算一个状态”,而是把它当成项目沟通对象。
last_update_status 为什么很容易让人误会
在 project.project 上,你会看到这些字段:
update_idslast_update_idlast_update_statuslast_update_color
很多人看到 last_update_status,就会以为:
这就是项目当前状态,而且系统会按任务变化自动重算。
但源码不是这样。
project_project.py 里有这一段:
@api.depends('last_update_id.status')
def _compute_last_update_status(self):
for project in self:
project.last_update_status = project.last_update_id.status or 'to_define'
意思非常直接:
项目上的“当前状态”,本质上只是“最后一条 update 的状态”。
也就是说:
- 它不是从任务完成率自动推出来的
- 不是从盈利面板自动算出来的
- 不是从 milestone 自动推出来的
- 而是最后一次项目更新记录里,作者给出的状态判断
这就是第一个关键边界:
Odoo 把项目状态视为“管理判断的结果”,不是“底层数据自动算出来的唯一真相”。
这是一个很成熟的产品选择。
因为真实项目里,任务完成 80%,不代表项目一定 on track; 里程碑延期了,也不一定必须 off track; 盈利暂时难看,也可能只是结算还没到。
系统可以提供证据,但最终状态是汇报判断,不是机械结论。
为什么改项目状态时,系统会顺手创建一条 project.update
还有一个特别值得讲的细节。
在 project.project.write() 里,Odoo 对 last_update_status 做了特殊处理:
if 'last_update_status' in vals and vals['last_update_status'] != 'to_define':
for project in self:
self.env['project.update'].with_context(default_project_id=project.id).create({
'name': _('Status Update - %(date)s', date=fields.Date.today().strftime(get_lang(self.env).date_format)),
'status': vals.get('last_update_status'),
})
vals.pop('last_update_status')
这段逻辑很有味道。
它不是简单把 project.last_update_status 写掉,而是:
- 发现你想改项目状态
- 先创建一条新的
project.update - 再让项目去指向这条 update
这意味着:
在 Odoo 的设计里,“项目状态改变”本身就应该留下一个更新记录。
也就是:
- 状态不是裸字段改动
- 状态变化应该被历史化
- 状态变化应该成为可追溯的汇报事件
这跟很多系统只在项目表上放一个红黄绿字段很不一样。
Odoo 更偏向这样的理念:
项目状态不只是“现在是什么颜色”,还要能回答“它是什么时候、由谁、基于什么上下文变成这个颜色的”。
新建 update 时,为什么默认会帮你填一部分内容
project.update.default_get() 是这套体验顺滑的关键。
源码里它会在创建 update 时,尽量从项目上带一些已有上下文:
project_id:来自当前上下文active_idprogress:默认取project.last_update_id.progressdescription:默认用_build_description(project)生成status:默认继承项目当前最后状态;若还是to_define,则回落为on_track
这说明 Odoo 不是要求用户每次从零写一篇周报,而是:
给你一张预填好的更新草稿,你再在上面补判断与说明。
这里尤其值得注意两个点。
1)progress 默认继承上一条 update,而不是系统自动重算
这再次说明:
- progress 更像“汇报口径”
- 不是任务关闭率的强绑定映射
任务关闭率只是辅助背景; 你填 60%、70%、85%,是项目经理对整体推进程度的表达。
2)如果项目还没定过状态,默认不是 to_define,而是 on_track
源码注释写得很直白:
to_define是项目初始状态占位- 但
project.update.status里没有这个选项 - 所以第一次填更新时,默认会落到
on_track
产品层面很好理解:
“还没设置项目状态” 可以存在;但 “写 update 的时候还不给状态” 就不太成立了。
默认描述不是空白富文本,而是 QWeb 拼出来的“汇报模板”
很多人会忽略 description 的来源,以为只是一个 HTML 字段。
实际上 project.update._build_description(project) 会调用:
self.env['ir.qweb']._render(
'project.project_update_default_description',
self._get_template_values(project)
)
也就是说,默认描述来自 QWeb 模板,不是简单字符串拼接。
模板文件在:
addons/project/views/project_update_templates.xml
里面会动态渲染几块内容:
- Summary
- Activities / Milestones
- Profitability
这就很关键了。
Odoo 不是让 update 只写“今天挺顺利”,而是把它引导成一份有结构的项目汇报。
这份模板默认想让你回答什么
从模板结构看,官方默认关心的是三类东西:
- 整体情况:How’s this project going?
- 里程碑进展:哪些 milestone 已到期、可标记完成、最近有无变化
- 经营情况:如果项目具备盈利分析条件,就带出收入 / 成本摘要
换句话说,这条 update 其实在帮项目经理回答:
- 项目总体还稳不稳
- 关键交付有没有偏移
- 业务结果是否在朝预期推进
这比单纯一段随手备注有结构得多。
为什么说它是“快照”,不是“实时仪表盘”
这个判断最核心的证据,在 create() 里。
project.update.create() 在创建完成后,会把当下项目任务信息写入 update:
update.write({
'task_count': project.task_count,
'closed_task_count': project.task_count - project.open_task_count,
})
注意这里的语义:
- 不是做关联字段
- 不是实时 compute
- 而是创建那一刻写死一份数值
这意味着:
一条 update 的任务统计,记录的是“写这条 update 时”的任务状态,不会跟着未来任务变化一起漂移。
这就是典型快照设计。
它的好处非常大:
- 你下周回头看上周 update,不会被今天的数据覆盖
- 你可以比较不同阶段的项目状态
- 你能保留“当时的判断依据”
如果它做成实时字段,历史 update 就会失去意义,因为过去每一条都会被现在改写。
所以 Odoo 这里很明确:
update 的价值不在“实时”,而在“保留当时”。
里程碑部分为什么特别有意思:它不只看当前值,还看“自上次更新以来发生了什么”
这是这条链路最容易被低估、也最值得写文章的一点。
project.update._get_milestone_values(project) 不只是列出 milestone,它还区分了三层信息:
list:当前项目里相关 milestone 列表updated:自上次 project update 以来,deadline 被改过的 milestonecreated:自上次 project update 以来,新建的 milestone
这说明 Odoo 关心的不只是“现在有哪些 milestone”,还关心:
相对于上一次正式汇报,这次有哪些计划边界发生了变化。
这就是管理语义,不是简单数据展示。
它怎么知道 milestone 截止日期被改过
更妙的是 _get_last_updated_milestone()。
这段代码没有直接看当前字段值,而是查:
mail_messagemail_tracking_valueir_model_fields
并且只抓 project.milestone.deadline 的字段追踪记录。
也就是说,它不是问:
- milestone 现在 deadline 是多少
而是在问:
- 自上次 update 之后,谁的 deadline 被改过,从什么日期改成了什么日期
这是非常“汇报型”的设计。
因为管理层最关心的往往不是静态表,而是变化:
- 哪个里程碑延期了
- 哪个里程碑被重新承诺了
- 哪个里程碑是刚新增进来的
所以这块设计的本质不是“列数据”,而是“列变化”。
为什么模板里会塞 profitability,但它又不等于“项目更新就是盈利报表”
_get_template_values(project) 里会调用:
profitability_values, show_profitability = project._get_profitability_values()
然后把结果喂给描述模板。
这让很多人误会:
project update 是不是本质上就是 profitability 的外壳?
不是。
更准确地说:
盈利摘要是 project update 的上下文材料之一,不是它的全部定义。
这篇和已有“项目盈利”文章最大的区别也在这里:
- “项目盈利”那篇讲的是收入 / 成本 / billable 的经营逻辑
- 这篇讲的是项目更新怎样把盈利信息包装进一次阶段性汇报
也就是说,盈利在这里扮演的是:
- 证明项目经营情况的一个维度
- 而不是替代项目状态判断
更重要的是,project_project.py 里 _get_profitability_values() 一开始就做了权限判断:
if not self.env.user.has_group('project.group_project_manager'):
return {}, False
这说明:
不是所有看 update 的人都能看到盈利摘要。
这也再次证明 update 不是简单导出一张固定报表,而是一个会随角色、权限、模块能力而变化的汇报容器。
project.update 里真正被“冻结”的,和真正会“继承”的,分别是什么
如果你想读懂这条机制,最有用的方法不是记字段,而是把字段分成两类。
一类:创建时冻结的快照
例如:
task_countclosed_task_count- 当次生成的
description - 该次选择的
status - 该次填写的
progress - 当时的
date
这类信息的核心目的是:
保存“那次汇报”的样子。
一类:下一次创建时会继承的上下文
例如:
- 上一条 update 的
progress - 上一条 update 的
status - 基于最新项目情况重新生成的默认描述
这类信息的核心目的是:
降低下一次写更新的成本。
所以这套机制不是“全冻结”,也不是“全实时”,而是:
- 历史记录要稳定
- 新一轮编辑要顺手
这是非常成熟的“汇报对象”设计。
删除最后一条 update 会怎样:项目状态会回退
project.update.unlink() 也很值得一提:
for project in projects:
project.last_update_id = self.search([('project_id', '=', project.id)], order='date desc', limit=1)
这意味着如果你删掉某项目最近一条 update:
- 项目的
last_update_id会重新指向上一条 last_update_status也会跟着回退到上一条状态
这再次说明:
项目当前状态没有单独维护一套“真值系统”,它就是对最后一条 update 的引用。
这也提醒开发者:
- 如果你在二开里把 update 当普通日志随便删
- 你可能会不小心把项目当前状态一起改掉
所以对 project.update 的删除、批量清理、迁移,都不能只当历史备注处理。
常见误解,逐个拆开
误解 1:项目更新会随着任务变化自动刷新
不会。
新建 update 时抓一份任务统计快照,之后历史 update 不会自动同步成最新任务数据。
误解 2:项目状态就是任务完成率
不是。
任务完成率是证据之一;最终 status 仍是人工汇报判断。
误解 3:有 profitability 就等于有 update
也不是。
盈利面板是一个上下文来源,但 update 是独立的沟通对象。
误解 4:里程碑列表只是当前清单
不止。
它还会强调“自上次 update 以来发生的变化”,尤其是 deadline 的改动与新建 milestone。
误解 5:改项目状态只是改一个字段
源码上并不是。
改 last_update_status 时,Odoo 倾向于先生成一条新的 project.update,把这次状态变化历史化。
对实施和二开来说,这条机制最值得抓住的设计思想是什么
我觉得有三条。
1)把“状态”视为管理判断,不是机械计算
底层数据可以支持判断,但不要假设系统能替管理者做最后裁决。
2)把“汇报”设计成快照,而不是实时投影
历史汇报的价值,在于保留当时的上下文。 如果全做成实时字段,历史就会失真。
3)重点不是列出全部对象,而是提炼“相对上次发生了什么变化”
里程碑变更追踪就是最好的例子。 管理真正关心的是变化,不是静态存量。
如果你要二开,最容易踩的坑有哪些
坑一:把 update 当普通 note,忽略它和项目当前状态的绑定
一旦删除、覆盖或乱同步 update,last_update_status 可能会跟着失真。
坑二:把 progress 自动绑成任务关闭率
表面省事,实际会把“管理判断”错误地降格成“机械百分比”。
坑三:重写默认描述时,只留一个大文本框
这样会把 Odoo 原来那种“结构化项目汇报”能力削弱掉。 更好的做法通常是:
- 保留 summary
- 保留 milestone 变化摘要
- 按角色决定是否展示盈利摘要
- 再补你自己的业务模块区块
坑四:忽略 mail.tracking,导致无法识别“自上次 update 以来的变化”
如果你做了自定义 milestone 字段、计划日期字段、阶段承诺字段,也可以借鉴 Odoo 这套思路:
- 不是只展示当前值
- 而是展示“上次汇报之后发生的变化”
这会让你的项目汇报质量立刻上一个层级。
最后再用一句话收住
Odoo 的 project.update 最值得学的地方,不是它做了一个好看的更新页面,而是它把项目汇报拆成了三层:
- 人工判断的状态
- 创建当下的业务快照
- 相对上次汇报的关键变化
所以它不是自动日报,也不是实时仪表盘。
它更像是:
一张可追溯、可协作、带业务上下文的项目阶段快照。
如果你把这层想通了,就会明白为什么 Odoo 没把项目状态做成一个自动计算出来的“唯一真值”,而是选择了更贴近真实管理场景的设计。
DISCUSSION
评论区