Tạo Module Odoo 19 Tùy Chỉnh Từ Scratch
Tạo Odoo 19 custom module từ scratch — manifest, model field, view XML, security rules trong khoảng 200 dòng code.

Tuần trước mình ngồi với một anh dev mới chuyển sang Odoo, anh ấy mở Apps Store, cài Helpdesk, rồi loay hoay nửa buổi để giấu bớt 12 field business team không dùng. Đến cuối ngày anh ấy hỏi mình một câu rất thẳng: viết hẳn một module mới có khi nào còn nhanh hơn không. Câu trả lời của mình hôm đó, nếu gói gọn thành một bài, thì chính là cái bạn đang đọc. Mình sẽ không hứa rằng Odoo module dev là dễ. Mình hứa rằng sau bài này bạn dựng được một module tên dx_service_request từ clone repo đến chạy được trong khoảng 1.5 giờ, và biết chỗ nào cần đọc lại khi nó fail.
Vấn đề
Có khi nào bạn nhận ra mình cần một module Odoo 19 hoàn toàn mới — không phải override module có sẵn? Có thể là quản lý phiếu yêu cầu dịch vụ, theo dõi thiết bị maintenance, hay log công việc bảo trì hằng ngày. Module có sẵn từ Odoo Apps Store thường thừa tính năng (Helpdesk, Maintenance, Field Service) hoặc thiếu field business mà phòng kế toán bên bạn cần. Custom hoá module có sẵn qua _inherit đáp ứng được khoảng 60 phần trăm nhu cầu, phần còn lại thường đụng vào logic core và phá khả năng nâng cấp lên minor version sau.
Nghe có vẻ là đánh đổi đơn giản, nhưng thật ra phần "phá khả năng nâng cấp" mới là chỗ đau lâu nhất. Một module từ scratch trong tầm 200 dòng code thường gọn hơn, bảo trì dễ hơn, và không gắn chặt với upgrade lifecycle của Odoo core. Bài viết này dựng một module mẫu tên dx_service_request đầy đủ các phần cần thiết. Phần Python định nghĩa model với fields điển hình, state machine 4 bước (draft, confirmed, in_progress, done), và 5 action method. Phần XML định nghĩa security với 2 groups, view form/tree/search, menu top-level, và sequence để auto-generate reference number theo format SR/2026/00001.
So sánh tốc độ build với số dòng code thực tế. Mình viết module mới từ template cộng reference docs mất khoảng 1.5 giờ khi đã quen Odoo, trong khi inherit cộng override một module có sẵn lớn như hr_expense hoặc project mất 3 đến 5 giờ. 200 dòng code đổi lại quyền kiểm soát toàn bộ field schema, validation logic, menu structure, và không phải đọc qua hàng ngàn dòng inherited code mỗi lần debug.
Module source live tại https://github.com/vytharion/odoo-tao-module-tuy-chinh. Clone về, copy vào addons path, restart Odoo, vào Apps Store rồi Update List, search "DX Service Request", click Install.
Cấu trúc addon
Một addon Odoo 19 có 7 folder con, nhưng chỉ 2 trong số đó là nơi bạn thực sự sửa code mỗi ngày — phần còn lại là declarative artefacts viết một lần rồi quên. Phân chia thư mục tiêu chuẩn của Odoo như sau. Thư mục models/ chứa Python class kế thừa models.Model. Thư mục views/ chứa XML định nghĩa form/tree/search/menu. Thư mục security/ chứa CSV access rules và XML record rules. Thư mục data/ chứa data records được cài lúc install (sequence, default values, mail templates).
dx_service_request/
__init__.py
__manifest__.py
models/
__init__.py
service_request.py
views/
service_request_views.xml
service_request_menus.xml
security/
ir.model.access.csv
security.xml
data/
service_request_sequence.xml
File __init__.py ở root chỉ chứa from . import models. Odoo registry sẽ tự import file này khi load addon. File models/__init__.py import từng file model cụ thể. Mình thường tách model ra nhiều file ngay từ đầu, vì khi module phát triển lên 5 model trở lên, mỗi file lo một concern riêng biệt sẽ giúp dễ tìm code hơn nhiều so với một file duy nhất dài 800 dòng.
Manifest
Tại sao manifest Odoo lại là file duy nhất trong addon mà bạn KHÔNG được dùng f-string, function call, hay biến runtime? Nội dung file là một Python dict, được Odoo đọc bằng ast.literal_eval, không phải bằng import. Vì vậy đừng dùng f-string, function call, hay biến runtime trong đây. Manifest phải đứng độc lập về parse.
{
"name": "DX Service Request",
"version": "19.0.1.0.0",
"summary": "Quản lý phiếu yêu cầu dịch vụ với state machine 4 bước.",
"author": "vytharion",
"category": "Services",
"license": "LGPL-3",
"depends": ["base", "mail"],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"data/service_request_sequence.xml",
"views/service_request_views.xml",
"views/service_request_menus.xml",
],
"installable": True,
"application": True,
"auto_install": False,
}
Required keys cần lưu ý. Field name hiển thị trong Apps Store. Field version theo format <odoo_version>.<addon_major>.<minor>.<patch> — với Odoo 19 mở đầu bằng 19.0. List depends khai báo module phụ thuộc. List data khai báo XML/CSV file load lúc install theo đúng thứ tự: security trước view, view trước menu, sequence trước record nào dùng nó.
Dependency mail cho phép mail.thread mixin (tracking cộng chatter). Bỏ ra nếu module không cần audit log. Key application: True đẩy module lên đầu danh sách khi filter "Apps", ngược lại module bị xếp vào "Other Apps" cùng các utilities nhỏ.
Vài lỗi mình hay gặp khi viết manifest, kể ra để bạn tránh. Thiếu key depends thì module vẫn install nhưng crash khi field reference qua comodel_name="res.partner", vì module base chưa load trước đó. Thiếu key data thì view cộng security không apply — module install xong không thấy menu. Thiếu installable (default True từ Odoo 16 trở đi) thì module ẩn khỏi UI.
Model + field
Naming convention cần nhất quán. Model name dx.service.request với dấu chấm phân cách, snake_case, prefix dx_ để tránh đụng module core. Class name ServiceRequest PascalCase. Field _description BẮT BUỘC từ Odoo 16 trở đi — thiếu nó Odoo crash khi tạo ir.model record lúc install.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class ServiceRequest(models.Model):
_name = "dx.service.request"
_description = "DX Service Request"
_order = "create_date desc"
_inherit = ["mail.thread", "mail.activity.mixin"]
name = fields.Char(
string="Reference",
required=True,
copy=False,
default="New",
tracking=True,
)
partner_id = fields.Many2one(
comodel_name="res.partner",
string="Customer",
required=True,
tracking=True,
)
state = fields.Selection(
selection=[
("draft", "Draft"),
("confirmed", "Confirmed"),
("in_progress", "In Progress"),
("done", "Done"),
("cancelled", "Cancelled"),
],
string="Status",
default="draft",
required=True,
tracking=True,
)
estimated_hours = fields.Float(string="Estimated Hours", default=1.0)
actual_hours = fields.Float(string="Actual Hours")
overrun_hours = fields.Float(
string="Overrun Hours",
compute="_compute_overrun_hours",
store=True,
)
@api.depends("estimated_hours", "actual_hours")
def _compute_overrun_hours(self):
for rec in self:
rec.overrun_hours = max(rec.actual_hours - rec.estimated_hours, 0.0)
Mấy điểm đáng để ý ở đây.
_inherit = ["mail.thread", "mail.activity.mixin"] cho phép tracking field changes vào chatter và tạo activity. Field nào muốn track thì thêm flag tracking=True. Mixin này thêm khoảng 8 columns vào table (message_main_attachment_id, message_is_follower, các cột follow-up khác). Đánh đổi rõ ràng: audit log đầy đủ vs bloat database khi module có hàng triệu record.
fields.Selection dùng list literal cố định. Tuyệt đối đừng dùng selection=lambda capture self, vì lambda được evaluate lúc class build time — lúc đó self chưa tồn tại. Nếu cần dynamic selection, dùng function reference selection="_get_states" rồi định nghĩa method trả về list tuple.
company_id với default lambda self: self.env.company là pattern multi-company chuẩn. Lambda ở đây OK vì Odoo evaluate nó tại record-create time. Lambda nhận self là recordset instance, không phải class object như ở selection.
Field overrun_hours là computed field với store=True. Khi bạn muốn search, group, hoặc filter trên computed field thì phải store. Đánh đổi rất rõ: store=True đẩy compute ra write/create time và tốn disk khoảng 8 bytes mỗi row. Không store thì compute lại mỗi lần read, gây chậm khi list view có sum hoặc group-by.
Decorator @api.model_create_multi từ Odoo 17 trở đi là batched create, nhận vals_list (list dict) thay vì vals (single dict). Override create theo style cũ chỉ chạy đúng khi batch size bằng 1, gây bug subtle khi user import 100 record cùng lúc qua data import wizard. Luôn dùng model_create_multi cho code mới.
View XML
Odoo 17 trở đi đổi <tree> tag thành <list>. Cũng bỏ attrs cộng states attribute để ưu tiên expression syntax trực tiếp. Odoo 19 chỉ chấp nhận:
<button invisible="state != 'draft'"/>
<field name="name" readonly="state in ('done', 'cancelled')"/>
Không còn syntax cũ kiểu attrs="{'invisible': [('state', '!=', 'draft')]}". Code base nào còn dùng pattern cũ sẽ crash ngay khi load XML, với error ValueError: Invalid view definition.
<record id="view_dx_service_request_form" model="ir.ui.view">
<field name="name">dx.service.request.form</field>
<field name="model">dx.service.request</field>
<field name="arch" type="xml">
<form string="Service Request">
<header>
<button name="action_confirm" string="Confirm" type="object"
class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_start" string="Start Work" type="object"
class="oe_highlight" invisible="state != 'confirmed'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed,in_progress,done"/>
</header>
<sheet>
<group>
<group>
<field name="partner_id"/>
<field name="request_date"/>
</group>
<group>
<field name="technician_id"/>
<field name="estimated_hours" widget="float_time"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
Header section chứa state buttons cộng statusbar widget. Class oe_highlight để button nổi bật theo style Bootstrap primary. Statusbar widget với statusbar_visible="draft,confirmed,in_progress,done" ẩn các state intermediate khi record đã pass qua, giúp form không bị clutter ở stage cuối.
Search view có 3 phần riêng biệt. Element <field> cho search trực tiếp. Element <filter> cho preset filters. Element <group> cho group-by options. Filter domain="[('technician_id', '=', uid)]" dùng built-in uid (current user ID) — handy cho preset "My Tasks" filter mà mọi business app đều cần.
Action ir.actions.act_window link model với search view và menu. Field view_mode="list,form" định nghĩa thứ tự view, mở list trước rồi double-click vào form. Cần kanban view thì đổi thành "kanban,list,form" và thêm record <record model="ir.ui.view"> kiểu kanban.
Security
Security trong Odoo có 2 tầng. Tầng 1 là ir.model.access.csv cho CRUD permissions theo group. Tầng 2 là ir.rule (record rules) cho row-level filtering. Cả 2 phải có nếu module xử lý dữ liệu nhạy cảm hoặc multi-company.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_dx_service_request_user,dx.service.request.user,model_dx_service_request,group_dx_service_request_user,1,1,1,0
access_dx_service_request_manager,dx.service.request.manager,model_dx_service_request,group_dx_service_request_manager,1,1,1,1
CSV header phải đúng order: cột id, name, model_id:id, group_id:id, rồi 4 permission columns. Field model_id:id reference theo external ID auto-generate từ _name. Model dx.service.request map tới external ID model_dx_service_request (đổi dấu chấm thành underscore, prefix model_).
<record id="group_dx_service_request_user" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="module_category_dx_service"/>
</record>
<record id="group_dx_service_request_manager" model="res.groups">
<field name="name">Manager</field>
<field name="category_id" ref="module_category_dx_service"/>
<field name="implied_ids" eval="[(4, ref('group_dx_service_request_user'))]"/>
</record>
<record id="dx_service_request_company_rule" model="ir.rule">
<field name="name">Service Request: multi-company</field>
<field name="model_id" ref="model_dx_service_request"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
Pattern User vs Manager dễ nhớ. User group có CRUD nhưng không delete (perm_unlink=0). Manager có full access bao gồm delete. Field implied_ids ở Manager auto-thêm User group — gán Manager là gán cả 2, không cần manual sync.
Record rule với domain_force=['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] là multi-company pattern chuẩn. Record nào có company_id=False (shared across companies) thì user nào cũng thấy. Record còn lại filter theo company_ids của current user. Bỏ qua rule này thì user công ty A đọc được record công ty B — leak data ngay sau install lần đầu khi có ít nhất 2 company.
Workflow / business logic
State machine ở dx_service_request đi qua 4 trạng thái normal cộng 1 nhánh cancel.
draft -> confirmed -> in_progress -> done
| | |
v v v
cancelled cancelled cancelled
Mỗi action method validate state hiện tại trước khi transition. Method action_start thêm precondition: technician phải được assign trước khi start. Đừng validate ở UI một mình — UI button có thể bị invisible="..." ẩn đi nhưng RPC call (/web/dataset/call_kw) vẫn đi qua, nên check ở Python side mới chắc.
def action_start(self):
for rec in self:
if rec.state != "confirmed":
raise UserError(_("Only confirmed requests can be started."))
if not rec.technician_id:
raise UserError(_("Assign a technician before starting."))
rec.state = "in_progress"
raise UserError(...) thay vì raise Exception(...) để Odoo hiển thị dialog box thân thiện thay vì stack trace cho end user. UserError rollback transaction tự động, không cần self.env.cr.rollback(). Đặc biệt KHÔNG gọi self.env.cr.commit() trong business method, vì Odoo quản lý transaction boundary ở request-level. Commit thủ công break atomicity — dữ liệu nửa vời lọt vào DB khi exception thrown sau đó.
Computed field overrun_hours recompute mỗi lần estimated_hours hoặc actual_hours thay đổi (qua @api.depends). Với store=True, Odoo write giá trị vào DB column lúc create/write, không tính lại khi read. Overhead chỉ 1 lần khi update field source, không có overhead khi list 10000 record.
Method create override gọi self.env["ir.sequence"].next_by_code("dx.service.request") để lấy reference number theo prefix SR/2026/00001. Sequence record được declare ở data/service_request_sequence.xml với flag noupdate="1". Install lần đầu tạo sequence với counter bằng 1. Upgrade module lần sau không reset counter — giữ nguyên reference đã issue cho khách hàng.
Test thủ công
Sau khi module load thành công, mình test qua UI lần lượt như sau.
Bước 1. Login Odoo với user admin. Vào Settings, rồi Users & Companies, rồi Users, chọn admin, tab Access Rights, tick checkbox DX Service Request / Manager. Save. Refresh trang.
Bước 2. Mở menu Service Requests ở top bar (sequence=50 đặt giữa Sales và Inventory). Nếu menu không hiện, kiểm tra lại bước 1 (chưa gán group) hoặc check log Odoo (XML view có thể fail parse, menu sẽ không render).
Bước 3. Tạo record mới. Click New, chọn Customer (any partner trong res.partner), set Priority High, Save. Field Reference auto-fill SR/2026/00001. Nếu bạn thấy New thay vì reference đúng, sequence chưa load — verify thứ tự data/ trong manifest.
Bước 4. Test state transition. Click Confirm, state Draft chuyển sang Confirmed. Click Start Work, báo lỗi "Assign a technician before starting" (validate hoạt động). Quay lại set Technician bằng admin, Start Work lần 2, state chuyển sang In Progress. Set actual_hours = 2.5, estimated_hours mặc định 1.0. Field overrun_hours auto-update thành 1.5 ngay sau khi blur khỏi input.
Bước 5. Verify ở DB layer. Login psql: psql -d odoo_test. Query:
SELECT name, state, partner_id, estimated_hours, actual_hours, overrun_hours
FROM dx_service_request;
Bạn thấy row vừa tạo với đầy đủ field. Verify rằng overrun_hours được store thật (cột tồn tại trong table), không phải compute on-read.
Bước 6. Verify chatter. Refresh form view. Bạn thấy log dạng "Status: Draft -> Confirmed -> In Progress" trong chatter (tracking=True trên field state làm việc). Nếu chatter empty, có thể _inherit = ["mail.thread"] chưa đúng hoặc mixin chưa load (depends thiếu mail).
Bước 7. Test security. Tạo user mới: vào Settings, Users, New, set group DX Service Request / User (không tick Manager). Login bằng user mới qua incognito. Vào menu Service Requests. Tạo record OK. Nhưng menu Action -> Delete bị disable (perm_unlink=0). Đây là bằng chứng access rule apply đúng từ CSV.
Install qua CLI thay vì UI khi dev iterate:
odoo-bin -d test_db -i dx_service_request --stop-after-init --log-level=debug
Flag -i (initialize) load module lần đầu. Sau đó iterate dùng -u dx_service_request (upgrade) để reload XML mà không drop DB. Log-level=debug giúp catch warning về deprecated syntax sớm.
Repository
Full source at https://github.com/vytharion/odoo-tao-module-tuy-chinh.
- Commit 0 -> e5318e0 - Scaffold README cộng .gitignore
- Commit 1 -> 781e730 - Manifest cộng module skeleton (
__init__.py,__manifest__.py) - Commit 2 -> 4a9105b - Model fields cộng state machine actions (
models/service_request.py) - Commit 3 -> 8696420 - Security groups cộng access rules (
security/ir.model.access.csvcộngsecurity/security.xml) - Commit 4 -> 85e62fc - Views, menus, sequence (
views/service_request_views.xmlcộngdata/service_request_sequence.xml)
Tham khảo public:
- Odoo 19 ORM API reference:
https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html - Module manifest schema:
https://www.odoo.com/documentation/19.0/developer/reference/backend/module.html
Kết luận cộng Bước tiếp
Module 200-dòng dx_service_request cover được phần lớn boilerplate cần thiết khi build custom module Odoo 19. Manifest đúng schema. Model với typed fields và state machine có validate Python-side. Security 2 tầng (group cộng record rule). View dùng expression-based attribute thay attrs cũ. Sequence auto-numbering với prefix theo năm. Chatter tracking qua mixin. Pattern này scale tốt đến module 5-10 model trước khi cần tách thành nhiều addon hoặc rewrite controllers/wizards riêng.
Bước tiếp bạn có thể tự làm để mở rộng module:
- Thêm wizard
dx.service.request.assign.wizardđể bulk-assign technician cho nhiều record cùng lúc. - Đẩy reports XLSX qua
report_xlsxmodule (cài qua OCA repository). - Tạo REST endpoint
/api/v1/service-requestsqua controller cộng@http.route(auth='user', type='json')để mobile app fetch data. - Viết unit test với
tests/test_service_request.pykế thừaTransactionCase, test các action method quawith self.assertRaises(UserError).
Clone repo, copy vào addons path, install lần đầu qua -i dx_service_request, sửa theo nhu cầu business của bạn. Module này là starter template, không phải production-ready end-product. Thêm reports, validation rules, cron jobs theo từng use case cụ thể.