跳至內容

Odoo 客製化模組開發實戰 從 ORM 模型到部署升級的完整攻略

不改核心程式碼,也能讓 ERP 完美貼合業務。以一個「產品保修登記」模組為例,走完 Odoo 開發的完整生命週期。
2026年8月27日 作者
Odoo 客製化模組開發實戰 從 ORM 模型到部署升級的完整攻略
Asta Systems Limited

每家公司的業務都有獨特之處:零售業需要保修登記、製造業需要客製工序、服務業需要特殊計費邏輯。Odoo 的 70 多個官方模組覆蓋了 80% 的通用需求,剩下那 20% 的獨特需求,正是客製化模組(Custom Module)的用武之地。

這篇文章會帶你從零開發一個完整可用的 Odoo 模組——「產品保修登記」(Product Warranty),它會覆蓋 Odoo 開發的核心知識:模組結構、ORM 模型、視圖繼承、權限控制、業務邏輯、資料遷移與部署升級。讀完這篇,你就有能力獨立交付一個符合 Odoo 最佳實踐的客製模組。

一、鐵律:永不修改核心程式碼

開始之前,先建立 Odoo 開發最重要的觀念。Odoo 的所有功能都是模組(Module)——銷售、庫存、會計,全部由模組堆疊而成,而客製化模組與官方模組平起平坐,透過繼承(inheritance)機制擴展原有功能。

為什麼絕對不能直接改核心或官方模組的程式碼?

  • 升級即毀滅:每次 Odoo 版本升級,被你改過的檔案會直接被官方新版覆蓋,所有修改一夜歸零。
  • 失去支援:混入核心修改的實例,官方與 Odoo.sh 都難以排查問題。
  • 無法回溯:核心改動通常缺乏版本管理,出錯時難以定位與回退。

💡 正確姿勢

所有客製行為都放進自己的模組,用繼承機制擴展:改欄位用 _inherit、改視圖用 xpath 定位、改邏輯用方法覆寫。模組放進版本控制(Git),升級時客製層與官方層各自獨立演化,永不相撞。

二、模組結構解剖

一個標準的 Odoo 模組,目錄結構長這樣:

product_warranty/                ← 模組根目錄(技術名稱,必須全小寫、底線分隔)
├── __init__.py              ← Python 套件入口,宣告要載入的模型
├── __manifest__.py          ← 模組身分證:名稱、版本、依賴、資料檔清單
├── models/                  ← ORM 模型(業務邏輯核心)
│   ├── __init__.py
│   ├── product_warranty.py
│   └── sale_order.py       ← 繼承擴展銷售訂單
├── views/                   ← 視圖定義(表單、列表、搜尋)
│   ├── product_warranty_views.xml
│   └── sale_order_views.xml
├── security/
│   └── ir.model.access.csv ← 模型存取權限(ACL)
├── data/                    ← 預設資料(如流水號規則、郵件模板)
│   └── sequence.xml
└── tests/                   ← 單元測試
    ├── __init__.py
    └── test_warranty.py

其中 __manifest__.py 是模組的「身分證」,也是 Odoo 安裝模組時第一個讀的檔案:

{
    "name": "Product Warranty",
    "summary": "產品保修登記與追蹤",
    "version": "1.0.0",
    "category": "Sales",
    "author": "Asta Systems Ltd",
    "license": "LGPL-3",
    "depends": ["sale", "product"],        # 依賴:安裝本模組前會先裝這些
    "data": [
        "security/ir.model.access.csv",   # 權限必須最先載入
        "data/sequence.xml",
        "views/product_warranty_views.xml",
        "views/sale_order_views.xml",
    ],
    "application": False,
    "installable": True,
}

⚠️ 常見陷阱:data 清單順序

security/ir.model.access.csv 必須排在清單最前面。曾有無數開發者因為權限檔載入順序在視圖之後,在全新資料庫安裝時直接撞上「Access Error」——因為視圖載入時會建立示範資料,而此時權限還沒生效。

三、ORM 模型:業務邏輯的心臟

Odoo 的 ORM(物件關聯映射)讓你用 Python 類別描述資料表,無需手寫 SQL。先定義我們的保修登記模型:

