業務システム

Odooカスタマイズの実装手順:継承モジュールの書き方とOdoo 20への追従

Odooカスタマイズの実装手順:継承モジュールの書き方とOdoo 20への追従

Odooのカスタマイズは、標準のソースを書き換えず、既存のモデルや画面を継承する自作モジュールを足して行います。この記事では、受注に「金額しきい値を超えたら承認が要る」ルールを足す例で、_inheritによるモデル継承、受注確定のフック上書き、XPathによるビュー継承、テストまでをコード付きで説明する記事です。後半では、Odoo 20.0でアクセス権の定義ファイルがir.access.csvへ変わった点と、版を上げるときの移行スクリプトの書き方を扱います。数値と仕様は2026年10月27日時点の公式ドキュメントとGitHubのソースで確かめたものです。

まとめ:Odooカスタマイズは継承で差分だけを足し、版移行の手順まで先に組む

Pythonでカスタマイズするなら、提供形態はOdoo.shかオンプレミスに限られます。Odoo OnlineにはPythonを含む自作モジュールを入れられません。モジュールでは、既存モデルは_inheritだけで拡張し、標準メソッドは中身を丸ごと書き換えず、super()を呼ぶ小さなフックを上書きします。画面はXPathで差分だけを足します。

Odoo 20.0では、19.0までのir.model.access.csvとir.ruleがir.accessに統合されました。19.0以前に書いたモジュールは、odoo-bin upgrade_codeで変換してから差分を目で確かめます。項目名を変えるときはmigrationsフォルダの移行スクリプトでデータを引き継ぎます。設定とStudioで済む要件にはコードを書かない。これが版移行の費用を抑える一番の手です。

Odooカスタマイズの手段の切り分けとPythonで書くべき範囲

手段は設定、Studio、カスタムモジュールの3段階です。料金プランと提供形態の比較はOdooとは?オープンソースERPの機能・料金・Community版との違いとOdoo 20の要点で整理しているので、ここではコードを書くかどうかの線引きだけを扱います。

Pythonのカスタムモジュールが要る業務要件と提供形態の制約

コードが要るのは、標準の処理の途中に業務ルールを差し込みたいときです。受注確定の前に独自の検査を入れる、他システムと受け渡す項目を足す、といった要件がこれに当たります。項目や画面の配置を変えるだけならStudioで足ります。

公式のUpgrade a customized databaseは、Odoo OnlineではPythonを含むカスタムモジュールのインストールを認めていないと明記しています。コードを書く前提なら、Odoo.shかオンプレミスを最初から選ぶ必要があります。オンプレミスの20.0は、Source installのとおりPython 3.12以上とPostgreSQL 16以上が前提です。

コードを書く前に標準機能で足りるかを確かめる購買の二段階承認の例

承認ルールは標準に入っていることがあります。購買には二段階承認の設定があり、20.0のソースでは会社ごとの金額しきい値po_double_validation_amountの既定値が5000です。購買の承認ならモジュールを書く理由はありません。

公式の移行手順も、版を上げる前に自作機能を標準と突き合わせ、重なるものは消すよう求めています。カスタマイズの量は、そのまま版移行の作業量になります。この記事の承認ルールは受注側の例として書いていますが、実案件では先に標準設定とApps Storeを確かめてください。

カスタムモジュールの構成と__manifest__.pyに書く依存関係の決め方

モジュールは__manifest__.pyを持つPythonパッケージです。--addons-pathに置いたフォルダから読み込まれます。

受注承認モジュールのディレクトリ構成とマニフェストの依存関係・版指定

受注承認モジュールの構成と、__manifest__.pyのdepends・data・versionの書き方は次のとおりです。dependsには拡張する標準モジュールを書きます。saleを指定すれば、依存先のsales_teamも先に読み込まれます。

issoh_sale_approval/
├── __init__.py
├── __manifest__.py
├── models/
│   ├── __init__.py
│   ├── res_company.py
│   └── sale_order.py
├── views/
│   └── sale_order_views.xml
└── tests/
    ├── __init__.py
    └── test_sale_approval.py

# __manifest__.py
{
    "name": "Sale Approval Threshold",
    "version": "20.0.1.0.0",
    "depends": ["sale"],
    "author": "Your Company",
    "license": "LGPL-3",
    "data": [
        "views/sale_order_views.xml",
    ],
}

