如果只看表面,Odoo 里的 many2one 字段很容易被理解成:
- 一个输入框;
- 配一个下拉建议列表;
- 选中后回填一条记录。
但只要你真正做过二开,就会很快发现 many2one 从来不只是“自动补全输入框”。
你会不断遇到这些问题:
- 为什么有的字段能直接新建,有的只能搜不能建;
- 为什么有时会出现 “Create” 和 “Create and Edit”,有时没有;
- 为什么同一个字段在不同记录上搜索结果不一样;
- 为什么一开始只给 7 条结果,后面又弹出 Search More;
- 为什么某些结果选过一次后,别处又能秒显示 display_name;
- 为什么扫码、只读、链接跳转也跟 many2one 放在一起。
如果去读 many2one_field.js、many2one.js、record_autocomplete.js,你会看到 Odoo 的真实设计是:
many2one 不是一个 UI 小组件,而是一条关系记录选择与创建链路。
一、字段层先决定“允许做什么”,再决定“怎么搜”
many2one_field.js 最值得看的不是模板,而是 extractM2OFieldProps()。
它会把字段静态信息、动态信息和 options 汇总成一组能力开关:
canCreatecanCreateEditcanQuickCreatecanOpencanWritecanScanBarcodesearchThresholdnameCreateFieldplaceholder
这一步非常关键,因为它说明 many2one 的第一层问题不是“候选怎么显示”,而是:
当前用户、当前字段、当前视图配置,到底被允许完成哪些关系动作。
例如:
no_create直接关闭所有创建路径;no_quick_create禁掉基于输入文字直接创建;no_create_edit禁掉通过弹窗表单创建;no_open禁掉已选记录的打开能力。
也就是说,many2one 先是一个关系动作权限壳,然后才是搜索框。
二、computeM2OProps() 说明 domain 和 context 都是运行时现算
many2one.js 里的 computeM2OProps() 很值得细看。
它不是直接把 domain、context 从 field props 原样往下传,而是做了两层运行时计算:
1)domain 是函数,不是死值
domain: () => getFieldDomain(record, name, fieldProps.domain)
这说明 many2one 搜索条件不是一个固定数组,而是和当前 record 状态绑定。比如:
- 当前公司;
- 当前 partner;
- 当前单据类型;
- 甚至临时虚拟 ID 的 eval context。
这些都会影响“这一刻该搜哪些记录”。
2)openActionContext 也要合成
它会把:
openActionContext- 字段本身 context
- record 当前 evalContext
组合成真正用于打开记录动作的上下文。
这说明 many2one 不是选完值就结束,它还要考虑:
- 之后打开记录时进入什么上下文;
- 创建表单默认值从哪来;
- 不同业务单据下 relation 记录怎么继承环境。
三、Many2One 组件真正维护的是“选择 + 创建 + 打开”的统一体验
Many2One 组件里最能体现架构思路的是 activeActions 和 many2XAutocompleteProps。
它把当前关系字段能力整理成:
- create
- createEdit
- write
然后传给自动补全层。
注意这意味着自动补全不是一个独立的“搜列表”,而是必须知道:
- 这个字段是否允许 quick create;
- 是否允许 Create and Edit;
- 选中后如何更新 record;
- 打开现有记录时走 action、dialog 还是 tab。
换句话说,many2one 的补全层从一开始就是服务于关系编辑工作流的,不是通用下拉菜单。
四、为什么选中值存的是 {id, display_name},而不是只存 id
源码里的 extractData(record) 很简单,却特别说明问题。
many2one 选中后并不只保留一个 id,而是希望立刻拿到:
iddisplay_name
原因很现实:
- 字段渲染立刻要显示名字;
- 某些 name 会有多行,需要拆 extraLines;
- 之后打开记录、恢复状态、重新渲染时都需要稳定显示文本。
如果只存 id,界面到处都得额外补读一次 display_name,交互会变得很脏。
这也是记录保存后 onRecordSaved() 还会再 read(display_name) 一次的原因:
- 新建或编辑完后,many2one 需要拿到最新、规范的显示名;
- 不能只相信输入框当时那段文本。
五、RecordAutocomplete 不是简单 name_search 包装,而是带缓存和升级通道的搜索入口
record_autocomplete.js 里有几层很值得注意:
1)结果数量故意受限
默认 SEARCH_LIMIT = 7,多一条只是为了判断要不要显示 “Search More...”。
这说明官方有明确取舍:
- 输入下拉只适合快速决策;
- 不是给你把整库数据都塞进 dropdown。
2)结果会进入 nameService
addNames() 会把 [id, label] 写入 name service。
这很关键,因为 many2one 不是只在一个输入框里短暂显示结果。关系记录名称一旦查出来,系统后续很多地方都能复用,避免重复补读。
3)旧请求会被主动 abort
loadOptionsSource() 每次搜索前,会把上一次 lastProm.abort(false)。
这和前面模型层讲的竞态控制是一个思路:
- 用户输入很快;
- 搜索请求很多;
- 如果不取消旧请求,结果顺序就可能乱掉。
对于自动补全,这种问题尤其明显,因为用户眼睛盯着的是“当前输入对应的候选”,而不是某个历史关键词的结果。
六、为什么要有 Search More 对话框
当结果超过短列表承载范围时,Odoo 没继续往 dropdown 里硬塞,而是切到 SelectCreateDialog。
onSearchMore() 里会:
- 先基于当前 quick search 结果生成动态过滤;
- 再把它作为 dialog 的
dynamicFilters; - 同时保留当前 domain、context、multiSelect 等条件。
这说明 Search More 不是“打开另一个无关页面再搜一次”,而是:
把当前输入上下文升级成一个更完整的关系选择界面。
它承接的是同一条选择链路,只是从“轻量下拉”升级成“完整搜索对话框”。
七、Quick Create 为什么危险又必要
many2one 的 quick create 一直是最容易被误解的地方。
很多人喜欢把它理解成“没搜到就顺手建一个名字一样的新记录”。
但源码设计其实很谨慎:
- 能不能 quick create,要先看字段级能力;
- 与 Create and Edit 分开;
- 真正保存后还会重新读记录并规范化显示名;
- 某些场景只能弹完整表单,不能直接快建。
这背后的原因很好理解。
关系记录常常不只是一个名字,它可能还需要:
- 公司;
- 税号;
- 联系方式;
- 分类;
- 权限或业务前提。
所以 quick create 适合“名字已足够建立最小记录”的关系,不适合所有模型无脑开启。
八、二开 many2one 时最容易犯的错
误区 1:把 many2one 当成普通 AutoComplete 复用
如果你忽略它背后的 domain/context/create/open 工作流,很快就会发现字段行为和标准系统不一致。
误区 2:把 domain 写成静态值
很多时候 many2one 的筛选依赖当前 record 实时状态,静态化后会出现“看上去能搜,但结果不对”。
误区 3:只保存 id,不维护 display_name
表面省事,后面渲染、恢复、链接显示、保存后刷新都会多出很多补丁代码。
误区 4:Search More 丢失当前上下文
如果弹出的完整搜索没有继承 quick search 的 domain/context,用户会觉得“下拉里和弹窗里像两个系统”。
九、结论
Odoo many2one 远不是“输入几个字弹个下拉框”。
从源码看,它真正维护的是一条完整的关系选择链路:
- 字段层先确定创建、编辑、打开、扫码等能力;
- 运行时基于当前 record 现算 domain 和 context;
- 自动补全层做受限搜索、名称缓存与请求取消;
- 结果过多时再升级到 Search More 对话框;
- 必要时接上 Quick Create 或完整创建表单;
- 最后把选中的关系记录规范成
{id, display_name}回写页面。
所以更准确的理解应该是:
many2one 不是“搜索型下拉框”,而是 Odoo Web Client 里最常见的一条关系记录发现、选择、创建与回填工作流。
理解这条链路之后,你改 many2one 就不会只想着“下拉怎么渲染”,而会开始先问:
- 当前关系动作权限是什么;
- domain/context 是怎么在运行时形成的;
- 结果列表何时该升级到 dialog;
- quick create 到底是不是这个模型该开的路。
DISCUSSION
评论区