# models/product_warranty.py
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from datetime import timedelta


class ProductWarranty(models.Model):
    _name = "product.warranty"
    _description = "產品保修登記"
    _order = "create_date desc"
    _rec_name = "display_code"

    # ===== 基本欄位 =====
    display_code = fields.Char("保修編號", default="New", copy=False)
    partner_id = fields.Many2one(
        "res.partner", "客戶",
        required=True, index=True,
        ondelete="restrict",        # 有保修在身時禁止刪除客戶
    )
    product_id = fields.Many2one(
        "product.product", "產品",
        required=True, domain=[("type", "=", "consu")],  # 限定可庫存產品
    )
    sale_order_id = fields.Many2one("sale.order", "來源訂單")

    # ===== 日期欄位 =====
    start_date = fields.Date("保修起始日", default=fields.Date.context_today)
    duration_months = fields.Integer("保修月數", default=12)

    # ===== 計算欄位:由其他欄位即時推導,不佔資料庫空間 =====
    end_date = fields.Date(
        "保修到期日", compute="_compute_end_date",
        store=True,  # 存進資料庫 → 可搜尋、可排序、可做條件
    )
    state = fields.Selection([
        ("draft", "草稿"),
        ("active", "生效中"),
        ("expired", "已到期"),
        ("cancelled", "已取消"),
    ], "狀態", default="draft", index=True)

    # ===== 關聯欄位:直接讀取關聯模型的欄位 =====
    partner_phone = fields.Char(related="partner_id.phone", string="聯絡電話")

    # ===== SQL 約束:資料庫層的最後防線 =====
    _sql_constraints = [
        ("duration_positive",
         "CHECK(duration_months > 0)",
         "保修月數必須大於 0"),
        ("display_code_uniq",
         "UNIQUE(display_code)",
         "保修編號不可重複"),
    ]

    # ===== 計算方法 =====
    @api.depends("start_date", "duration_months")
    def _compute_end_date(self):
        for rec in self:
            if rec.start_date:
                rec.end_date = rec.start_date + timedelta(
                    days=30 * rec.duration_months)
            else:
                rec.end_date = False

    # ===== 業務動作:狀態機推進 =====
    def action_activate(self):
        self.ensure_one()
        self.write({
            "state": "active",
            "display_code": self.env["ir.sequence"].next_by_code(
                "product.warranty") or "WR-00001",
        })

    # ===== Python 約束:跨欄位業務規則 =====
    @api.constrains("start_date", "duration_months")
    def _check_dates(self):
        for rec in self:
            if rec.duration_months > 60:
                raise ValidationError(_("保修期最長為 60 個月"))

幾個值得記住的 ORM 細節

技巧說明
compute + store=True計算欄位存入資料庫,可搜尋排序;代價是依賴欄位變動時觸發重算,高頻寫入場景慎用
related 欄位便捷讀取關聯資料,底層等同 compute + depends,跨多層關聯時注意效能
ondelete="restrict"防止誤刪仍有業務關聯的記錄——資料完整性比事後補救便宜得多
ensure_one()動作方法第一行呼叫,確保單記錄語義,避免批量記錄時的隱性 bug
recordset 慣例寫 compute / constrains 時永遠 for rec in self——Odoo 方法可能收到批量記錄

四、繼承擴展:在銷售訂單上「掛」保修

客製模組的精髓在於無痛擴展既有模型。假設我們要在銷售訂單確認時,自動為每個訂單行建立保修登記:

# models/sale_order.py
from odoo import models, fields


class SaleOrder(models.Model):
    _inherit = "sale.order"          # 不填 _name,只填 _inherit = 擴展既有模型

    warranty_count = fields.Integer("保修數量", compute="_compute_warranty_count")
    warranty_ids = fields.One2many("product.warranty", "sale_order_id", "保修登記")

    @api.depends("warranty_ids")
    def _compute_warranty_count(self):
        for order in self:
            order.warranty_count = len(order.warranty_ids)

    # 覆寫官方的 action_confirm:先做事,再交還給原邏輯
    def action_confirm(self):
        res = super().action_confirm()          # 1) 先跑官方確認流程
        for order in self:
            Warranty = self.env["product.warranty"]
            for line in order.order_line:      # 2) 為每個訂單行建保修
                if line.product_id.detailed_type == "product":
                    Warranty.create({
                        "partner_id": order.partner_id.id,
                        "product_id": line.product_id.id,
                        "sale_order_id": order.id,
                        "state": "active",
                    })
        return res                                # 3) 把結果交還給上游

