カスタム 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 Research and Development Team
ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
Odoo Accounting vs QuickBooks: Detailed Comparison 2026
In-depth 2026 comparison of Odoo Accounting vs QuickBooks covering features, pricing, integrations, scalability, and which platform fits your business needs.
Case Study: eCommerce Migration to Shopify with Odoo Backend
How a fashion retailer migrated from WooCommerce to Shopify and connected it to Odoo ERP, cutting order fulfillment time by 71% and growing revenue 43%.
Case Study: Manufacturing ERP Implementation with Odoo 19
How a Pakistani auto-parts manufacturer cut order processing time by 68% and reduced inventory variance to under 2% with ECOSIRE's Odoo 19 implementation.