跳轉至

建立 2026-09-20 更新 2026-09-20

繼承與擴充

Odoo 客製最重要的觀念:不要改 Odoo 原始碼,也不要複製標準模組來改。改了原始碼,下次升級就全部被覆蓋。正確做法是用自己的模組去「繼承」標準的模型與檢視,在旁邊疊加功能。

本頁延續 實作:圖書借閱模組,替標準的聯絡人(res.partner)加上:借書證號欄位、借閱數的智慧按鈕(smart button)、有未歸還書籍時禁止刪除。這三件事剛好對應繼承的三種手法。

三種繼承手法

想做的事 手法 放在哪
幫既有模型加欄位、加方法 模型繼承:_inherit = "res.partner"不寫 _name models/*.py
修改既有檢視(加欄位、加按鈕、隱藏) 檢視繼承:inherit_id + xpath views/*.xml
改變既有方法的行為 覆寫方法,並呼叫 super() models/*.py

還有兩種進階用法,這裡只需要知道存在:

  • Mixin_inherit = ["mail.thread"] 一次獲得聊天室與追蹤功能(模組要加 depends: ["mail"])。
  • _inherits 委派:新模型「內含」另一個模型,例如員工內含聯絡人。

模型繼承:擴充聯絡人

models/res_partner.py
from odoo import _, api, fields, models
from odoo.exceptions import UserError


class ResPartner(models.Model):
    _inherit = "res.partner"

    loan_ids = fields.One2many("library.loan", "borrower_id", string="借閱紀錄")
    library_card = fields.Char(string="借書證號")
    loan_count = fields.Integer(string="借閱數", compute="_compute_loan_count")

    @api.depends("loan_ids")
    def _compute_loan_count(self):
        groups = self.env["library.loan"]._read_group(
            [("borrower_id", "in", self.ids)],
            groupby=["borrower_id"],
            aggregates=["__count"],
        )
        counts = {borrower.id: count for borrower, count in groups}
        for partner in self:
            partner.loan_count = counts.get(partner.id, 0)

    def action_view_loans(self):
        self.ensure_one()
        return {
            "type": "ir.actions.act_window",
            "name": _("借閱紀錄"),
            "res_model": "library.loan",
            "view_mode": "list,form",
            "domain": [("borrower_id", "=", self.id)],
            "context": {"default_borrower_id": self.id},
        }

    def unlink(self):
        active_loans = self.env["library.loan"].search_count([
            ("borrower_id", "in", self.ids),
            ("state", "=", "borrowed"),
        ])
        if active_loans:
            raise UserError(_("尚有未歸還的書,不能刪除這位聯絡人。"))
        return super().unlink()

記得在 models/__init__.py 加一行 from . import res_partner,並在 manifest 的 data 末尾加 "views/res_partner_views.xml"(下一節建立)。

逐項說明:

  • 只有 _inherit、沒有 _name:表示「不建立新模型,而是把這些內容加到既有的 res.partner」。library_card 會變成 res_partner 資料表的新欄位,升級模組(-u library)時才建立。
  • _read_group:Odoo 18 的分組查詢,回傳 (記錄, 筆數) 的列表。這裡一次查出所有聯絡人的借閱數,比在迴圈裡對每位聯絡人各查一次快得多,這是避免 N+1 查詢的標準做法。
  • action_view_loans:按鈕呼叫的方法,回傳一個動作(action)字典,Odoo 就會開啟對應的視窗。domain 篩出這位聯絡人的借閱單,contextdefault_borrower_id 讓在這個畫面新增時自動帶入借閱人。
  • unlink(覆寫):先檢查,符合條件就 raise UserError(給使用者看的錯誤,會以對話框顯示),否則 return super().unlink(),把工作交還給原本的實作。

覆寫方法一定要呼叫 super(),並回傳它的結果

忘了 super(),等於把標準行為整個吃掉;其他也繼承這個方法的模組也會跟著壞。除非你確定要完全取代,否則永遠是「先做自己的事,再 return super()...」或「先 super(),再做自己的事」。

踩雷實錄:計算欄位為什麼沒更新

這個模組的測試一開始沒寫 @api.depends("loan_ids"),結果跑出:

FAIL: TestLoan.test_partner_loan_count_and_unlink
    self.assertEqual(self.partner.loan_count, 1)
AssertionError: 0 != 1

原因是:loan_count 第一次讀取得到 0,Odoo 把結果放進快取;沒有 depends,Odoo 不知道新增借閱單會影響它,所以之後一直拿到快取的 0。加上 @api.depends("loan_ids") 後就正確了。

規則:每個計算欄位都要寫 @api.depends,列出它讀取的所有欄位。 讀到關聯欄位的子欄位時用點號,如 @api.depends("loan_ids.state")

另外,非儲存的計算欄位不能拿來搜尋

env["res.partner"].search([("loan_count", ">", 0)])
# ERROR: Non-stored field res.partner.loan_count cannot be searched.

要能搜尋,可以改成 store=True(並確保 depends 完整),或提供 search= 方法。

檢視繼承:加按鈕與欄位

views/res_partner_views.xml
<odoo>
  <record id="view_partner_form_inherit_library" model="ir.ui.view">
    <field name="name">res.partner.form.inherit.library</field>
    <field name="model">res.partner</field>
    <field name="inherit_id" ref="base.view_partner_form"/>
    <field name="arch" type="xml">
      <div name="button_box" position="inside">
        <button name="action_view_loans" type="object"
                class="oe_stat_button" icon="fa-book">
          <field name="loan_count" widget="statinfo" string="借閱"/>
        </button>
      </div>
      <xpath expr="//field[@name='phone']" position="after">
        <field name="library_card"/>
      </xpath>
    </field>
  </record>
</odoo>

inherit_id 指向要修改的原檢視(base.view_partner_form 是「模組.xml id」),arch 裡只寫「要改哪裡、怎麼改」,不需要複製整個檢視。

定位方式有兩種寫法:

  • 簡寫:直接用原檢視裡的元素當定位點,並帶上 name 屬性與 position。例如 <div name="button_box" position="inside">
  • xpath:用 XPath 運算式精準定位,例如 //field[@name='phone'] 是「name 為 phone 的 field」。

position 的可用值:

position 作用
inside 放進該元素內部的最後面
after 放在該元素後面(同層)
before 放在該元素前面
replace 取代該元素(拿掉就寫空的)
attributes 修改該元素的屬性

改屬性的寫法,例如把電話設成必填:

<xpath expr="//field[@name='phone']" position="attributes">
  <attribute name="required">1</attribute>
</xpath>

怎麼知道原檢視的 xml id 與結構

先啟用開發者模式(網址加 ?debug=1),畫面上方會出現蟲形圖示,選單裡有編輯目前檢視、檢視中繼資料(含 xml id)等項目。也可以直接到原始碼搜尋,例如聯絡人表單在 odoo/addons/base/views/res_partner_views.xml

升級並驗證

odoo-bin -d mydb -u library --stop-after-init

shell 快速驗證,不必開網頁:

odoo-bin shell -d mydb
>>> p = env["res.partner"].search([("name", "like", "王小明")])
>>> p.loan_count
1
>>> p.unlink()
odoo.exceptions.UserError: 尚有未歸還的書不能刪除這位聯絡人

網頁上打開該聯絡人,會看到電話下方多了「借書證號」,右上角多了「借閱」智慧按鈕,點下去進入這位聯絡人的借閱單清單。

繼承常見問題

現象 原因
升級後畫面沒有新欄位 檢視檔沒列進 manifest 的 data,或欄位改了但沒 -u
ParseError ... Field "xxx" does not exist in model 檢視引用的欄位不存在。欄位名稱打錯,或定義該欄位的檔案沒被 models/__init__.py 匯入
External ID not found in the system 引用了還沒載入的 xml id。檢查 manifest data 順序,以及 depends 有沒有包含定義它的模組
繼承了 sale.order 卻找不到它 manifest 的 depends 沒加 sale你繼承誰,就要依賴誰
覆寫的方法沒被呼叫 該方法在別處被以 SQL 或其他方式繞過,或父類別的簽名不同。先用 _logger 或 shell 確認

練習

  • 在聯絡人表單「借書證號」欄位旁,顯示借閱中的書本數(提示:多寫一個計算欄位,state == "borrowed"
  • 覆寫 library.loan.action_return:歸還時若已逾期,用 _logger.info 記錄一筆日誌(記得 super()
  • 仿照聯絡人的作法,替書籍表單也加一個「借閱數」智慧按鈕(提示:library.book 是自己的模型,直接改它的表單檢視即可,不需要 _inherit

下一步

測試與除錯:把上面驗證過的行為寫成自動化測試,並整理常見錯誤訊息。

進一步學習