每家公司的業務都有獨特之處:零售業需要保修登記、製造業需要客製工序、服務業需要特殊計費邏輯。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 實施項目中,用教訓換來的檢查清單:
- 技術名稱紀律:模組、模型的技術名稱(product_warranty)一旦發佈就不可更改——它是資料庫表名與外部 ID 的一部分,改名的代價是整套資料遷移。
- 所有資料用 XML 而非程式碼建:流水號、群組、規則放 data/ 目錄,讓它們進入 __manifest__.py 的 data 清單,享有版本管理與 noupdate 機制。
- 批量操作時想清楚 recordset:Odoo 的每個方法都可能收到一組記錄而非單筆,for rec in self 與 ensure_one() 是保命符。
- 別在 compute 裡做副作用操作:compute 應保持純粹(只算值、不寫其他記錄、不發郵件),副作用放在動作方法(action_*)或 create/write 覆寫中。
- 翻譯從第一天做起:所有使用者可見字串用 _() 包裹,否則日後中英切換時要全局翻找。
- 開發流程三件套:Git 版本控制、測試資料庫與生產資料庫分離、每次升級前完整備份。缺一不可。
- 能組態就別客製:很多「需要客製」的需求,其實用 Studio、計算欄位或自動化動作(Automated Actions)就能解決。客製模組是最後手段,不是第一手段。
結語
Odoo 的模組化架構,讓「客製化」從高風險的核心魔改,變成有紀律的標準工程:模型定義資料、視圖定義介面、權限定義邊界、測試定義品質、遷移定義演化路徑。掌握這條流水線,你就能把任何業務需求,轉譯成一個可維護、可升級、可傳承的 Odoo 模組。
本篇的完整範例程式碼(含本文未展開的流水號設定與搜尋視圖),可依文中介紹的目錄結構直接組裝。如果在實作過程遇到問題,或想探討更進階的主題——伺服器動作(Server Actions)、計劃任務(Cron)、網站控制器(Controller)——歡迎與我們交流。
需要專業的 Odoo 開發團隊?
Asta Systems Ltd 專注於 Odoo 實施、客製模組開發與系統整合
從電商、POS 到倉儲,為零售與貿易企業提供一站式數碼轉型方案
📧 歡迎聯絡我們,探討你的客製化需求