先说结论
Odoo 看板里那些“当前一张卡都没有、但仍然显示出来”的列,很多时候不是前端随便补的。
它们通常来自后端的 group_expand 机制。
也就是说,系统不是先把数据库真实分组结果原样扔给前端,再让前端凭空画空列; 而是:
- 后端先做
read_group/formatted_read_group聚合; - 判断当前分组字段有没有声明
group_expand; - 如果有,就额外求出“应该展示的全部组”;
- 把空组补进结果里;
- 再让前端按这个结果渲染列。
所以真正的结论是:
空列不是纯 UI 幻觉,而是 ORM 分组结果被有意识扩展后的产物。
一、group_expand 根本不是“看板专属小技巧”
在 odoo/orm/fields.py 里,字段对象有 group_expand 属性,determine_group_expand() 会去解析它。
这说明 group_expand 从设计上就不是前端 patch,而是字段元数据的一部分。
这点很重要。
因为它意味着:
- 哪些组应该显示;
- 空组是否值得保留;
- 保留顺序是什么;
- 是按 selection 值补,还是按 many2one 记录补;
这些都被视为数据分组语义的一部分,而不是某个具体页面的临时展示技巧。
二、Selection 字段为什么只写 group_expand=True 就够了
odoo/orm/fields_selection.py 有一段非常关键:
if attrs.get('group_expand') is True:
attrs['group_expand'] = self._default_group_expand
这意味着对 Selection 字段来说,group_expand=True 不是布尔开关那么简单。
它会被自动替换成默认实现 _default_group_expand()。
而这个默认实现做的事也非常干脆:
def _default_group_expand(self, records, groups, domain):
return self.get_values(records.env)
意思就是:
- 不关心当前库里有没有数据;
- 直接把这个字段“定义上允许的全部选项值”拿出来;
- 再让后续分组结果整形逻辑补全。
所以对 Selection 字段来说,空列之所以能出现,不是因为前端知道“还缺哪个状态”, 而是因为字段自己就声明了:
这些状态即使暂时没数据,也属于应展示的合法组。
三、Many2one 的 group_expand 又是另一套逻辑
到了 odoo/orm/models.py 的 _read_group_fill_results(),Odoo 会区分:
- relational 字段;
- 非 relational 字段。
如果是 relational 字段,比如 many2one:
values = group_expand(self, groups, domain).sudo()
这句很关键。
它表示 many2one 分组扩展不是返回简单值列表,而是返回一个记录集。
默认实现 _read_group_expand_full() 更直接:
def _read_group_expand_full(self, groups, domain):
return groups.search([])
也就是说,一个 many2one 字段如果挂了这条默认扩展逻辑,系统会倾向于把目标模型里“应该显示的记录组”一并取回来。
这时看板里的“空列”就不再只是状态值,而可能是:
- 还没有卡片的阶段;
- 还没有任务的负责人组;
- 还没有单据的某个业务分类对象。
所以 Selection 的补全更像“补枚举值”,Many2one 的补全更像“补记录对象”。
四、测试把行为差异写得非常清楚
test_web_group_expand.py 这组测试很适合当教材。
1)test_static_group_expand()
它验证了三件事:
- 返回顺序要遵守字段定义顺序;
- 没有记录的组也要出现;
False组放最后。
这非常关键,因为它说明 group_expand 不是“只多补几行数据”这么简单,而是连组顺序语义都一起定义了。
2)test_no_group_expand()
没有 group_expand 时,结果只保留必要组,空组不会出现。
这就能直接证明:
空列不是默认行为,而是显式声明出来的行为。
3)limit / offset 测试
测试还证明了一个很多人没意识到的细节:
offset存在时,可能不做 group_expand;limit太小、会把补全结果挤爆时,也可能不做完整扩展;- limit 足够时,才会把空组补回来。
这说明 Odoo 在这里并不是“功能优先不计代价”,而是在:
- 展示完整列语义;
- 控制查询与返回规模;
之间做平衡。
五、为什么这套设计比前端硬补列更好
如果让前端自己补空列,会遇到几个问题:
1)前端不知道合法组全集
Selection 还好说,Many2one 根本不可能只靠前端猜出“该展示哪几条记录”。
2)顺序与折叠信息不稳定
测试里 many2one 结果还会带:
display_namefold__extra_domain
这些都不是前端凭空能补对的。
3)不同视图会失去一致性
如果每个视图自己补,kanban、list、graph 之类的分组行为就会逐渐分叉。
而把它放到 read_group 结果层,统一性会更强。
所以 Odoo 的思路其实很成熟:
让字段声明“应显示哪些组”,让 ORM 负责把结果补齐,让前端只负责渲染。
六、开发时最容易误解的 3 个点
1)把 group_expand 理解成“显示全部分组结果”
并不完全对。
它显示的是“应该展示的组”,不是数据库里所有可能对象都无限铺开。
尤其碰到 limit / offset,行为会收敛。
2)以为它只影响看板
虽然最典型感知发生在 kanban 列,但根子在 formatted_read_group() 和 _read_group_fill_results(),是后端分组结果层。
3)以为没数据的列是前端空壳
错。它们通常带着真实的 __extra_domain,点进去之后是能继续 drill-down 的。
也就是说,这不是装饰,而是可操作的空组结果。
七、什么时候该考虑自定义 group_expand
如果你的业务对象存在下面这些需求,就值得考虑:
- 即使当前没单据,也要展示完整阶段;
- 某些候选组必须按固定业务顺序出现;
- 分组列不是“数据库自然聚合结果”,而是“业务允许集合”;
- 你需要让空列也能成为一个明确的投放或拖拽目标。
这时不要在前端硬写假列,更稳妥的做法是:
- 在字段层声明
group_expand; - 让
read_group结果自然长出这些列; - 保持 domain、顺序、fold、权限语义都一致。
结语
理解 group_expand 之后,你会发现 Odoo 看板空列的本质并不是“前端为了好看多画几列”。
它真正做的是:
- 先承认“分组展示”不是数据库现状的机械投影;
- 再允许字段和模型声明“哪些组具有展示资格”;
- 最后由 ORM 把这些语义化空组补进结果。
所以一句话总结就是:
Odoo 的空看板列,本质上是后端分组语义的一部分,不是前端视觉补丁。
DISCUSSION
评论区