做 Odoo 报表时,很多人都会遇到一种很熟悉的错觉:
- 数据明明是 3 月 31 日晚上录的;
- 图表里却跑到 4 月 1 日;
- 或者同一份数据,上海用户和欧洲用户看到的月分组不完全一样。
这时最常见的直觉是:
read_group()不就是数据库里做个日期截断吗?
不完全对。
从 /home/ubuntu/odoo-temp/odoo/orm/models.py 的实现看,Odoo 的 read_group() 对日期 / datetime 分组,至少分成三层:
- 底层聚合:先把结果按 groupby 算出来
- 结果格式化:把原始值变成用户看到的标签
- drill-down 语义:给每个桶生成
__domain与__range
所以用户看到的“一个月、一周、一天”,并不是单纯 SQL 截断结果,而是:
SQL 分组 + Odoo 时区换算 + UI 标签格式化 + 桶范围重建
一起完成的。
一、read_group() 默认先只做当前层分组
源码里最有名的一句是:
lazy_groupby = groupby[:1] if lazy else groupby
也就是说,默认 lazy=True 时:
- 当前调用只真正按第一层 groupby 分组;
- 剩余层级塞进
__context; - 每个结果桶再带一个
__domain供下一层 drill-down 使用。
这意味着列表、图表、透视表里你看到的“可展开分组”,并不是一开始就把所有层级都算完了。
对于日期字段尤其如此:
- 当前层先给出“这个月 / 这周 / 这天”的桶;
- 真正继续展开时,再拿
__domain和__context发下一次 grouped query。
二、日期分组最关键的不只是标签,而是 __range
在 _read_group_format_result() 里,Odoo 对 date / datetime 分组会做几件事:
- 生成可读标签;
- 计算这个桶的开始 / 结束边界;
- 把边界放进
__range; - 再把这个边界变成
__domain。
也就是说,一个“2026 年 3 月”的分组结果,真正包含的不是一句显示文本,而是:
- 前台标签:
March 2026或本地化后的月份名; - 过滤边界:从月初到下月初;
- drill-down 域:
>= start且< end。
这就是为什么你不能把分组标题文字当作唯一真相。
真正定义这个桶范围的,是
__range/__domain,不是 UI 上那几个字。
三、date 和 datetime 的处理方式并不一样
这是很多人最容易忽略的边界。
对 date 字段
Odoo 会直接用日期值去生成标签和边界。
因为 date 没有时区,不存在“同一时刻在别的时区变成前一天或后一天”的问题。
对 datetime 字段
Odoo 会先考虑 env.context['tz']。
源码里能看到:
- 如果上下文里的
tz是有效时区; - Odoo 会把分组值按这个时区 localize;
- 再换回 UTC 去求范围边界;
- 标签显示时再用本地时区格式化。
这一步很关键。
因为数据库里保存的是 UTC,而用户理解“今天”“本月”时,用的是自己的本地时间。
所以对于 datetime 分桶来说,问题从来不是“数据库值属于哪一天”,而是:
在当前用户时区里,它应该落进哪个业务时间桶。
四、为什么月底、周界、夏令时最容易出错
1)月底跨月
例如一条 UTC 时间接近 00:00,换到别的时区可能还是上个月,或者已经是下个月。
于是:
- SQL 原值你看着像 4 月;
- 用户分组却落在 3 月;
- 这不是 bug,而是时区桶边界本来就不同。
2)周分组
源码里对 week 还有特殊处理,因为不同环境下 week label 行为可能不一致。
所以 Odoo 甚至会自己算 Wxx YYYY 这种标签,避免只依赖格式化库的周表示。
3)夏令时切换
代码里还有一句注释很说明问题:
- 计算
range_end时要考虑 start 和 end 之间可能发生 hour change。
这说明 Odoo 很清楚:
datetime 桶不是“固定加若干秒”就完事,时区边界本身会动。
五、为什么同一张图不同用户看起来会不一样
如果 groupby 针对的是 datetime,而两个用户上下文 tz 不同,那么:
- 分组标签可能不同;
- 分组边界可能不同;
- 对应记录数量也可能不同。
这不是权限问题,也不是缓存脏数据,而是:
read_group 对 datetime 的聚合,本来就是用户时区敏感的。
所以当业务要做“跨地区统一经营报表”时,你必须先决定:
- 要按用户本地时区看;
- 还是按公司总部时区看;
- 还是干脆先把 datetime 归一成 date 再汇总。
不先定义口径,报表争论会没完没了。
六、__context 负责继续分组,不负责定义当前桶
源码里还有一段很值得记住:
if len(lazy_groupby) < len(groupby):
row['__context'] = {'group_by': groupby[len(lazy_groupby):]}
很多人会把 __context、__domain、__range 混成一件事。
其实分工很清楚:
__context:告诉下一层还要按什么继续 group__domain:当前这一桶到底筛哪些记录__range:当前时间桶的精确起止边界
如果你只看 __context 而忽略 __range / __domain,就会误以为分组只是“前端点开再查一次”那么简单。
实际上前端之所以能点开后继续准,靠的是服务端已经把当前桶边界定义好了。
七、开发里最容易踩的坑
坑 1:把 datetime 分组当成 date 分组
如果业务含义其实是“按自然日统计”,但字段用的是 datetime,你就必须明确时区。
否则深夜记录跨天几乎是必然。
坑 2:拿显示标签做导出或二次过滤
March 2026、W14 2026 只是标签,不应该当真正过滤条件。
要用 __range 或实际 __domain。
坑 3:以为 eager / lazy 只影响性能
不是。
它还影响:
- 哪一层现在真的被算了;
__context是否继续携带剩余 groupby;- 前端 drill-down 如何接着走。
坑 4:忽略用户时区导致“月报对不上”
这类问题最常见,也最像“系统玄学”。
其实先看 env.context['tz'],往往就已经找到根源。
八、排错时该怎么想
如果用户说:
为什么这条记录在我这里是 4 月,在别人那里是 3 月?
优先检查:
- 分组字段是
date还是datetime - 当前用户上下文的
tz是什么 - 该记录 UTC 原始时间是什么
read_group返回的__range是什么- 报表想表达的是“本地业务日”还是“统一总部口径”
这五步通常比盯着 SQL 更快。
九、一句话记住它
read_group() 的日期分桶不是“数据库帮你截一下日期”这么简单。
更准确的说法是:
Odoo 先算出分组值,再根据字段类型和用户时区,把它重建成一个带标签、带边界、可 drill-down 的时间桶。
所以当你看到“差一格”的月报、周报、日报时,不要第一反应就怪缓存或前端。
很多时候,真正应该检查的是:
- 你分的是
date还是datetime - 你看的时区是不是和业务口径一致
- 你用的是标签,还是用
__range/__domain在继续分析
把这三件事想清楚,read_group 的大部分日期分组疑难,都能一下子变清楚。
DISCUSSION
评论区