跳轉至

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

實作:圖書借閱模組

開發入門 用最小的模組認識了清單檔、模型、檢視、權限。本頁從空資料夾開始,做出一個可以真正使用的模組:登記書籍、建立借閱單、借出與歸還、標示逾期,並依角色給不同權限。

做完你會用到 addon 開發最常見的 20%:關聯欄位、預設值、計算欄位、驗證、流水號、按鈕與狀態列、搜尋檢視、群組權限。

本頁所有程式碼都在 Odoo 18.0 社群版實際安裝過並通過測試。

先備:會啟動 Odoo(安裝與環境),看過 odoo-bin 指令-i-uscaffold

要做什麼

模型 用途 重點欄位
library.book(書籍) 一本書的基本資料 書名、ISBN、作者、是否可借
library.loan(借閱單) 誰在什麼時候借了哪一本 書、借閱人、借出日、應還日、狀態

借閱人直接用 Odoo 內建的聯絡人(res.partner),不另外建「讀者」資料表。能重用標準模型就重用,這是 Odoo 開發的基本原則。

流程:草稿 → 借出中 → 已歸還。同一本書在借出中時,不能再借給別人。

步驟 1:用 scaffold 建骨架

原始碼安裝:

./odoo-bin scaffold library ~/my_addons

Docker(本站 Compose,檔案會出現在專案的 ./addons/library/):

docker compose run --rm odoo odoo scaffold library /mnt/extra-addons

scaffold 產生的內容大多是註解掉的範例。我們用不到 controllers/views/templates.xml,所以刪掉它們,並把 models/models.pyviews/views.xmldemo/demo.xml 換成下面自己命名的檔案。最後的結構:

library/
├─ __init__.py
├─ __manifest__.py
├─ models/
│  ├─ __init__.py
│  ├─ library_book.py
│  └─ library_loan.py
├─ security/
│  ├─ library_security.xml
│  └─ ir.model.access.csv
├─ data/
│  └─ ir_sequence_data.xml
├─ views/
│  ├─ library_book_views.xml
│  ├─ library_loan_views.xml
│  └─ library_menus.xml
└─ demo/
   └─ library_demo.xml

檔名慣例是「模型名_views.xml」,一個模型一組檔案,日後才好找。

步驟 2:清單檔與入口

__init__.py
from . import models
models/__init__.py
from . import library_book
from . import library_loan
__manifest__.py
{
    "name": "Library 圖書借閱",
    "summary": "登記書籍,記錄誰借了哪一本、何時要還",
    "version": "18.0.1.0.0",
    "category": "Services",
    "author": "唯客學院",
    "license": "LGPL-3",
    "depends": ["base"],
    "data": [
        "security/library_security.xml",
        "security/ir.model.access.csv",
        "data/ir_sequence_data.xml",
        "views/library_book_views.xml",
        "views/library_loan_views.xml",
        "views/library_menus.xml",
    ],
    "demo": [
        "demo/library_demo.xml",
    ],
    "application": True,
    "installable": True,
}

新出現的欄位:

欄位 說明
license 授權。沒寫時 Odoo 會警告並預設 LGPL-3
application True 表示它是一個獨立「應用程式」,會出現在應用程式清單與主選單
demo 只在資料庫啟用示範資料時才載入,用來做展示與測試

data 的順序就是載入順序。權限(群組、CSV)要在檢視與選單之前,因為檢視與選單會引用群組;有依賴關係的檔案,被引用的放前面。

步驟 3:模型

書籍

models/library_book.py
from odoo import api, fields, models


class LibraryBook(models.Model):
    _name = "library.book"
    _description = "Library Book"
    _order = "name"

    name = fields.Char(string="書名", required=True)
    isbn = fields.Char(string="ISBN", copy=False)
    author = fields.Char(string="作者")
    active = fields.Boolean(default=True)
    loan_ids = fields.One2many("library.loan", "book_id", string="借閱紀錄")
    is_available = fields.Boolean(
        string="可借",
        compute="_compute_is_available",
        store=True,
    )

    _sql_constraints = [
        ("isbn_uniq", "unique(isbn)", "ISBN 不可重複。"),
    ]

    @api.depends("loan_ids.state")
    def _compute_is_available(self):
        for book in self:
            book.is_available = not book.loan_ids.filtered(
                lambda loan: loan.state == "borrowed"
            )