Module Manifestsによると、dataのファイルはインストール時と更新時に必ず読まれ、licenseの既定値はLGPL-3です。dataは書いた順に読み込まれるため、参照される側のファイルを先に並べます。versionは移行スクリプトのフォルダ名と対応するので、20.0.1.0.0のように本体の版を含めた形で書いておくと後で迷いません。

_inheritだけの拡張と_name併用の新モデル、_inherits委譲の違い

ORM APIの継承と拡張には3つの方式があります。実務で使うのはほぼ1つ目です。

書き方 結果 使う場面
_inheritだけ 既存モデルをその場で拡張 項目追加・処理の上書き
_nameと_inherit 既存を土台に別モデルを作る 似た台帳を別テーブルで持つ
_inherits 親レコードへ項目を委譲 原則使わない

_inheritsについて、公式は「おおむね実装されている程度なので、できれば避ける」と書き、連鎖させた場合の動作は保証しないとしています。委譲ではメソッドは継承されず項目だけが見える点も、想定外の不具合の元になります。

受注に承認ステップを足すモデル継承とフック上書きの実装コード

ここからは、受注金額が会社ごとのしきい値以上なら、営業マネージャーの承認が無いと確定できないようにします。

会社ごとのしきい値を持つ計算項目をstore付きで定義する手順

res.companyにしきい値を、sale.orderに「承認が要るか」と「承認者」を足します。20.0の公式チュートリアルにならい、_inheritはリストで書いています。

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


class ResCompany(models.Model):
    _inherit = ["res.company"]

    sale_approval_threshold = fields.Monetary(
        string="受注承認のしきい値",
        currency_field="currency_id",
        default=1000000,
    )


# models/sale_order.py
from odoo import api, fields, models
from odoo.exceptions import AccessError, UserError


class SaleOrder(models.Model):
    _inherit = ["sale.order"]

    approval_required = fields.Boolean(
        compute="_compute_approval_required", store=True,
    )
    approved_by_id = fields.Many2one(
        "res.users", string="承認者", copy=False, readonly=True,
    )

    @api.depends("amount_total", "company_id.sale_approval_threshold")
    def _compute_approval_required(self):
        for order in self:
            threshold = order.company_id.sale_approval_threshold
            order.approval_required = bool(threshold) and order.amount_total >= threshold

    def action_approve(self):
        if not self.env.user.has_group("sales_team.group_sale_manager"):
            raise AccessError(self.env._("受注の承認は営業マネージャーだけが行えます。"))
        self.write({"approved_by_id": self.env.user.id})

    def _confirmation_error_message(self):
        error = super()._confirmation_error_message()
        if error:
            return error
        if self.approval_required and not self.approved_by_id:
            return self.env._("しきい値以上の受注です。承認を受けてから確定してください。")
        return False

    @api.ondelete(at_uninstall=False)
    def _unlink_except_approved(self):
        if any(order.approved_by_id for order in self):
            raise UserError(self.env._("承認済みの見積は削除できません。"))

store=Trueにすると一覧で絞り込みや並べ替えができます。@api.dependsにcompany_id.sale_approval_thresholdまで書くのは、しきい値を変えたときに既存の受注も再計算させるためです。amount_totalは受注の通貨建てなので、外貨の受注がある会社では会社通貨へ換算した値で比べる処理に直してください。

受注確定処理全体ではなく検査フックだけを上書きする理由と影響範囲

20.0のsale_order.pyを読むと、action_confirmは受注ごとに_confirmation_error_message()を呼び、文字列が返ればUserErrorで止める作りです。検査を足すだけなら、このフックを上書きするのが一番小さな変更になります。

action_confirmそのものを上書きすると、ロックやメール送信を含む確定処理全体の前後に自分のコードが挟まります。版が上がって標準の処理順が変わったとき、影響を受ける範囲が広い。公式チュートリアルも、上書きでは必ずsuper()を呼び、親と同じ型の値を返すよう求めています。

削除の禁止をunlink上書きでなく@api.ondeleteで書く理由と注意点

削除を止めたいとき、unlinkを上書きして例外を出す書き方は避けます。ORM APIは、unlinkの上書きはモジュールのアンインストール時にも例外を出し、データが残ってDBの整合が崩れると説明しています。

@api.ondelete(at_uninstall=False)なら、アンインストール中は検査が走りません。標準のsale.orderも同じ方式で、送信済みや確定済みの受注の削除を_unlink_except_draft_or_cancelで止めています。メソッド名は_unlink_except_か_unlink_if_で始めるのが慣例です。

