カスタム Odoo モジュールの構築: 開発者チュートリアル
Odoo のモジュール システムは、ERP の世界で最も強力な拡張フレームワークの 1 つです。 Odoo のすべての機能 (会計から在庫、CRM まで) はモジュールです。これは、カスタム機能の構築が Odoo 自身の開発者が使用しているのとまったく同じパターンに従っており、コアをフォークすることなく完全なフレームワークにアクセスできることを意味します。
このチュートリアルでは、ディレクトリ構造のスキャフォールディングとモデルの定義から、ビューの作成、アクセスの保護、運用環境へのデプロイまで、カスタム Odoo 19 モジュールのライフサイクル全体をカバーします。最終的には、Odoo 19 Enterprise の規則に従って、市場に投入できる実用的なモジュールが完成します。
重要なポイント
- すべての Odoo モジュールは
__manifest__.py記述子を持つ Python パッケージです- モデルは
models.Modelから継承し、PostgreSQL テーブルに直接マップします- ビューは XML で定義され、モデル フィールドを名前で参照します
ir.model.accessCSV およびレコード ルールを通じてセキュリティが適用されます- ウィザード (
TransientModel) は複数ステップのユーザー操作を処理します- 計算フィールドと onchange メソッドは関連フィールドを動的に更新します
- 自動化されたアクションとスケジュールされたジョブは、トリガーでサーバー側のロジックを実行します
- モジュールの依存関係により、正しいロード順序と機能の可用性が保証されます
モジュールの構造と足場
すべての Odoo モジュールは、特定の構造を持つディレクトリです。 Odoo の組み込み scaffold コマンドを使用してボイラープレートを生成します。
# From your Odoo addons directory
python odoo-bin scaffold my_module /path/to/addons
これにより、以下が生成されます。
my_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── my_model.py
├── views/
│ └── my_model_views.xml
├── security/
│ ├── ir.model.access.csv
│ └── my_module_security.xml
├── data/
│ └── my_module_data.xml
├── wizard/
│ └── my_wizard.py
├── report/
│ └── my_report.xml
└── static/
└── src/
└── js/
マニフェスト ファイル (__manifest__.py)
{
'name': 'My Custom Module',
'version': '19.0.1.0.0',
'summary': 'Short description for module list',
'description': """
Extended description of what this module does.
Can be multi-line RST text.
""",
'author': 'ECOSIRE Private Limited',
'website': 'https://ecosire.com',
'category': 'Sales/CRM',
'depends': ['sale', 'account', 'stock'],
'data': [
'security/ir.model.access.csv',
'security/my_module_security.xml',
'data/my_module_data.xml',
'views/my_model_views.xml',
'views/menu_views.xml',
'report/my_report.xml',
'wizard/my_wizard_views.xml',
],
'assets': {
'web.assets_backend': [
'my_module/static/src/js/my_widget.js',
'my_module/static/src/css/my_styles.css',
],
},
'license': 'OPL-1',
'installable': True,
'application': False,
'auto_install': False,
'price': 249.0,
'currency': 'USD',
}
バージョン番号付け規則: {odoo_version}.{major}.{minor}.{patch}。新しいモジュールは常に 19.0.1.0.0 から開始してください。
モデルの定義
モデルは Odoo モジュールの中心です。データ構造とビジネス ロジックを定義します。
# models/service_request.py
from odoo import api, fields, models
from odoo.exceptions import ValidationError, UserError
class ServiceRequest(models.Model):
_name = 'my.service.request'
_description = 'Service Request'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date_request desc, name'
_rec_name = 'name'
name = fields.Char(
string='Reference',
required=True,
copy=False,
readonly=True,
default=lambda self: self.env['ir.sequence'].next_by_code('my.service.request')
)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('in_progress', 'In Progress'),
('done', 'Completed'),
('cancelled', 'Cancelled'),
], string='Status', default='draft', tracking=True)
partner_id = fields.Many2one(
'res.partner', string='Customer',
required=True, tracking=True,
domain=[('customer_rank', '>', 0)]
)
user_id = fields.Many2one(
'res.users', string='Assigned To',
default=lambda self: self.env.user
)
date_request = fields.Datetime(
string='Request Date',
default=fields.Datetime.now,
required=True
)
date_deadline = fields.Date(string='Deadline')
description = fields.Html(string='Description')
priority = fields.Selection([
('0', 'Normal'),
('1', 'Low'),
('2', 'High'),
('3', 'Urgent'),
], string='Priority', default='0')
tag_ids = fields.Many2many(
'my.service.tag', string='Tags'
)
line_ids = fields.One2many(
'my.service.request.line', 'request_id',
string='Service Lines'
)
amount_total = fields.Float(
string='Total Amount',
compute='_compute_amount_total',
store=True
)
company_id = fields.Many2one(
'res.company', string='Company',
required=True,
default=lambda self: self.env.company
)
@api.depends('line_ids.subtotal')
def _compute_amount_total(self):
for request in self:
request.amount_total = sum(request.line_ids.mapped('subtotal'))
@api.constrains('date_deadline', 'date_request')
def _check_deadline(self):
for record in self:
if record.date_deadline and record.date_request:
if record.date_deadline < record.date_request.date():
raise ValidationError("Deadline cannot be before the request date.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.user_id = self.partner_id.user_id or self.env.user
def action_submit(self):
for record in self:
if not record.line_ids:
raise UserError("Cannot submit a request without service lines.")
record.state = 'submitted'
record.message_post(body="Service request submitted for processing.")
def action_start_progress(self):
self.write({'state': 'in_progress'})
def action_mark_done(self):
self.write({'state': 'done'})
def action_cancel(self):
for record in self:
if record.state == 'done':
raise UserError("Cannot cancel a completed request.")
record.state = 'cancelled'
@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(
'my.service.request'
) or 'New'
return super().create(vals_list)
サービス リクエスト ライン モデル:
class ServiceRequestLine(models.Model):
_name = 'my.service.request.line'
_description = 'Service Request Line'
request_id = fields.Many2one(
'my.service.request', string='Request',
required=True, ondelete='cascade'
)
product_id = fields.Many2one(
'product.product', string='Service',
required=True,
domain=[('type', '=', 'service')]
)
description = fields.Text(string='Description')
quantity = fields.Float(string='Quantity', default=1.0)
price_unit = fields.Float(string='Unit Price')
subtotal = fields.Float(
string='Subtotal',
compute='_compute_subtotal',
store=True
)
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
for line in self:
line.subtotal = line.quantity * line.price_unit
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.price_unit = self.product_id.lst_price
self.description = self.product_id.description_sale
ビューの作成
ビューは、UI でのレコードの表示方法を定義します。 Odoo は XML を使用してフォーム、リスト、かんばんボードなどを記述します。
<!-- views/service_request_views.xml -->
<odoo>
<!-- Form View -->
<record id="view_service_request_form" model="ir.ui.view">
<field name="name">my.service.request.form</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<form string="Service Request">
<header>
<button name="action_submit" string="Submit"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_start_progress" string="Start"
type="object" class="oe_highlight"
invisible="state != 'submitted'"/>
<button name="action_mark_done" string="Mark Done"
type="object" class="oe_highlight"
invisible="state != 'in_progress'"/>
<button name="action_cancel" string="Cancel"
type="object"
invisible="state in ['done', 'cancelled']"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,submitted,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id"
options="{'no_create': True}"/>
<field name="user_id"/>
<field name="priority" widget="priority"/>
</group>
<group>
<field name="date_request"/>
<field name="date_deadline"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<field name="tag_ids" widget="many2many_tags"/>
<notebook>
<page string="Service Lines">
<field name="line_ids">
<tree editable="bottom">
<field name="product_id"/>
<field name="description"/>
<field name="quantity"/>
<field name="price_unit"/>
<field name="subtotal" readonly="1"/>
</tree>
</field>
<group class="oe_subtotal_footer">
<field name="amount_total"
widget="monetary"
class="oe_subtotal_footer_separator"/>
</group>
</page>
<page string="Description">
<field name="description" widget="html"
placeholder="Detailed description..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- List View -->
<record id="view_service_request_tree" model="ir.ui.view">
<field name="name">my.service.request.list</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<tree string="Service Requests" decoration-danger="state=='cancelled'"
decoration-success="state=='done'">
<field name="name"/>
<field name="partner_id"/>
<field name="user_id" optional="show"/>
<field name="priority" widget="priority"/>
<field name="date_request"/>
<field name="date_deadline" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="state" widget="badge"
decoration-info="state=='draft'"
decoration-warning="state=='submitted'"
decoration-primary="state=='in_progress'"
decoration-success="state=='done'"
decoration-danger="state=='cancelled'"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="view_service_request_search" model="ir.ui.view">
<field name="name">my.service.request.search</field>
<field name="model">my.service.request</field>
<field name="arch" type="xml">
<search>
<field name="name" string="Reference"/>
<field name="partner_id"/>
<field name="user_id"/>
<filter string="My Requests" name="my_requests"
domain="[('user_id', '=', uid)]"/>
<filter string="In Progress" name="in_progress"
domain="[('state', '=', 'in_progress')]"/>
<filter string="Urgent" name="urgent"
domain="[('priority', '=', '3')]"/>
<separator/>
<filter string="This Month" name="this_month"
domain="[('date_request', '>=',
(context_today() - relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Group By">
<filter string="Customer" name="group_partner"
context="{'group_by': 'partner_id'}"/>
<filter string="Status" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Assigned To" name="group_user"
context="{'group_by': 'user_id'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_service_request" model="ir.actions.act_window">
<field name="name">Service Requests</field>
<field name="res_model">my.service.request</field>
<field name="view_mode">list,form,kanban</field>
<field name="search_view_id" ref="view_service_request_search"/>
<field name="context">{'search_default_in_progress': 1}</field>
</record>
</odoo>
セキュリティ構成
セキュリティはどの実稼働モジュールにも必須です。
アクセス制御リスト (security/ir.model.access.csv):
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_service_request_user,service.request.user,model_my_service_request,base.group_user,1,1,1,0
access_service_request_manager,service.request.manager,model_my_service_request,base.group_system,1,1,1,1
access_service_request_line_user,service.request.line.user,model_my_service_request_line,base.group_user,1,1,1,1
レコード ルール (security/my_module_security.xml):
<odoo>
<!-- Users can only see their own requests unless they're managers -->
<record id="rule_service_request_own" model="ir.rule">
<field name="name">Service Request: Own Records</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="domain_force">
[('user_id', '=', user.id)]
</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>
ウィザード (TransientModel)
ウィザードは、ガイド付きの複数ステップのアクションのための一時的なフォームです。
# wizard/service_request_wizard.py
from odoo import api, fields, models
class ServiceRequestBulkAssign(models.TransientModel):
_name = 'my.service.request.bulk.assign'
_description = 'Bulk Assign Service Requests'
user_id = fields.Many2one(
'res.users', string='Assign To', required=True
)
request_ids = fields.Many2many(
'my.service.request', string='Requests',
default=lambda self: self.env.context.get('active_ids', [])
)
note = fields.Text(string='Note')
def action_assign(self):
self.request_ids.write({'user_id': self.user_id.id})
if self.note:
for request in self.request_ids:
request.message_post(body=self.note)
return {'type': 'ir.actions.act_window_close'}
自動化されたアクションとシーケンス
自動番号付けのシーケンス:
<record id="seq_service_request" model="ir.sequence">
<field name="name">Service Request</field>
<field name="code">my.service.request</field>
<field name="prefix">SRQ/%(year)s/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
スケジュールされたアクション (cron ジョブ):
# In the model
def _cron_remind_overdue_requests(self):
overdue = self.search([
('state', 'in', ['submitted', 'in_progress']),
('date_deadline', '<', fields.Date.today()),
])
for request in overdue:
request.activity_schedule(
'mail.mail_activity_data_warning',
summary='Overdue Service Request',
user_id=request.user_id.id
)
<record id="ir_cron_remind_overdue" model="ir.cron">
<field name="name">Remind Overdue Service Requests</field>
<field name="model_id" ref="model_my_service_request"/>
<field name="state">code</field>
<field name="code">model._cron_remind_overdue_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
よくある質問
models.Model、models.TransientModel、models.AbstractModel の違いは何ですか?
models.Model はデータベースに永続テーブルを作成します。 models.TransientModel は、定期的にクリアされる一時テーブルを作成します (ウィザードに使用されます)。 models.AbstractModel はテーブルを作成しません。これは、別のテーブルを作成せずに、他のモデルがメソッドやフィールドを取得するために継承できるミックスインです。
コア コードを変更せずに既存の Odoo モデルを拡張するにはどうすればよいですか?
_inherit を既存のモデル名で使用し、_name を省略します。これにより、フィールドとメソッドが既存のモデル class SaleOrder(models.Model): _inherit = 'sale.order' に追加されます。出発点として他の人の動作をコピーする新しいモデルを作成するには、_name (新しい名前) と _inherit (ソース モデル) の両方を使用します。
モジュールのデータ モデルが変更された場合、移行はどのように処理すればよいですか?
my_module/migrations/{version}/pre-migrate.py または post-migrate.py で移行スクリプトを作成します。これらのスクリプトは、モジュールの更新中に自動的に実行されます。列の名前を変更するには、openupgradelib ヘルパーを使用します。本番環境に適用する前に、必ず本番データベースのコピーで移行をテストしてください。
コア XML ファイルを変更せずに既存の Odoo ビューをオーバーライドできますか?
はい。 inherit_id を使用して拡張するビューを参照し、xpath 式を使用して要素を見つけ、position 属性 (前、後、内部、置換、属性) を使用して変更を指定します。これにより、変更が分離され、安全にアップグレードできます。
複数の会社が存在する環境でフィールドを会社固有にするにはどうすればよいですか?
フィールド定義: my_field = fields.Char(company_dependent=True) で company_dependent=True を使用します。これにより、会社ごとに個別の値が保存されるため、会社 A と会社 B が同じレコードに対して異なる値を持つことができます。これは、価格表、税金口座、その他の会社固有の構成に使用されます。
開発中にメッセージをログに記録し、デバッグする正しい方法は何ですか?
Python の logging モジュール: import logging; _logger = logging.getLogger(__name__) を使用します。さまざまな重大度レベルには _logger.info()、_logger.warning()、_logger.error() を使用します。製品コードでは print() ステートメントを決して使用しないでください。開発中は、--log-level=debug を指定して Odoo を実行して、すべてのデバッグ出力を確認します。
次のステップ
本番環境に対応した Odoo モジュールを構築するには、フレームワーク、PostgreSQL のパフォーマンスに関する考慮事項、アップグレードしても安全なパターン、および徹底的なテストに関する深い知識が必要です。 Odoo マーケットプレイス向けのモジュールは、セキュリティ、パフォーマンス、コード品質について追加の検証を受けます。
ECOSIRE は、特殊な業界ワークフローからマーケットプレイス コネクタ モジュールに至るまで、特定のビジネス要件に合わせてカスタム Odoo 19 Enterprise モジュールを開発します。私たちの開発チームは、Odoo の公式コーディング ガイドラインに従っており、包括的な単体テストが含まれており、完全なドキュメントを備えたモジュールを提供しています。
ECOSIRE からカスタム Odoo モジュールをコミッショニング →
要件を共有していただければ、開発作業の範囲を絞り、タイムラインを提供し、Odoo 19 Enterprise インストールときれいに統合されるモジュールを提供します。
執筆者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
関連記事
AI を活用した顧客セグメンテーション: RFM から予測クラスタリングまで
AI が顧客セグメンテーションを静的な RFM 分析から動的な予測クラスタリングにどのように変換するかを学びます。 Python、Odoo、および実際の ROI データを使用した実装ガイド。
サプライチェーン最適化のための AI: 可視性、予測、自動化
AI を使用してサプライ チェーンの運用を変革します。需要の検知、サプライヤーのリスク スコアリング、ルートの最適化、倉庫の自動化、混乱の予測などです。 2026年のガイド。
B2B E コマース戦略: 2026 年に卸売オンライン ビジネスを構築する
卸売価格設定、アカウント管理、クレジット条件、パンチアウト カタログ、Odoo B2B ポータル構成の戦略を使用して B2B e コマースをマスターします。