重點:

  • _description:模型的人類可讀名稱,沒寫會有警告。
  • _order:預設排序。
  • active:Odoo 內建慣例。有這個欄位,記錄就能「封存」(隱藏但不刪除),表單會自動出現封存按鈕,搜尋預設也會排除封存記錄。
  • copy=False:複製記錄時不複製這個欄位。ISBN 複製後會重複,所以不應複製。
  • _sql_constraints:資料庫層級的限制,最可靠。unique(isbn) 允許多筆空值,只擋重複。
  • One2many:「一本書有多筆借閱紀錄」。它不是資料庫欄位,只是 library.loan.book_id 的反向檢視,第二個參數要寫對方的 Many2one 欄位名。
  • compute + store=True:計算欄位。store=True 會存進資料庫,才能在搜尋、篩選、分組使用。@api.depends("loan_ids.state") 告訴 Odoo「借閱單狀態一變,就重算這本書的可借狀態」。

compute 一定要寫 @api.depends

沒寫 depends 的計算欄位,第一次讀取後結果會被快取,之後來源資料變了也不會重算。繼承與擴充 會示範這個踩雷過程。

Odoo 19 的 SQL 限制寫法

Odoo 19 起 _sql_constraints 改為 models.Constraint。本站以 18.0 為準,升級時再改。

借閱單

models/library_loan.py
from datetime import timedelta

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class LibraryLoan(models.Model):
    _name = "library.loan"
    _description = "Library Loan"
    _order = "date_borrowed desc, id desc"

    name = fields.Char(string="單號", default="New", readonly=True, copy=False)
    book_id = fields.Many2one("library.book", string="書籍", required=True)
    borrower_id = fields.Many2one("res.partner", string="借閱人", required=True)
    date_borrowed = fields.Date(string="借出日", default=fields.Date.context_today)
    loan_days = fields.Integer(string="借期(天)", default=14)
    date_due = fields.Date(string="應還日", compute="_compute_date_due", store=True)
    date_returned = fields.Date(string="歸還日", readonly=True, copy=False)
    state = fields.Selection(
        [("draft", "草稿"), ("borrowed", "借出中"), ("returned", "已歸還")],
        string="狀態",
        default="draft",
        required=True,
        copy=False,
    )
    is_overdue = fields.Boolean(string="逾期", compute="_compute_is_overdue")

    @api.depends("date_borrowed", "loan_days")
    def _compute_date_due(self):
        for loan in self:
            if loan.date_borrowed:
                loan.date_due = loan.date_borrowed + timedelta(days=loan.loan_days)
            else:
                loan.date_due = False

    @api.depends("state", "date_due")
    def _compute_is_overdue(self):
        today = fields.Date.context_today(self)
        for loan in self:
            loan.is_overdue = (
                loan.state == "borrowed" and bool(loan.date_due) and loan.date_due < today
            )

    @api.constrains("loan_days")
    def _check_loan_days(self):
        for loan in self:
            if loan.loan_days <= 0:
                raise ValidationError(_("借期必須大於 0 天。"))

    @api.constrains("book_id", "state")
    def _check_book_not_lent_twice(self):
        for loan in self.filtered(lambda l: l.state == "borrowed"):
            other = self.search_count([
                ("book_id", "=", loan.book_id.id),
                ("state", "=", "borrowed"),
                ("id", "!=", loan.id),
            ])
            if other:
                raise ValidationError(
                    _("《%s》已經借出,還沒歸還。", loan.book_id.name)
                )

    @api.model_create_multi
    def create(self, vals_list):
        for vals in vals_list:
            if vals.get("name", "New") == "New":
                vals["name"] = self.env["ir.sequence"].next_by_code("library.loan") or "New"
        return super().create(vals_list)

    def action_borrow(self):
        self.write({"state": "borrowed"})

    def action_return(self):
        self.write({
            "state": "returned",
            "date_returned": fields.Date.context_today(self),
        })

逐項看:

寫法 說明
Many2one("res.partner") 多對一:這張單指向一位聯絡人,資料庫存的是對方的 id
default=fields.Date.context_today 預設值是「使用者時區的今天」。傳函式本身,不要寫 context_today(),否則預設值會被固定在啟動當下
Selection([(值, 標籤)]) 下拉選項。存進資料庫的是左邊的值(英文),右邊是畫面上的標籤
is_overdue(非儲存的計算欄位) 依「今天」變動的值不適合存進資料庫,所以不用 store=True
@api.constrains Python 驗證。寫入後觸發,raise ValidationError 就會回滾這次寫入
_("...%s", 參數) 可翻譯字串。用逗號傳參數,不要自己用 % 拼,翻譯才抓得到
@api.model_create_multi 覆寫 create,簽名固定是 vals_list(多筆)。覆寫後一定要呼叫 super().create(...)
action_* 方法 給按鈕呼叫。self 可能是多筆記錄,所以用 write 一次寫入