ビュー継承でフォームに承認ボタンと項目を足すXPathの書き方

画面は標準のフォームを書き換えず、差分だけを持つ継承ビューで足します。after・inside・attributesで挿入位置や属性変更を指定します。

ビュー継承のinherit_idとpositionによる挿入位置・属性変更の指定

受注フォームsale.view_order_formのヘッダーに承認ボタンを、支払条件の下に承認者を足す継承ビューです。

<odoo>
    <record id="view_order_form_approval" model="ir.ui.view">
        <field name="name">sale.order.form.approval</field>
        <field name="model">sale.order</field>
        <field name="inherit_id" ref="sale.view_order_form"/>
        <field name="arch" type="xml">
            <xpath expr="//header" position="inside">
                <button name="action_approve" type="object" string="承認"
                        groups="sales_team.group_sale_manager"
                        invisible="not approval_required or approved_by_id or state not in ('draft', 'sent')"/>
            </xpath>
            <field name="payment_term_id" position="after">
                <field name="approval_required" invisible="1"/>
                <field name="approved_by_id" invisible="not approval_required"/>
            </field>
        </field>
    </record>
</odoo>

View recordsの継承では、positionの既定はinsideです。ほかにafter、before、replace、属性だけを変えるattributes、ノードを移すmoveがあります。20.0の仕様では、表示条件はinvisibleに直接記述する式です。旧来のattrsは、公式の移行手順でも構文変更の例に挙がっています。

壊れにくさで選ぶなら、nameを持つ項目はfield要素で、名前の無い要素はname属性を持つ親から辿ります。div[3]のような番号指定や長い絶対パスは、標準の画面が少し変わるだけで一致しなくなる指定です。公式チュートリアルは、XPathが要素に一致しないとエラーになると書いています。公式の移行手順でも「移動または削除されたXPath」が典型的な失敗として挙がっています。

Odoo 20で変わったアクセス権定義をir.accessへ書き換える手順

20.0での一番大きな変更はアクセス権です。19.0まで別々だったモデル単位の権限ir.model.accessとレコードルールir.ruleが、ir.accessという1つのモデルになりました。

19.0と20.0のアクセス権CSVで変わる列名・権限・条件の対応表

公式チュートリアル第4章の19.0版と20.0版を比べると、ir.model.access.csvからir.access.csvへの変更により、CSVの列は8列から6列に減っています。

19.0の列 20.0の列 変わった点
model_id:id model_id モデル名をそのまま書く
group_id:id group_id/id 外部IDで指定
perm_read など4列 operation crudの部分文字列
ir.ruleのXML domain 同じ行に条件を書く

新しいモデルissoh.sale.approval.logを足す場合、19.0と20.0の書き方は次のように変わります。

# 19.0  security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_approval_log_user,approval log user,model_issoh_sale_approval_log,sales_team.group_sale_salesman,1,0,0,0
access_approval_log_manager,approval log manager,model_issoh_sale_approval_log,sales_team.group_sale_manager,1,1,1,1

# 20.0  security/ir.access.csv
id,name,model_id,group_id/id,operation,domain
access_approval_log_user,approval log user,issoh.sale.approval.log,sales_team.group_sale_salesman,r,
access_approval_log_manager,approval log manager,issoh.sale.approval.log,sales_team.group_sale_manager,crud,

標準モジュールもすでに移っています。saleのsecurityフォルダは、19.0ではir.model.access.csvとir_rules.xmlの2本でした。20.0のir.access.csvでは1本になり、営業担当が自分の担当する請求書の送信だけを扱える条件もdomain列に入っています。Security in Odooによると、group_idが空の行は全員に掛かる制限として働き、複数あればその積集合が制限の範囲です。空欄の行を気軽に足すと、誰も読めないモデルができます。

odoo-bin upgrade_codeでソースを一括変換し差分を確かめる手順

手で書き換える前に、Odoo本体のコード変換コマンドを使えます。CLIのupgrade_codeは、odoo/upgrade_codeにあるスクリプトでソースを書き換える仕組みです。20.0ブランチには、2026年10月27日時点で19.1から20.0向けのスクリプトが9本あり、アクセス権の変換は19.4-00-ir-access.pyが担います。

# 1. 自作モジュールをGitで管理した状態で、変換内容だけを表示する
./odoo-bin upgrade_code --addons-path=../custom-addons --from 19.0 --to 20.0 --dry-run

