先说结论
很多 Odoo 开发在做“复制一条记录并顺手改点东西”时,第一反应是直接重写 copy()。
这当然能做,但很多场景里,更准确的落点其实是 copy_data()。
因为这两个方法的职责根本不一样:
copy_data():准备“将来要创建的新记录数据”copy():真正调用 create,创建新记录,并复制翻译
一句话记忆:
copy_data管“复制什么值”,copy管“怎么真的复制出新对象”。
这条边界一旦搞清,很多重复命名、子表复制、后处理逻辑都会顺很多。
ORM 源码里,这个分层写得非常清楚
在 odoo/orm/models.py 里,Odoo 对复制流程做了三步拆分:
第一步:copy_data(default=None)
它会:
- 读取原记录可复制字段
- 组装成
vals_list - 把 one2many 递归变成
Command.create(...) - 把 many2many 变成
Command.set(ids) - 应用
default覆盖 - 返回一组“准备写入 create 的字典”
也就是说,到这一步为止,还没有真正创建新记录。
第二步:copy(default=None)
它会:
- 先
self.with_context(active_test=False).copy_data(default) - 再
self.create(vals_list) - 最后
old_record.copy_translations(new_record, excluded=default or ())
所以 copy() 是“总协调器”,不是“值组装器”。
为什么很多复制改值逻辑更适合写在 copy_data()
因为你真正想改的,往往只是:
- 新名字怎么起
- 某些字段复制时要不要清空
- 某些关系字段是否要跟着复制
- 某些默认值是否要覆盖
这些都属于:
我要产出什么 vals 去 create
这就是 copy_data() 的语义地盘。
如果你只是想“复制时把 name 改成 copy 版”,却去重写整个 copy(),往往就是在更外层的地方做更内层的事。
Odoo 自己也经常这么干
例子 1:res.partner.copy_data()
在 odoo/addons/base/models/res_partner.py 里,Odoo 就是先走 super().copy_data(default=default),然后如果没显式指定 name,再把名字改成:
原名字 (copy)
这个例子非常典型。
因为它要改的不是复制流程,而只是复制出来的新值。
例子 2:库存里的 copy_data() 也经常用来改命名
在 Odoo 官方很多模型里,你都能看到类似模式:
- 先继承父类复制值
- 再把名称、编码、序列信息做局部修正
这再次说明:
“复制时值该长什么样”这件事,天然就属于
copy_data()。
copy_data() 到底帮你做了哪些脏活
源码里这段方法其实很值得读,因为它已经替你处理了很多复杂边界。
1. 它会尊重字段的 copy 属性
只有 field.copy=True 的字段才会进入复制集。
所以很多你以为“怎么没跟着复制”的字段,其实是字段定义上就不允许复制。
这点很重要,因为有些开发者会误判成 ORM 漏复制。
2. 它会黑名单掉魔法列和某些继承字段
比如:
id- 审计字段
parent_path- 某些
_inherits带来的字段
这能避免把本该由新记录重新生成的内容硬拷过去。
3. 它会递归复制 one2many
源码里对 one2many 的做法是:
- 先对子记录调用
copy_data() - 再包成
Command.create(...)
所以很多子表复制,本来框架已经帮你递归处理了。
4. 它不会“暴力深拷 many2many 对象”
many2many 默认只是复制链接,用 Command.set(ids)。
这很合理,因为多数场景下:
- 标签、分类、可选关系对象
- 本来就不是要再复制一份“对象本体”
而只是让新记录继续指向同一批已存在对象。
5. 它还考虑了访问权限
源码里 many2many 复制时,会对目标记录做 _filtered_access('read')。
意思是:
只复制当前能读到的链接,否则后续写入可能失败。
这又一次提醒你,复制不只是“值搬过去”,它仍然活在权限边界里。
copy() 适合管什么
如果你的逻辑不只是改 vals,而是涉及:
- 创建后必须做额外动作
- 需要在新记录生成后补建别的对象
- 要根据新记录 ID 再继续处理
- 要控制整段复制事务语义
这时候才更像 copy() 的职责。
因为这些动作都必须发生在“新对象已经存在之后”。
一个很实用的判断法是:
如果你只需要改“将被 create 的字典”
优先看 copy_data()。
如果你需要拿到“已经创建出来的新 record”再继续做事
再考虑 copy()。
很多人踩坑的地方:把复制后处理硬塞进 copy_data()
copy_data() 返回的是一堆字典,不是已存在的新记录。
所以你在这里做这些事通常就不对:
- 给新记录发消息
- 根据新记录 ID 建附件
- 立刻写依赖新对象主键的子对象
- 依赖 create side effect 的逻辑
因为那时候新记录还没出生。
所以别把“未来值准备”误当成“对象已经创建”。
另一个经常被忽略的点:copy() 还会复制翻译
很多人自己手写:
- 读原字段
- create 一条新记录
然后以为这就等于 Odoo 的复制了。
其实不完全一样。
在 models.py 里,copy() 在 create 完之后,还会调用:
copy_translations(new_record, excluded=default or ())
也就是说,Odoo 标准复制流程不仅复制基础值,还会递归处理:
- 可翻译字段的翻译内容
- one2many 子记录的翻译复制
所以如果你完全绕开标准复制链,常常会丢掉这些细节能力。
实战里最常见的 3 种写法
场景 1:只是想把名字改成“(copy)”
优先重写 copy_data()。
场景 2:复制后要补建关联对象
通常重写 copy() 更自然。
场景 3:既要改默认值,又要创建后补动作
可以:
- 在
copy_data()里处理“值长什么样” - 在
copy()里处理“创建后还要做什么”
别全部堆在一层。
一条很稳的设计原则
你可以把复制过程理解成两段流水线:
A. 准备订单清单
也就是 copy_data()。
B. 按清单真的下单并收货
也就是 copy() -> create() -> copy_translations()。
如果你只是想改订单清单,却跑去改收货流程,代码就会变笨重。
总结
copy_data() 和 copy() 最大的区别,不在于谁更高级,而在于它们处理的是复制链上的不同层次:
copy_data():决定复制值怎么组装copy():决定如何真正创建新记录,并补齐翻译与后续流程
所以以后遇到“复制时顺手改几个字段”的需求,先别急着重写 copy()。
先问一句:
我到底是在改‘新记录的数据’,还是在改‘新记录创建之后的动作’?
大多数时候,答案会直接把你带到正确的方法上。
DISCUSSION
评论区