做Odoo二开的都知道,最怕的不是业务逻辑复杂,而是你接手的时候,系统已经跑了大半年,客户天天在用。这时候你说"这个字段我得改核心源码才能加",没人敢让你动。我早期带团队时,有个同事就是直接改了某标准模块的源文件来加一个客户分类字段,结果客户升级系统后,那个文件被覆盖,字段全没了,数据倒是还在表里,但界面一片混乱。那次之后,我定了个规矩:凡是二开需求,一律通过模块继承来做,不碰任何标准模块的源代码。
Odoo模块继承是官方为二次开发准备的核心机制,它允许你在不改动原模块代码的前提下,给现有模型加字段、改方法、调整视图、扩展菜单和报表。这篇是"模块继承第一讲",我默认你已经能写一个最简单的Odoo模块(有manifest、有模型、有视图、能安装),下面我重点拆解三种继承方式里最常用、最值得先吃透的两种:模型继承(_inherit)和视图继承(inherit_id)。这两种搞定,市面上八九成的二次开发需求你都能接得住。
1. 从"改源码"到"写模块",Odoo继承机制到底解决了什么问题
先说一个反常识的事情:Odoo的源码几乎完全可以当作文档来看,但它绝不适合直接修改。原因很简单,模块化架构决定了标准模块之间互相依赖,你今天改了某个模型里的字段逻辑,可能没影响当前功能,但下次官方升级或安装新模块时,这个改动就变成了冲突源。
1.1 继承机制的本质:在不破坏原模型的前提下叠加能力
Odoo的模型层基于ORM实现,它的继承不是传统编程里"子类扩展父类"那么简单,更像是给同一个模型动态叠加补丁。当你写下:
class ResPartner(models.Model): _inherit = 'res.partner'这段代码的意思是:"请在原有合作伙伴模型上再加点东西",而不是"请创建一个新模型"。原模型的所有字段、方法、约束都还在,你的代码只是往里面追加内容。这种设计的好处极其明显:逻辑解耦。你的模块负责你的功能,标准模块负责标准功能,两者在代码层面互不侵入,但在运行时无缝融合。
1.2 改源码与写模块的真实代价对比
我见过太多刚入门的开发者纠结这个问题。直接改源码确实爽,加字段到标准视图里,保存,刷新,完事。但代价你迟早要还:
| 对比维度 | 直接改源码 | 写模块继承 |
|---|---|---|
| 开发速度 | 快,改完即用 | 稍慢,需要搭模块骨架 |
| 升级兼容性 | 升级必冲突,手动合并 | 几乎无感,继承自动合并 |
| 多环境同步 | 容易漏改文件 | 模块即部署单元 |
| 团队协作 | 代码冲突频繁 | 各自模块互不干扰 |
| 卸载回滚 | 几乎不可能 | 卸载模块即恢复原状 |
我后来带项目有个习惯,接任何需求先问一句:"这个功能能不能用继承实现?"如果答案是肯定的,就走模块化路线。有一次客户要求把采购订单的编号前缀改成自定义格式,我们做了一个只有几十行代码的继承模块,后来客户升级系统,前后不到十分钟就完成了,那边的IT负责人自己都惊讶。
1.3 第一讲你该掌握到什么程度
继承机制本身有三个层次:模型字段扩展、业务方法重写、视图结构修改。第一讲不贪多,主攻前两个加上第三个的基础用法。也就是说,学完这篇,你应该能做到:给现有模型加字段、加默认值、加选择项,把现有方法的逻辑篡改一部分且不影响原始流程,以及在表单视图里插入、调整字段位置。
如果你能顺手把这三个能力组合起来用,比如"加一个字段,这个字段影响某个按钮的行为,同时把这个字段显示在指定位置",那你对Odoo的理解就超过了大多数只写业务模块的开发新手。
2. 三种继承方式:模型继承、视图继承、模块依赖继承,第一讲先吃透哪几种
很多人一查资料,看到"继承有三四种写法",直接被唬住了。实际上,日常开发里用得最多的是两类,剩下的是特定场景才需要。我在这一节帮你把概念彻底理清。
2.1 模型继承:两种写法要分清
模型继承有两种典型写法,效果完全不同,这是最容易被混淆的点。
第一种是扩展式继承,也是最常用的:
class SaleOrder(models.Model): _inherit = 'sale.order'这种写法不创建新模型,只对'sale.order'现有模型打补丁。你在里面定义的字段会直接加到'sale.order'模型上,定义的方法会直接替换或扩展同名方法。这是二开最主力的方式。
第二种是原型继承,也叫复制继承:
class CustomOrder(models.Model): _inherit = 'sale.order' _name = 'custom.order'注意,这里同时定义了_inherit和_name。含义是:以sale.order为模板复制出一个新模型custom.order。新模型拥有原模型的所有字段,但本质上和原模型没有ORM层面的耦合。这种写法主要用于你不想污染原模型、但要复用一套完整字段设计的情况,比如做特殊单据类型时。
第一讲你只需要吃透第一种,第二种等做到复杂业务再回头补也不晚。
2.2 视图继承:界面改动的正确打开方式
视图继承的核心思路和模型继承如出一辙:通过inherit_id指定要继承的原始视图,然后在arch结构里用XPath定位到某个具体位置,插入或修改内容。
我先给你一个感性的认识:
<record id="view_partner_form_inherit" model="ir.ui.view"> <field name="name">res.partner.form.inherit.customer_source</field> <field name="model">res.partner</field> <field name="inherit_id" ref="base.view_partner_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='city']" position="after"> <field name="customer_source"/> </xpath> </field> </record>这段代码的意思是:"把合作伙伴表单视图找出来,在city字段后面插入一个叫customer_source的字段。"inherit_id指向你要改的原始视图,xpath负责告诉你改在哪里。
2.3 三种继承方式一张表看懂分工
| 继承方式 | 核心关键字 | 作用对象 | 第一讲掌握等级 |
|---|---|---|---|
| 模型扩展继承 | _inherit | 模型字段与方法 | 必须掌握 |
| 模型原型继承 | _inherit+_name | 复制模型生成新模型 | 了解即可 |
| 视图继承 | inherit_id | 视图界面结构与行为 | 必须掌握 |
顺带解释一个常见困惑:为什么讲继承一定要扯到depends?因为你要继承res.partner,你的模块必须在manifest的depends里声明依赖基础模块base;你要继承销售订单,就要声明依赖sale。这不是走形式,Odoo在升级模块时会根据依赖关系决定加载顺序。依赖声明错误,继承的模型或视图根本没被加载,报错的时候你会莫名其妙。
3. 模型继承实操:_inherit、字段扩展与方法重写的完整代码拆解
这一节是硬核部分,我以一个我从实际项目里提炼出来的需求为例带着你完整过一遍。需求背景是这样:客户做销售业务,想知道每个客户是通过什么渠道来的,比如官网、老客户转介绍、广告。他们希望这个字段直接挂在合作伙伴表单上,并且当销售订单确认时,如果是转介绍来的客户,自动在订单备注里写一句话提醒跟单员回访。
这个需求典型地覆盖了模型继承的三个动作:加字段、上加字段相关的界面、重写方法。我们一部分一部分来。
3.1 搭建模块骨架,别把manifest写错
先建一个模块目录,我习惯命名为partner_customer_source:
partner_customer_source/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ └── res_partner.py └── views/ └── res_partner_views.xml__manifest__.py是模块的门面,第一讲你重点关注depends和data这两项:
{ 'name': '客户来源管理扩展', 'version': '16.0.1.0.0', 'category': 'Sales', 'depends': ['base', 'sale'], 'data': [ 'views/res_partner_views.xml', ], 'installable': True, 'application': False, }这里有两个关键点。第一,depends为什么既要base又要sale?因为我们要给res.partner加字段,它属于基础模型,声明base是底线;同时我们要重写sale.order的方法,必须声明依赖sale。第二,data里只放视图文件,没有模型文件,因为模型文件是通过Python的import机制加载的,不需要在data中声明。
3.2 用_inherit给既有模型加字段
models/res_partner.py里面这样写:
from odoo import models, fields class ResPartner(models.Model): _inherit = 'res.partner' customer_source = fields.Selection( [('website', '官网询盘'), ('referral', '老客户转介绍'), ('ad', '广告投放'), ('other', '其他')], string='客户来源', default='other', help='记录该客户第一次接触公司的渠道' )这段代码要这么理解:_inherit = 'res.partner'告诉Odoo我要扩展这个现有模型,customer_source字段此时就真的成为了合作伙伴模型的一个字段。它跟你在标准模块里定义字段没有本质区别,可以正常用在搜索、分组、列表中。
我来解释一下这个字段设计里的实用细节。用Selection是因为渠道这种东西枚举值通常可控;default='other'是为了防止历史数据为空导致列表视图排序或过滤异常;help写清楚业务含义,鼠标悬停会显示,对后续维护的人非常友好。
然后,因为我的业务场景要求销售订单确认时读取这个字段,所以还需要在销售订单模型里同样通过继承追加一条关联逻辑。新建models/sale_order.py:
from odoo import models, fields class SaleOrder(models.Model): _inherit = 'sale.order' note = fields.Text(string='备注')等等,sale.order本身已经有note字段了。如果你在扩展继承里重新定义一个同名普通字段,会造成字段定义冲突。正确的做法是:如果只是想要一个备注字段,直接用原生的note即可。我在这里特意提这个,是想提醒你一个第一讲必须建立的意识:扩展字段之前,先确认原模型里有没有同名字段,别重复定义。你可以从界面开发者菜单里查看模型字段列表,或者直接查ir.model.fields表。
既然原模型已有note,那models/sale_order.py就不需要写任何字段了,我们只需要在后续重写方法时直接使用order.note。
3.3 方法重写:super()的写法和返回值处理
在前面的基础上,现在我要重写action_confirm()这个方法。先看代码:
from odoo import models, fields, api class SaleOrder(models.Model): _inherit = 'sale.order' def action_confirm(self): res = super().action_confirm() for order in self: if order.partner_id.customer_source == 'referral': order.note = (order.note or '') + '本单来自老客户转介绍,请在交付后安排回访。' return res关键点全在super()这一行。它的作用是:先把控制权交还给被我们覆盖的原始方法,让标准模块的确认逻辑完整执行一遍,然后我们拿到返回结果,再做扩展处理。
这里有一个新手特别容易犯的错:在super()调用之后立刻return,导致后面的扩展代码永远不会执行。比如:
# 错误写法 def action_confirm(self): res = super().action_confirm() return res for order in self: # 这行永远执行不到 ...还有一种错误是忘记保存返回值。action_confirm()在不同的Odoo版本里返回值不一样,有时是bool,有时是action字典。如果你在方法里做了很多操作却漏了return res,前端点击确认按钮时行为会变得异常,有时代理单能确认但页面不跳转,有时代理单根本没建出来。
之所以在super()之后做扩展,是因为订单确认动作本身可能修改了state、生成了仓库单据、创建了日志。如果我先遍历再调super(),我基于的状态就是旧状态,扩展逻辑可能写错上下文。先执行原始逻辑,再在结果基础上调整,是方法扩展最安全的姿势。
另外要注意super()在Odoo里的使用风格。新版Python推荐直接写super().action_confirm(),不需要传参,简洁且安全。老代码里你会看到super(SaleOrder, self).action_confirm(),那是Python 2时代遗留写法,现在没必要模仿。
3.4 字段顺序、默认值与相关字段的补充说明
围绕模型继承还有几个冷门但实用的细节值得第一讲就掌握。
字段在表单里的显示顺序,通常由视图里的位置决定,跟模型里定义顺序无关。但如果你用fields_view_get动态加字段,或者在列表视图里没显式写出字段顺序,默认顺序就按ORM模型字段的创建顺序来。继承字段的创建顺序晚于原生字段,所以在列表视图如果不指定,继承字段会排在很后面。
再说related字段。如果你要继承的源字段在关联模型上,可以用related直接拉过来:
source = fields.Selection(related='partner_id.customer_source', string='客户来源', readonly=True)这样在订单行甚至订单层面都能直接看到客户来源,不需要手动同步值。related字段是Odoo的高级特性,但在第一讲里我建议你只读使用,不要想着通过它自动回写,等完全理解了字段存储机制再玩双向联动。
关于默认值的补充:Selection字段设置default='other'只是界面层的预选值,不是数据库层面的约束。如果某条记录没有设置这个字段,查询结果可能是False。所以我在扩展方法里判断时用的是== 'referral',而不是if order.partner_id.customer_source:,因为'other'和False都有可能。这种细节在大批量数据处理时非常关键,经常有人的判断逻辑莫名失效,查到最后是默认值没有真正落到数据上。
4. 视图继承实操:XPath定位、arch结构修改与属性调整
模型层的字段加好了,方法也重写了,现在必须让用户界面把这些新东西显示出来。视图继承是这中间最难、也最体现经验的部分。
4.1 在XML中声明视图继承的完整姿势
还是用前面那个views/res_partner_views.xml:
<?xml version="1.0" encoding="UTF-8"?> <odoo> <record id="view_partner_form_inherit" model="ir.ui.view"> <field name="name">res.partner.form.inherit.customer_source</field> <field name="model">res.partner</field> <field name="inherit_id" ref="base.view_partner_form"/> <field name="arch" type="xml"> <xpath expr="//field[@name='city']" position="after"> <field name="customer_source" required="False"/> </xpath> </field> </record> </odoo>几点说明:
name字段理论上全局可以不唯一,但在项目里养成用"模型+类型+业务含义"的命名习惯,比如res.partner.form.inherit.customer_source,后续排查视图问题时光看名字就知道它继承自谁。model声明这个视图作用于哪个模型。最关键的是inherit_id里的ref,它指向你要修改的目标视图的外部ID。
base.view_partner_form是Odoo基础模块里合作伙伴表单视图的外部ID。问题来了:我怎么知道每个标准视图的外部ID?最简单的办法是利用开发者模式。在开发模式下,打开视图界面,下拉框选择对应视图,就能看到它的External ID。如果你没有开发模式权限,也可以去源码里翻base/views/res_partner_views.xml,不过那确实费劲。
4.2 XPath定位的几种写法,按场景选择
xpath的expr属性是整个视图继承的核心,它决定了你要操作的位置。最常见的写法是基于字段名定位:
<xpath expr="//field[@name='city']" position="after">这种写法的完整含义是:找到文档树中field标签且name属性等于city的第一个节点,在它后面插入内容。
除了字段,还可以用其他属性定位。比如找按钮:
<xpath expr="//button[@string='Confirm Sale']" position="attributes">或者用更宽泛的属性组合。expr本质是XPath语法,理论上任何标准的XPath表达式都可用。但我在实践中总结出一条铁律:能通过field[@name='xxx']定位就不要绕弯子。理由有两个,第一是标准模块里字段名相对稳定,第二是name属性在视图arch中语义清晰,不容易误伤。
如果你要操作的元素在重复区域里,比如订单行明细里面某个字段:
<xpath expr="//field[@name='order_line']/tree/field[@name='product_id']" position="after">这里的关键是层级关系必须写对。order_line是一个One2many字段,它内部有子tree,你要定位的是这个树视图内部的字段。很多新手写成全局的//field[@name='product_id'],如果表单里恰好还有其他地方有同名product_id,就会定位出多个节点,直接报错。
4.3 常用操作:加字段、改显示、加按钮、替换文本
视图继承在position属性上有五种操作,第一讲你必须掌握两种,其他的了解即可。
**after和before**是最常用的插入类操作,用法前面已经示范过。在某个字段前或后插入新字段,这是加字段的标准姿势。
**attributes**用于修改已有节点的属性。比如你想把某个字段的必填属性动态改掉:
<xpath expr="//field[@name='customer_source']" position="attributes"> <attribute name="required">True</attribute> </xpath>再比如你想给按钮添加确认弹窗:
<xpath expr="//button[@name='action_confirm']" position="attributes"> <attribute name="confirm">确定要确认这个订单吗?</attribute> </xpath>**replace**用于整体替换一个节点。比如你要替换某个提示文本:
<xpath expr="//div[@name='some_info']" position="replace"> <div>新的提示内容</div> </xpath>replace能不留痕迹地改界面,但风险也高——如果目标节点里包含其他模块继承添加的内容,替换会把这些内容一起干掉。我建议能用attributes解决的绝不replace,这是惨痛教训换来的。
关于视图继承,还有一个新版版本细节。Odoo 13之后,继承视图默认就是mode="extension",你不需要在record里显式写mode,系统会自动把它当作扩展视图处理。旧资料里教人写mode="primary"或mode="extension",那是Odoo 11时代的老黄历,现在别照搬。
4.4 视图继承后的优先级与字段归属问题
当你写了多个视图继承同一个原始视图时,会出现叠加效应。Odoo会根据sequence属性和模块加载顺序决定这些扩展视图的应用顺序。默认sequence是16,如果你希望自己的修改在最后生效,就把sequence设大一些;要覆盖别人的修改,就设小一些。
再看字段归属。在表单视图中显示一个字段,不代表这个字段属于你当前开发的模型。比如你在合作伙伴表单上插入customer_source,这个字段本身定义在合作伙伴模型上,任何模块都能在视图里引用它。视图继承只是界面层的事,不产生字段所有权。
有一种需求是:字段要显示,但只能由特定用户组修改。我经常在继承视图里配合groups属性使用:
<field name="customer_source" groups="sales_team.group_sale_salesman"/>这样普通用户看得到字段但不可见可编辑,销售团队成员才能看到完整字段。这种受限可见性完全是视图层控制,比在模型层做权限判断要直观得多。
5. 模块继承开发中必须避开的几个坑(第一讲版)
教程讲思路是线性的,但实际开发会遇到各种意外。这一节我专门整理第一讲阶段最常踩的坑,每个都是我或我身边的同事真实碰到过的。
5.1 视图继承报错"Element cannot be located"的完整排查链路
这是视图继承里最高频的报错,几乎每个写继承的人都会遇到。完整的报错信息长这样:
odoo.tools.convert.ParseError: while parsing res.partner.form.inherit.customer_source Element cannot be located in parent view: field[attribute='name'] with value 'city'别慌,这个信息很明确:你的XPath表达式在target视图里没找到对应的元素。排查按以下顺序来:
第一步,确认inherit_id里ref指向的视图编号是否正确。打开开发者模式的"视图"菜单,搜索目标模型,看它的表单视图外部ID是什么。如果你继承的是base.view_partner_form,但实际视图的external ID根本不是这个,必然报错。
第二步,确认目标字段真的存在于你继承的那个视图文件里。注意,同一个字段可能在模型里存在,但不在这个特定视图的架构里。比如city这个字段可能只存在于base.view_partner_form里,而res.partner的树视图里根本没有。这时候你如果用一个树视图作为inherit_id,再去定位city字段,自然定位不到。
第三步,检查你的XPath表达式是否写得太泛或太具体。//field[@name='city']这种写法在表单视图里通常没问题,但如果字段在可编辑列表视图的多行区域内,可能需要更精确的定位路径。
最后,检查你的模块在升级时有没有报其他错误。视图继承是相互依赖的——如果多个模块同时修改同一个视图,其中一个定位失败,后面的都会失败。排查时把所有自定义模块的日志打开,找到最早报错的那个。
5.2 视图外部ID冲突与继承顺序问题
你能继承标准视图,别人也能。这是Odoo多模块协作的正常形态,但也带来一个隐患:多个模块往同一个位置插入内容,顺序不可控。
举个例子,A模块在city字段后加一个字段,B模块也在city字段后加一个字段。最终哪个在前,取决于两个模块的安装顺序和加载顺序。如果你是B模块的维护者,你没法保证用户在装B模块之前一定装了A模块。
解决思路有两个。第一,不要刻意依赖其他自定义模块的加载顺序;第二,如果你的字段必须显示在某个确定的相对位置,优先选择一个标准视图里稳定存在的字段作为锚点,而不是依赖另一个自定义模块插入的字段。
id冲突也是新手碰得多的。视图的id在模块内部要唯一,但更重要的是跨模块引用时的可预期性。我给你一个建议:所有视图继承的id都加上模块名前缀,比如我们模块里的view_partner_form_inherit就够清晰,但如果你公司有多个项目组同时开发,最好写成partner_customer_source.view_partner_form_inherit这种带模块名的形式。避免未来合并代码时出现external ID撞车。
5.3 升级模块但看不到效果时的检查顺序
这个问题几乎每周都有人在社区里问:"为什么我加了字段,更新模块后界面没变化?"
我的检查顺序是:
第一步,确认是否真的在Odoo里执行了升级。在应用菜单里找到你的模块,点击"升级",而不是只点击"应用"。修改Python代码,仅重新启动服务是不够的;修改XML视图,必须升级模块才会重新解析。如果你只重启了服务,旧的视图定义还留在数据库里。
第二步,看是否有报错。升级模块后立即查看日志。视图继承相关的错误通常会在升级时被捕获。如果你忽略了报错,界面自然不会有变化。
第三步,确认你在正确的视图下查看。我见过有人改了销售订单表单视图,却去合作伙伴列表界面找新字段,找了半天没找到。字段在哪个模型上,就要去哪个模型的视图里看。
第四步,考虑浏览器缓存。Odoo前端的assets会做缓存,特别是视图相关的静态资源。多按几下Ctrl+Shift+R强刷,再进界面看。如果这步之前一切正常,很可能是缓存导致的——这种情况我碰到的次数可比想象中多得多。
5.4 方法重写时super()用错导致的连锁问题
前面讲了super()的顺序问题,但还有一个更隐蔽的坑:别把super()的结果随意修改后返回。
某些扩展方法,比如创建或写入记录(create()、write()),返回值的类型和语义有严格约定。你在扩展时往往需要对记录集做额外操作,但绝对要遵循原方法的返回约定。我曾经看到一个同事这样写:
def write(self, vals): if 'customer_source' in vals: vals['customer_source'] = 'other' return super().write(vals)表面上逻辑没问题,但他在某些条件下直接return True,跳过了super().write(),导致字段值根本没写入数据库。这个Bug因为每个条件分支都走了不同的返回路径,排查了整整一下午。
更安全的做法是:在任何情况下,都让super()完整执行一次,再在返回值基础上做微调。如果确实需要提前结束,一定要清醒地知道你在放弃什么。
另外,不要在create()或write()里用self.field = value这样的ORM自动更新语法,这个在重写方法中很容易触发二次写入甚至递归。需要改值就去改vals字典。这个细节值得贴在你的IDE注释里。
5.5 多模块协作时的继承冲突预防
当一个系统里跑着好几个二开模块时,视图继承的冲突概率直线上升。我处理过的最典型一个场景是:两个模块同时继承销售订单的表单视图,各自在不同位置加了字段,结果安装顺序一变,有个字段跑到完全不合理的区域里。
预防有四个原则,建议你在团队里推广:
一个模块只继承一个视图的一个位置,不要贪多。聚合需求到同一个模块里处理,比分散在N个模块里更容易维护。记录锚点字段的选择,尽量选那些标准视图里长期稳定存在的字段,而不是其他模块的私有字段。最后,定期做全局视图审查,开发模式下打开每个视图,检查arch结构是否符合预期。
这几个原则执行到位,所谓继承冲突基本就能扼杀在摇篮里。
最后再分享一点我个人体会。第一讲的内容学完,你能做的已经不少了,但别急着把所有继承技术都塞进脑子里。我在带新人时发现,把模型扩展继承和视图继承吃透的人,后续学习报表继承、菜单继承、数据继承时都非常快。Odoo官方文档很长,很多人翻两页就把自己劝退了,说实话没必要——上手就用,遇到问题再查,是这门技术最有效的打开方式。下一篇我会讲计算字段和约束算法在继承场景里的应用,那种"字段值自动从关联模型算出来"的需求,很多电商和分销项目都会碰到,到时候我们继续用第一讲搭好的模块往里加内容。