# 2. 問題がなければ書き換えを実行し、差分を確かめる
./odoo-bin upgrade_code --addons-path=../custom-addons --from 19.0 --to 20.0
git diff --stat

# 3. 20.0の空のDBに入れてテストを流す
./odoo-bin -c odoo.conf -d approval_test -i issoh_sale_approval --test-tags /issoh_sale_approval --stop-after-init

公式は、変換はあくまでベストエフォートで万能ではないと断っています。アクセス権の変換スクリプトも冒頭で「冪等ではない」と宣言しているので、同じソースに2回掛けないでください。git diffで、権限の範囲が広がっていないかを行単位で確かめるのが前提です。

テストとアップグレードスクリプトでバージョン追従を仕組みにする方法

版移行のたびに手で全画面を確かめていては続きません。業務ルールはテストで、データの引き継ぎは移行スクリプトで固定します。

TransactionCaseで承認ルールを検証し–test-tagsで流す書き方

Testing OdooのTransactionCaseは、テストの変更を巻き戻すので、テスト同士でデータが干渉しません。

# tests/test_sale_approval.py
from odoo import Command
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestSaleApproval(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.env.company.sale_approval_threshold = 100000
        cls.partner = cls.env["res.partner"].create({"name": "テスト取引先"})
        cls.product = cls.env["product.product"].create({"name": "保守契約"})

    def _order(self, price):
        return self.env["sale.order"].create({
            "partner_id": self.partner.id,
            "order_line": [Command.create({
                "product_id": self.product.id,
                "product_uom_qty": 1,
                "price_unit": price,
            })],
        })

    def test_blocked_without_approval(self):
        order = self._order(200000)
        self.assertTrue(order.approval_required)
        with self.assertRaises(UserError):
            order.action_confirm()

    def test_confirm_after_approval(self):
        order = self._order(200000)
        order.approved_by_id = self.env.user
        order.action_confirm()
        self.assertEqual(order.state, "sale")

post_installを付けると、全モジュールのインストール後に走ります。会計や在庫など他モジュールの影響を受けた状態で確かめられるので、受注のように多くのモジュールが手を入れるモデルではこちらを選ぶのが適切です。前の節の手順3のコマンドで、このモジュールのテストだけを流せます。

migrationsフォルダのpre・post・end実行順と項目名変更の書き方

項目名を変えるときに、旧項目の値を引き継ぐのがアップグレードスクリプトです。置き場所はmigrations(13以降はupgradesも可)の下に、本体の版を含むモジュールの版名のフォルダを切ります。

# migrations/20.0.1.1.0/pre-rename_approver.py
# __manifest__.py の version を 20.0.1.1.0 に上げると、更新時に1回だけ実行される
from odoo.upgrade import util


def migrate(cr, version):
    util.rename_field(cr, "sale.order", "approved_by_id", "approver_id")

実行は、モジュールを読み込む前のpre、読み込んだ後のpost、全モジュール更新後のendの3段階です。同じ段階の中での実行順はファイル名の辞書順です。Upgrade utilsのrename_fieldは、フィルタやサーバーアクション、ドメインに残った旧名の参照まで書き換えます。ライブラリはodoo/upgrade-utilを--upgrade-pathに足すか、pipで入れて使います。

公式アップグレードの保証範囲とStudio・自社コードの責任の差

UpgradeのSLAでは、Enterprise版の標準アプリと、Studioで作ったカスタマイズ(Studioを入れたままで契約が有効な間)の移行は保証の対象です。自作モジュールは、カスタマイズ保守の契約が無ければ対象外になります。新しい版で自作モジュールが壊れた場合、直すのはモジュールの保守担当者だと明記されています。

公式の移行手順は6段階です。開発を止めて自作機能を見直す、移行済みのテストDBを取り寄せる、空のDBで自作モジュールを入れられる状態にする、移行済みDBで動かす、リハーサルする、本番を上げる、の順になります。自作モジュールを持つ会社にとって、版移行はこの6段階を毎回回す作業です。

Odooをコードでカスタマイズするか見送るか、採用条件と外注の判断

コードを書けば標準に無い処理を足せます。そのぶん、標準サポートの期限が来るたびに版を上げる作業と費用を背負い続けます。

カスタムモジュールを書いてよい条件と書かずに済ませるべき場面

書いてよいのは、次の条件がそろうときです。

  • 標準設定・Studio・Apps Storeのどれでも満たせないと確かめた
  • Odoo.shかオンプレミスで運用する
  • テストと移行スクリプトを書き、版移行のたびに回す担当者がいる

逆に、画面の項目追加や帳票の見た目だけならStudioで止めます。標準の会計や在庫の計算そのものを差し替える改修も見送るべきです。標準の処理順に深く依存するため、版移行のたびに作り直しに近い作業になります。長く使うほど、業務を標準の流れに合わせるほうが費用を小さく抑えられる形です。基幹業務のどこまでを標準に寄せるかはERPモジュール一覧|個別原価計算を回す基幹システム構成とSAP・国産ERP対応表の分け方が参考になります。

Odooの外注先に版追従の体制を確かめる質問と移行費用の見積りの見方

開発会社に依頼するときは、初期開発の見積りより、版移行1回あたりの作業と費用を先に聞いてください。具体的には、テストコードを納品物に含めるか、XPathを番号指定で書かない規約があるか、20.0のir.access移行を経験しているかの3点です。答えがあいまいなら、2〜3年後の移行で費用が膨らみます。

一創では、要件を標準機能・Studio・カスタムモジュールに振り分けるところからOdoo導入支援サービスで支援しています。他のERPとの比較から検討するなら、ERPとは?基幹システム・CRMとの違いと主な機能・導入の進め方を解説で導入の進め方を整理しています。

よくある質問

Odooのカスタマイズを検討するときによく聞かれる質問をまとめました。

Odoo Onlineでもカスタマイズはできますか?

設定とStudioの範囲ならできます。Pythonを含むカスタムモジュールはOdoo Onlineには入れられないと公式が明記しており、アップグレードスクリプトも動かせません。受注確定の前に独自の検査を入れるなど、処理そのものを変える要件があるならOdoo.shかオンプレミスを選びます。途中で提供形態を移すとDBの移行作業が別に発生するため、要件を洗い出した段階で決めておくのが安全です。

Community版でもカスタムモジュールは作れますか?

作れます。モデル継承、ビュー継承、アクセス権、テスト、移行スクリプトの仕組みはCommunity版にも含まれます。使えないのはEnterprise版だけのStudioや会計レポートなどで、それらを継承するモジュールはCommunity版には入りません。公式の版移行サービス(SLA)もEnterprise版向けなので、Community版では移行作業を自社か開発会社で行う前提になります。

19.0のモジュールはOdoo 20でそのまま動きますか?

多くの場合、そのままでは入りません。20.0ではアクセス権がir.accessに変わり、Pythonは3.12以上、PostgreSQLは16以上が前提です。odoo-bin upgrade_codeでソースを変換し、20.0の空のDBでインストールとテストを通してから、移行済みのDBで動作を確かめる順に進めます。標準の画面が変わってXPathが一致しなくなる箇所も、この段階で見つかります。

Studioとカスタムモジュールは併用できますか?

併用できます。項目の追加や画面の配置はStudio、処理の差し込みはカスタムモジュールと分けるのが扱いやすい形です。ただし同じ画面をStudioとXPathの両方で変えると、どちらの変更が効いているか追いにくくなります。公式の移行手順も、自作モジュールは空のDBで先に動かし、Studioの変更が混ざった本番DBとは切り分けて確かめるよう勧めています。

Odooのカスタマイズを外注すると何を納品してもらうべきですか?

モジュールのソース一式をGitのリポジトリで受け取り、著作権とリポジトリの所有者を契約で決めます。加えて、業務ルールを確かめる自動テストと、項目名の変更があれば移行スクリプトも納品物に含めることが必要です。テストが無いモジュールは、版を上げるたびに全画面を手で確かめることになり、移行の見積りが毎回膨らみます。

関連記事

お気に入りに入れた記事の一覧

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.23 テックブログ Renta!不正アクセスでサービス停止:再開未定の現状と運営側の停止判断・証拠保全・段階的再開
  2. 2026.07.09 テックブログ Muse Imageとは?Metaの画像生成AIの使い方・API料金(1枚0.01ドル)と日本での提供状況【2026年10月】
  3. 2026.10.23 テックブログ Splunk 脆弱性CVE-2026-76299:影響版の確認と10.2.8などへの更新手順
  4. 2026.10.22 テックブログ Tomcatのバージョン一覧とサポート期限【2026年10月】最新版11.0.27、9.0系は2027年3月末終了・9.1系は2030年末まで
  5. 2026.10.24 テックブログ ブラストメール不正アクセス:配信先288件の漏えいと利用企業の報告・通知の手順

RELATED POSTS 関連記事

目次