💡 super() 的位置哲學

覆寫官方方法時,super() 放最前面還是最後面是語義問題:前 super(先跑官方邏輯再做事)適合「官方流程成功後」的後續動作;後 super 適合前置校驗。本例用前 super,因為只有訂單確認成功,建保修才有意義。

五、視圖:用 XPath 精準插入 UI

模型定義好後,需要視圖讓使用者操作。客製視圖的寫法是繼承官方視圖,用 XPath 定位後插入內容

    
        product.warranty.form
        product.warranty
        
            
                
                      
                    
                
                
                    
                        
                            
                            
                            
                            
                        
                        
                            
                            
                            
                            
                        
                    
                
            
        
    

    
    
        product.warranty.list
        product.warranty
        
            
                
                
                
                
                
            
        
    

    
    
        保修登記
        product.warranty
        list,form
    

    

在官方視圖上動刀:XPath 繼承

想在銷售訂單表單加上保修資訊?繼承官方視圖,用 XPath 表達式定位插入點:

    sale.order.form.warranty
    sale.order
      
    
        
        
            
                
                    
                
            
        
    

⚠️ 版本相容性:視圖語法的世代差異

Odoo 17 起:<tree> 改為 <list>;attrs 屬性被移除,改為直接寫 invisible="state != 'draft'" 這類 Python 運算式。網路上的舊教學大多還在用 attrs="{'invisible': [...]}" 舊語法——在 Odoo 17+ 會直接報錯。寫模組前務必確認目標版本。

六、權限控制:CSV 裡的一行,決定誰能做什麼

Odoo 的安全模型是群組(Group)+ 存取權限(ACL)+ 記錄規則(Record Rule)三層結構。新模型沒設權限時,所有人都看不到、用不了——這是 Odoo 的預設拒絕(default deny)設計。

# security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_product_warranty_user,product.warranty.user,model_product_warranty,sales_team.group_sale_salesman,1,1,1,0
access_product_warranty_manager,product.warranty.manager,model_product_warranty,sales_team.group_sale_manager,1,1,1,1

解讀這兩行:

  • 業務員(salesman):可讀、可改、可建保修,不可刪除(perm_unlink = 0)
  • 業務經理(manager):完整權限,含刪除
  • model_product_warranty 是模型的外部 ID,安裝時 Odoo 自動生成,規則是 model_ + 模型名(點號換底線)

如果需要更細的控制——例如「業務員只能看自己的客戶的保修」——再加上記錄規則(Record Rule),用網域條件過濾可見記錄:

    保修:僅本人客戶
    
    [("partner_id.user_id","=",user.id)]
    
      

七、單元測試:讓模組經得起升級

客製模組最大的長期成本是維護——每次 Odoo 大版本升級,官方模型的行為可能微調,你的覆寫邏輯可能悄悄失效。單元測試是唯一的保險:

# tests/test_warranty.py
from odoo.tests.common import TransactionCase
from datetime import date