「同一本書不能同時借給兩人」用 @api.constrains("book_id", "state") 實作:只要有借閱單被設為借出中,就檢查是否已有別張借出中的單。把商業規則放在模型,而不是放在畫面,這樣不管從網頁、匯入或外部程式寫入,規則都成立。

為什麼借出日是 Date,而不是 Datetime?

借書只需要到「哪一天」。Date 沒有時區問題,Datetime 在資料庫存 UTC,顯示時才轉換,比較容易出錯。需要精確到時間再用 Datetime

步驟 4:流水號

create 已經呼叫了 next_by_code("library.loan"),現在定義這個序號:

data/ir_sequence_data.xml
<odoo noupdate="1">
  <record id="seq_library_loan" model="ir.sequence">
    <field name="name">Library Loan</field>
    <field name="code">library.loan</field>
    <field name="prefix">LN/%(year)s/</field>
    <field name="padding">4</field>
  </record>
</odoo>

單號會長得像 LN/2026/0001

noupdate="1" 很重要:它表示「安裝後這些記錄就屬於資料庫,升級模組時不要再覆蓋」。序號的目前數字存在這筆記錄裡,若沒有 noupdate,每次 -u 都可能把流水號重置。

步驟 5:群組與權限

分兩層:群組決定「你是誰」,存取權(access)決定「這群人能對哪個模型做什麼」。

security/library_security.xml
<odoo>
  <record id="module_category_library" model="ir.module.category">
    <field name="name">Library</field>
    <field name="sequence">50</field>
  </record>

  <record id="group_library_user" model="res.groups">
    <field name="name">User</field>
    <field name="category_id" ref="module_category_library"/>
    <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
  </record>

  <record id="group_library_manager" model="res.groups">
    <field name="name">Manager</field>
    <field name="category_id" ref="module_category_library"/>
    <field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
    <field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
  </record>
</odoo>
security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_library_book_user,library.book user,model_library_book,group_library_user,1,1,1,0
access_library_book_manager,library.book manager,model_library_book,group_library_manager,1,1,1,1
access_library_loan_user,library.loan user,model_library_loan,group_library_user,1,1,1,0
access_library_loan_manager,library.loan manager,model_library_loan,group_library_manager,1,1,1,1
概念 說明
ir.module.category 在使用者設定頁把同一個模組的群組歸成一區
implied_ids 「包含」關係。管理員(Manager)自動包含使用者(User),使用者又包含內部使用者 base.group_user
(4, id) 「加入既有記錄」的指令,用在 Many2many 欄位
users 直接把 admin 放進管理員群組,安裝後馬上能用
CSV 每一列 群組、模型、讀/寫/建立/刪除四個權限(1 或 0)。模型的 xml id 是 model_ 加上 _name 的點換成底線

我們的設計:使用者可以讀、寫、建立,但不能刪除;只有管理員能刪除。

沒有存取權,就沒有人能用

模型沒有任何 CSV 列時,除了系統內部的超級使用者,所有人都無法存取它,日誌也會警告:The models ['library.book', 'library.loan'] have no access rules in module library, consider adding some。看到這行,就是你忘了寫 CSV,或忘了把 CSV 放進 manifest 的 data

還有更細的「記錄規則」(ir.rule),可以限定「只能看自己的記錄」或「只能看自己公司的記錄」,本頁不展開,見 官方文件:安全性

步驟 6:檢視

書籍

views/library_book_views.xml
<odoo>
  <record id="view_library_book_list" model="ir.ui.view">
    <field name="name">library.book.list</field>
    <field name="model">library.book</field>
    <field name="arch" type="xml">
      <list>
        <field name="name"/>
        <field name="author"/>
        <field name="isbn"/>
        <field name="is_available" widget="boolean"/>
      </list>
    </field>
  </record>

  <record id="view_library_book_form" model="ir.ui.view">
    <field name="name">library.book.form</field>
    <field name="model">library.book</field>
    <field name="arch" type="xml">
      <form>
        <sheet>
          <div class="oe_title">
            <label for="name"/>
            <h1><field name="name" placeholder="書名"/></h1>
          </div>
          <group>
            <group>
              <field name="author"/>
              <field name="isbn"/>
            </group>
            <group>
              <field name="is_available"/>
            </group>
          </group>
          <notebook>
            <page string="借閱紀錄" name="loans">
              <field name="loan_ids" readonly="1">
                <list>
                  <field name="name"/>
                  <field name="borrower_id"/>
                  <field name="date_borrowed"/>
                  <field name="state"/>
                </list>
              </field>
            </page>
          </notebook>
        </sheet>
      </form>
    </field>
  </record>

  <record id="view_library_book_search" model="ir.ui.view">
    <field name="name">library.book.search</field>
    <field name="model">library.book</field>
    <field name="arch" type="xml">
      <search>
        <field name="name" string="書名/作者/ISBN"
               filter_domain="['|', '|', ('name', 'ilike', self), ('author', 'ilike', self), ('isbn', 'ilike', self)]"/>
        <filter name="available" string="可借" domain="[('is_available', '=', True)]"/>
        <filter name="lent" string="已借出" domain="[('is_available', '=', False)]"/>
        <separator/>
        <filter name="archived" string="已封存" domain="[('active', '=', False)]"/>
      </search>
    </field>
  </record>

  <record id="action_library_book" model="ir.actions.act_window">
    <field name="name">書籍</field>
    <field name="res_model">library.book</field>
    <field name="view_mode">list,form</field>
    <field name="help" type="html">
      <p class="o_view_nocontent_smiling_face">登記第一本書</p>
    </field>
  </record>
