ORM 聚合边界

Odoo 报表按天按月分组为什么总差一格:read_group 的时区与日期桶规则

很多人以为 read_group 的日期分组只是 SQL 里的 date_trunc。可从 Odoo ORM 源码看,真正展示给用户的分组标签、用于 drill-down 的 __domain、以及 datetime 的时区换算,都是在 read_group 格式化阶段重新拼出来的。

Odoo 开发 前端
进阶 开发者 2 分钟阅读
0 评论 0 点赞 0 收藏 7 阅读

做 Odoo 报表时,很多人都会遇到一种很熟悉的错觉:

  • 数据明明是 3 月 31 日晚上录的;
  • 图表里却跑到 4 月 1 日;
  • 或者同一份数据,上海用户和欧洲用户看到的月分组不完全一样。

这时最常见的直觉是:

read_group() 不就是数据库里做个日期截断吗?

不完全对。

/home/ubuntu/odoo-temp/odoo/orm/models.py 的实现看,Odoo 的 read_group() 对日期 / datetime 分组,至少分成三层:

  1. 底层聚合:先把结果按 groupby 算出来
  2. 结果格式化:把原始值变成用户看到的标签
  3. 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 2026W14 2026 只是标签,不应该当真正过滤条件。

要用 __range 或实际 __domain

坑 3:以为 eager / lazy 只影响性能

不是。

它还影响:

  • 哪一层现在真的被算了;
  • __context 是否继续携带剩余 groupby;
  • 前端 drill-down 如何接着走。

坑 4:忽略用户时区导致“月报对不上”

这类问题最常见,也最像“系统玄学”。

其实先看 env.context['tz'],往往就已经找到根源。

八、排错时该怎么想

如果用户说:

为什么这条记录在我这里是 4 月,在别人那里是 3 月?

优先检查:

  1. 分组字段是 date 还是 datetime
  2. 当前用户上下文的 tz 是什么
  3. 该记录 UTC 原始时间是什么
  4. read_group 返回的 __range 是什么
  5. 报表想表达的是“本地业务日”还是“统一总部口径”

这五步通常比盯着 SQL 更快。

九、一句话记住它

read_group() 的日期分桶不是“数据库帮你截一下日期”这么简单。

更准确的说法是:

Odoo 先算出分组值,再根据字段类型和用户时区,把它重建成一个带标签、带边界、可 drill-down 的时间桶。

所以当你看到“差一格”的月报、周报、日报时,不要第一反应就怪缓存或前端。

很多时候,真正应该检查的是:

  • 你分的是 date 还是 datetime
  • 你看的时区是不是和业务口径一致
  • 你用的是标签,还是用 __range / __domain 在继续分析

把这三件事想清楚,read_group 的大部分日期分组疑难,都能一下子变清楚。

DISCUSSION

评论区

想参与讨论?先 登录 再发表评论。
还没有评论,你可以成为第一个留言的人。