class TestWarranty(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env["res.partner"].create({"name": "測試客戶"})
        self.product = self.env["product.product"].create({
            "name": "測試產品", "type": "consu"})

    def test_end_date_computation(self):
        """保修到期日 = 起始日 + 月數 × 30 天"""
        warranty = self.env["product.warranty"].create({
            "partner_id": self.partner.id,
            "product_id": self.product.id,
            "start_date": date(2026, 1, 1),
            "duration_months": 12,
        })
        self.assertEqual(warranty.end_date, date(2026, 12, 31))

    def test_duration_constraint(self):
        """超過 60 個月必須被拒絕"""
        with self.assertRaises(ValidationError):
            self.env["product.warranty"].create({
                "partner_id": self.partner.id,
                "product_id": self.product.id,
                "duration_months": 120,
            })

執行方式:

# 本地開發環境執行測試(odoo-bin)
odoo-bin -d test_db -i product_warranty --test-enable --stop-after-init \
         --log-level=info --http-port=8099

八、部署與升級:模組的生命週期管理

安裝與升級指令

# 安裝模組( addons_path 已指向模組目錄)
odoo-bin -d mydb -i product_warranty

# 改了程式碼後,升級模組(重新載入 Python 程式碼 + XML 資料)
odoo-bin -d mydb -u product_warranty

# 生產環境建議:停機窗口內升級,先備份資料庫
pg_dump mydb > backup_$(date +%F).sql && odoo-bin -d mydb -u product_warranty

版本迭代與資料遷移

模組不是寫完就結束。當你為模型新增欄位,Odoo 的 -u 升級會自動在資料庫加上新欄位;但改欄位類型、改計算邏輯時,既有資料需要遷移腳本。正確做法是在模組內建遷移目錄:

product_warranty/
└── migrations/
    └── 1.1.0/              ← 對應 manifest 中的目標版本
        ├── pre-migration.py   ← 升級前執行(改結構、清資料)
        └── post-migration.py  ← 升級後執行(回填資料、修正值)
# migrations/1.1.0/post-migration.py
def migrate(cr, version):
    """v1.1.0:duration_months 改為必填,為舊資料回填預設值 12"""
    cr.execute("""
        UPDATE product_warranty
        SET duration_months = 12
        WHERE duration_months IS NULL OR duration_months = 0
    """)

💡 noupdate="1":保護使用者改過的預設資料

XML 中的示範資料(如流水號、郵件模板)預設每次 -u 都會被重置回檔案內容——使用者若在介面上改過,心血就沒了。對允許使用者後續修改的資料,加上 noupdate="1":<record id="..." noupdate="1">,安裝時建立、升級時不動。

九、實務最佳實踐清單

最後,是我們在多年 Odoo 實施項目中,用教訓換來的檢查清單:

  1. 技術名稱紀律:模組、模型的技術名稱(product_warranty)一旦發佈就不可更改——它是資料庫表名與外部 ID 的一部分,改名的代價是整套資料遷移。
  2. 所有資料用 XML 而非程式碼建:流水號、群組、規則放 data/ 目錄,讓它們進入 __manifest__.py 的 data 清單,享有版本管理與 noupdate 機制。
  3. 批量操作時想清楚 recordset:Odoo 的每個方法都可能收到一組記錄而非單筆,for rec in self 與 ensure_one() 是保命符。
  4. 別在 compute 裡做副作用操作:compute 應保持純粹(只算值、不寫其他記錄、不發郵件),副作用放在動作方法(action_*)或 create/write 覆寫中。
  5. 翻譯從第一天做起:所有使用者可見字串用 _() 包裹,否則日後中英切換時要全局翻找。
  6. 開發流程三件套:Git 版本控制、測試資料庫與生產資料庫分離、每次升級前完整備份。缺一不可。
  7. 能組態就別客製:很多「需要客製」的需求,其實用 Studio、計算欄位或自動化動作(Automated Actions)就能解決。客製模組是最後手段,不是第一手段。

結語

Odoo 的模組化架構,讓「客製化」從高風險的核心魔改,變成有紀律的標準工程:模型定義資料、視圖定義介面、權限定義邊界、測試定義品質、遷移定義演化路徑。掌握這條流水線,你就能把任何業務需求,轉譯成一個可維護、可升級、可傳承的 Odoo 模組。

本篇的完整範例程式碼(含本文未展開的流水號設定與搜尋視圖),可依文中介紹的目錄結構直接組裝。如果在實作過程遇到問題,或想探討更進階的主題——伺服器動作(Server Actions)、計劃任務(Cron)、網站控制器(Controller)——歡迎與我們交流。

需要專業的 Odoo 開發團隊?

Asta Systems Ltd 專注於 Odoo 實施、客製模組開發與系統整合

從電商、POS 到倉儲,為零售與貿易企業提供一站式數碼轉型方案

📧 歡迎聯絡我們,探討你的客製化需求

網誌: 客戶案例
顧問工時算不清、續約商機跟丟了 一間顧問公司的 Odoo 實錄
從人手記工時到系統自動開單,從商機靠腦記到 CRM 全程追蹤