</odoo>

一個模型通常會寫四種記錄:列表表單搜尋動作(action,決定選單點下去開哪個模型與哪些檢視)。

  • 搜尋檢視的 filter_domain 讓搜尋框輸入一次就同時比對書名、作者與 ISBN,'|' 是「或」,寫在條件前面(波蘭表示法),三個條件要兩個 '|'
  • 表單裡的 <group> 會自動排成兩欄,欄位左側顯示標籤。

借閱單

views/library_loan_views.xml
<odoo>
  <record id="view_library_loan_list" model="ir.ui.view">
    <field name="name">library.loan.list</field>
    <field name="model">library.loan</field>
    <field name="arch" type="xml">
      <list decoration-danger="is_overdue" decoration-muted="state == 'returned'">
        <field name="name"/>
        <field name="book_id"/>
        <field name="borrower_id"/>
        <field name="date_borrowed"/>
        <field name="date_due"/>
        <field name="state" widget="badge"
               decoration-info="state == 'draft'"
               decoration-warning="state == 'borrowed'"
               decoration-success="state == 'returned'"/>
        <field name="is_overdue" column_invisible="1"/>
      </list>
    </field>
  </record>

  <record id="view_library_loan_form" model="ir.ui.view">
    <field name="name">library.loan.form</field>
    <field name="model">library.loan</field>
    <field name="arch" type="xml">
      <form>
        <header>
          <button name="action_borrow" string="借出" type="object"
                  class="btn-primary" invisible="state != 'draft'"/>
          <button name="action_return" string="歸還" type="object"
                  class="btn-primary" invisible="state != 'borrowed'"/>
          <field name="state" widget="statusbar"
                 statusbar_visible="draft,borrowed,returned"/>
        </header>
        <sheet>
          <div class="oe_title">
            <h1><field name="name"/></h1>
          </div>
          <group>
            <group>
              <field name="book_id" readonly="state != 'draft'"/>
              <field name="borrower_id" readonly="state != 'draft'"/>
            </group>
            <group>
              <field name="date_borrowed" readonly="state != 'draft'"/>
              <field name="loan_days" readonly="state != 'draft'"/>
              <field name="date_due"/>
              <field name="date_returned" invisible="state != 'returned'"/>
            </group>
          </group>
        </sheet>
      </form>
    </field>
  </record>

  <record id="view_library_loan_search" model="ir.ui.view">
    <field name="name">library.loan.search</field>
    <field name="model">library.loan</field>
    <field name="arch" type="xml">
      <search>
        <field name="name"/>
        <field name="book_id"/>
        <field name="borrower_id"/>
        <filter name="borrowed" string="借出中" domain="[('state', '=', 'borrowed')]"/>
        <filter name="overdue" string="逾期"
                domain="[('state', '=', 'borrowed'), ('date_due', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
        <group expand="0" string="分組">
          <filter name="group_borrower" string="借閱人" context="{'group_by': 'borrower_id'}"/>
          <filter name="group_book" string="書籍" context="{'group_by': 'book_id'}"/>
          <filter name="group_state" string="狀態" context="{'group_by': 'state'}"/>
        </group>
      </search>
    </field>
  </record>

  <record id="action_library_loan" model="ir.actions.act_window">
    <field name="name">借閱紀錄</field>
    <field name="res_model">library.loan</field>
    <field name="view_mode">list,form</field>
    <field name="context">{'search_default_borrowed': 1}</field>
  </record>
</odoo>

這頁有幾個 Odoo 17 之後才有的寫法,網路上舊教學常寫成過時的形式:

想做的事 Odoo 18 寫法 舊寫法(新版不再使用)
依狀態隱藏按鈕或欄位 invisible="state != 'draft'" attrs="{'invisible': [('state', '!=', 'draft')]}"
依狀態設唯讀 readonly="state != 'draft'" attrs="{'readonly': ...}"states="draft"
列表中隱藏欄位但保留資料 column_invisible="1" invisible="1"
列表標籤 <list> <tree>

其他細節:

  • decoration-*:依條件替列表的列或欄位上色。用到的欄位(is_overdue)必須出現在檢視裡,所以我們加了一個隱藏欄位。
  • statusbar:表單上方的狀態列。header 裡的 <button type="object"> 會呼叫模型的同名方法。
  • XML 特殊字元< 在 XML 要寫成 &lt;,否則檢視會解析失敗。
  • context_today():搜尋篩選的 domain 內可以用,代表使用者今天。
  • search_default_borrowed:在動作的 context 加 search_default_篩選名: 1,開啟時就預先套用該篩選。

步驟 7:選單

views/library_menus.xml
<odoo>
  <menuitem id="menu_library_root" name="圖書借閱"
            groups="group_library_user" sequence="50"/>
  <menuitem id="menu_library_book" name="書籍"
            parent="menu_library_root" action="action_library_book" sequence="10"/>
  <menuitem id="menu_library_loan" name="借閱紀錄"
            parent="menu_library_root" action="action_library_loan" sequence="20"/>
</odoo>

沒有 parent 的是頂層選單(畫面左上角的應用程式圖示);有 parent 的是它底下的項目。groups 讓不在該群組的人看不到這個選單。

應用程式圖示

想要專屬圖示,放一張 static/description/icon.png 到模組目錄,Odoo 會自動使用。沒有時會用預設圖示。

步驟 8:示範資料

demo/library_demo.xml
<odoo>
  <record id="partner_demo_reader" model="res.partner">
    <field name="name">王小明(示範讀者)</field>
  </record>

  <record id="book_demo_odoo" model="library.book">
    <field name="name">Odoo 開發入門</field>
    <field name="author">唯客學院</field>
    <field name="isbn">978-0-00-000001-0</field>
  </record>
  <record id="book_demo_python" model="library.book">
    <field name="name">Python 學習手冊</field>
    <field name="author">Mark Lutz</field>
    <field name="isbn">978-0-00-000002-7</field>
  </record>

  <record id="loan_demo_1" model="library.loan">
    <field name="book_id" ref="book_demo_odoo"/>
    <field name="borrower_id" ref="partner_demo_reader"/>
  </record>
  <function model="library.loan" name="action_borrow" eval="[ref('loan_demo_1')]"/>
</odoo>
  • ref="..." 用 xml id 指向另一筆記錄。
  • <function> 在載入時呼叫模型方法,這裡把示範借閱單直接「借出」,讓畫面一打開就有資料。
  • 示範資料裡的聯絡人自己建立,不依賴 base 的示範資料,這樣用 --without-demo=all 的資料庫也不會壞。

步驟 9:安裝與試用

把模組所在資料夾放進 --addons-path,然後安裝:

# 原始碼安裝
./odoo-bin -d mydb --addons-path=addons,odoo/addons,~/my_addons -i library --stop-after-init

# Docker
docker compose run --rm odoo odoo -d mydb -i library --stop-after-init

日誌逐一出現 loading library/security/...loading library/views/...,沒有 ERRORWARNING 就是成功。接著啟動伺服器,用 admin 登入:

  1. 主選單多了「圖書借閱」。
  2. 開「書籍」,看示範資料,新增一本書。
  3. 開「借閱紀錄」,新增一張,選書與借閱人,按「借出」,狀態列跳到「借出中」,應還日自動算出。
  4. 再建一張借同一本書的單,按「借出」,會看到 《…》已經借出,還沒歸還。
  5. 按「歸還」,書恢復「可借」。
  6. 到 設定 → 使用者,把使用者加入 Library 的「User」群組,再用該帳號登入,確認看得到選單,但刪不掉記錄。

改程式後的節奏

改 Python 方法:重啟。改欄位或 XML:-u library。開發時用 --dev=all 可省下大部分重啟與升級,見 odoo-bin 指令

出錯時,測試與除錯 整理了真實的錯誤訊息與處理方式。

練習

  • library.book 加一個 Selection 欄位「類別」(小說、工具書、雜誌),並加入列表、表單與搜尋分組
  • 把借期預設天數從 14 改成 7,並確認應還日跟著變
  • 在借閱單加一個 Text 欄位「備註」
  • 新增一條記錄規則(ir.rule):一般使用者只能看到自己建立的借閱單,管理員看得到全部

下一步

進一步學習