tạo module odoo tùy chỉnh
Tạo Odoo 19 custom module từ scratch — manifest, model field, view XML, security rules trong khoảng 200 dòng code.
Tu\u1ea7n tr\u01b0\u1edbc m\u00ecnh ng\u1ed3i v\u1edbi m\u1ed9t anh dev m\u1edbi chuy\u1ec3n sang Odoo, anh \u1ea5y m\u1edf Apps Store, c\u00e0i Helpdesk, r\u1ed3i loay hoay n\u1eeda bu\u1ed5i \u0111\u1ec3 gi\u1ea5u b\u1edbt 12 field business team kh\u00f4ng d\u00f9ng. \u0110\u1ebfn cu\u1ed1i ng\u00e0y anh \u1ea5y h\u1ecfi m\u00ecnh m\u1ed9t c\u00e2u r\u1ea5t th\u1eb3ng: vi\u1ebft h\u1eb3n m\u1ed9t module m\u1edbi c\u00f3 khi n\u00e0o c\u00f2n nhanh h\u01a1n kh\u00f4ng. C\u00e2u tr\u1ea3 l\u1eddi c\u1ee7a m\u00ecnh h\u00f4m \u0111\u00f3, n\u1ebfu g\u00f3i g\u1ecdn th\u00e0nh m\u1ed9t b\u00e0i, th\u00ec ch\u00ednh l\u00e0 c\u00e1i b\u1ea1n \u0111ang \u0111\u1ecdc. M\u00ecnh s\u1ebd kh\u00f4ng h\u1ee9a r\u1eb1ng Odoo module dev l\u00e0 d\u1ec5. M\u00ecnh h\u1ee9a r\u1eb1ng sau b\u00e0i n\u00e0y b\u1ea1n d\u1ef1ng \u0111\u01b0\u1ee3c m\u1ed9t module t\u00ean dx_service_request t\u1eeb clone repo \u0111\u1ebfn ch\u1ea1y \u0111\u01b0\u1ee3c trong kho\u1ea3ng 1.5 gi\u1edd, v\u00e0 bi\u1ebft ch\u1ed7 n\u00e0o c\u1ea7n \u0111\u1ecdc l\u1ea1i khi n\u00f3 fail.
V\u1ea5n \u0111\u1ec1
C\u00f3 khi n\u00e0o b\u1ea1n nh\u1eadn ra m\u00ecnh c\u1ea7n m\u1ed9t module Odoo 19 ho\u00e0n to\u00e0n m\u1edbi \u2014 kh\u00f4ng ph\u1ea3i override module c\u00f3 s\u1eb5n? C\u00f3 th\u1ec3 l\u00e0 qu\u1ea3n l\u00fd phi\u1ebfu y\u00eau c\u1ea7u d\u1ecbch v\u1ee5, theo d\u00f5i thi\u1ebft b\u1ecb maintenance, hay log c\u00f4ng vi\u1ec7c b\u1ea3o tr\u00ec h\u1eb1ng ng\u00e0y. Module c\u00f3 s\u1eb5n t\u1eeb Odoo Apps Store th\u01b0\u1eddng th\u1eeba t\u00ednh n\u0103ng (Helpdesk, Maintenance, Field Service) ho\u1eb7c thi\u1ebfu field business m\u00e0 ph\u00f2ng k\u1ebf to\u00e1n b\u00ean b\u1ea1n c\u1ea7n. Custom ho\u00e1 module c\u00f3 s\u1eb5n qua _inherit \u0111\u00e1p \u1ee9ng \u0111\u01b0\u1ee3c kho\u1ea3ng 60 ph\u1ea7n tr\u0103m nhu c\u1ea7u, ph\u1ea7n c\u00f2n l\u1ea1i th\u01b0\u1eddng \u0111\u1ee5ng v\u00e0o logic core v\u00e0 ph\u00e1 kh\u1ea3 n\u0103ng n\u00e2ng c\u1ea5p l\u00ean minor version sau.
Nghe c\u00f3 v\u1ebb l\u00e0 \u0111\u00e1nh \u0111\u1ed5i \u0111\u01a1n gi\u1ea3n, nh\u01b0ng th\u1eadt ra ph\u1ea7n "ph\u00e1 kh\u1ea3 n\u0103ng n\u00e2ng c\u1ea5p" m\u1edbi l\u00e0 ch\u1ed7 \u0111au l\u00e2u nh\u1ea5t. M\u1ed9t module t\u1eeb scratch trong t\u1ea7m 200 d\u00f2ng code th\u01b0\u1eddng g\u1ecdn h\u01a1n, b\u1ea3o tr\u00ec d\u1ec5 h\u01a1n, v\u00e0 kh\u00f4ng g\u1eafn ch\u1eb7t v\u1edbi upgrade lifecycle c\u1ee7a Odoo core. B\u00e0i vi\u1ebft n\u00e0y d\u1ef1ng m\u1ed9t module m\u1eabu t\u00ean dx_service_request \u0111\u1ea7y \u0111\u1ee7 c\u00e1c ph\u1ea7n c\u1ea7n thi\u1ebft. Ph\u1ea7n Python \u0111\u1ecbnh ngh\u0129a model v\u1edbi fields \u0111i\u1ec3n h\u00ecnh, state machine 4 b\u01b0\u1edbc (draft, confirmed, in_progress, done), v\u00e0 5 action method. Ph\u1ea7n XML \u0111\u1ecbnh ngh\u0129a security v\u1edbi 2 groups, view form/tree/search, menu top-level, v\u00e0 sequence \u0111\u1ec3 auto-generate reference number theo format SR/2026/00001.
So s\u00e1nh t\u1ed1c \u0111\u1ed9 build v\u1edbi s\u1ed1 d\u00f2ng code th\u1ef1c t\u1ebf. M\u00ecnh vi\u1ebft module m\u1edbi t\u1eeb template c\u1ed9ng reference docs m\u1ea5t kho\u1ea3ng 1.5 gi\u1edd khi \u0111\u00e3 quen Odoo, trong khi inherit c\u1ed9ng override m\u1ed9t module c\u00f3 s\u1eb5n l\u1edbn nh\u01b0 hr_expense ho\u1eb7c project m\u1ea5t 3 \u0111\u1ebfn 5 gi\u1edd. 200 d\u00f2ng code \u0111\u1ed5i l\u1ea1i quy\u1ec1n ki\u1ec3m so\u00e1t to\u00e0n b\u1ed9 field schema, validation logic, menu structure, v\u00e0 kh\u00f4ng ph\u1ea3i \u0111\u1ecdc qua h\u00e0ng ng\u00e0n d\u00f2ng inherited code m\u1ed7i l\u1ea7n debug.
Module source live t\u1ea1i https://github.com/vytharion/odoo-tao-module-tuy-chinh. Clone v\u1ec1, copy v\u00e0o addons path, restart Odoo, v\u00e0o Apps Store r\u1ed3i Update List, search "DX Service Request", click Install.
C\u1ea5u tr\u00fac addon
M[u1ed9t addon](https://odoo.nicedx.com/text-to-sql-addon/) Odoo 19 c\u00f3 7 folder con, nh\u01b0ng ch\u1ec9 2 trong s\u1ed1 \u0111\u00f3 l\u00e0 n\u01a1i b\u1ea1n th\u1ef1c s\u1ef1 s\u1eeda code m\u1ed7i ng\u00e0y \u2014 ph\u1ea7n c\u00f2n l\u1ea1i l\u00e0 declarative artefacts vi\u1ebft m\u1ed9t l\u1ea7n r\u1ed3i qu\u00ean. Ph\u00e2n chia th\u01b0 m\u1ee5c ti\u00eau chu\u1ea9n c\u1ee7a Odoo nh\u01b0 sau. Th\u01b0 m\u1ee5c models/ ch\u1ee9a Python class k\u1ebf th\u1eeba models.Model. Th\u01b0 m\u1ee5c views/ ch\u1ee9a XML \u0111\u1ecbnh ngh\u0129a form/tree/search/menu. Th\u01b0 m\u1ee5c security/ ch\u1ee9a CSV access rules v\u00e0 XML record rules. Th\u01b0 m\u1ee5c data/ ch\u1ee9a data records \u0111\u01b0\u1ee3c c\u00e0i l\u00fac 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 \u1edf root ch\u1ec9 ch\u1ee9a from . import models. Odoo registry s\u1ebd t\u1ef1 import file n\u00e0y khi load addon. File models/__init__.py import t\u1eebng file model c\u1ee5 th\u1ec3. M\u00ecnh th\u01b0\u1eddng t\u00e1ch model ra nhi\u1ec1u file ngay t\u1eeb \u0111\u1ea7u, v\u00ec khi module ph\u00e1t tri\u1ec3n l\u00ean 5 model tr\u1edf l\u00ean, m\u1ed7i file lo m\u1ed9t concern ri\u00eang bi\u1ec7t s\u1ebd gi\u00fap d\u1ec5 t\u00ecm code h\u01a1n nhi\u1ec1u so v\u1edbi m\u1ed9t file duy nh\u1ea5t d\u00e0i 800 d\u00f2ng.
Manifest
T\u1ea1i sao manifest Odoo l\u1ea1i l\u00e0 file duy nh\u1ea5t trong addon m\u00e0 b\u1ea1n KH\u00d4NG \u0111\u01b0\u1ee3c d\u00f9ng f-string, function call, hay bi\u1ebfn runtime? N\u1ed9i dung file l\u00e0 m\u1ed9t Python dict, \u0111\u01b0\u1ee3c Odoo \u0111\u1ecdc b\u1eb1ng ast.literal_eval, kh\u00f4ng ph\u1ea3i b\u1eb1ng import. V\u00ec v\u1eady \u0111\u1eebng d\u00f9ng f-string, function call, hay bi\u1ebfn runtime trong \u0111\u00e2y. Manifest ph\u1ea3i \u0111\u1ee9ng \u0111\u1ed9c l\u1eadp v\u1ec1 parse.
{
"name": "DX Service Request",
"version": "19.0.1.0.0",
"summary": "Qu\u1ea3n l\u00fd phi\u1ebfu y\u00eau c\u1ea7u d\u1ecbch v\u1ee5 v\u1edbi state machine 4 b\u01b0\u1edbc.",
"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\u1ea7n l\u01b0u \u00fd. Field name hi\u1ec3n th\u1ecb trong Apps Store. Field version theo format <odoo_version>.<addon_major>.<minor>.<patch> n\u00ean v\u1edbi Odoo 19 m\u1edf \u0111\u1ea7u b\u1eb1ng 19.0. List depends khai b\u00e1o module ph\u1ee5 thu\u1ed9c. List data khai b\u00e1o XML/CSV file load l\u00fac install theo \u0111\u00fang th\u1ee9 t\u1ef1, security tr\u01b0\u1edbc view, view tr\u01b0\u1edbc menu, sequence tr\u01b0\u1edbc record n\u00e0o d\u00f9ng n\u00f3.
Dependency mail cho ph\u00e9p mail.thread mixin (tracking c\u1ed9ng chatter). B\u1ecf ra n\u1ebfu module kh\u00f4ng c\u1ea7n audit log. Key application: True \u0111\u1ea9y module l\u00ean \u0111\u1ea7u danh s\u00e1ch khi filter "Apps", ng\u01b0\u1ee3c l\u1ea1i module b\u1ecb x\u1ebfp v\u00e0o "Other Apps" c\u00f9ng utilities nh\u1ecf.
V\u00e0i l\u1ed7i m\u00ecnh hay g\u1eb7p khi vi\u1ebft manifest, k\u1ec3 ra \u0111\u1ec3 b\u1ea1n tr\u00e1nh. Thi\u1ebfu key depends th\u00ec module v\u1eabn install nh\u01b0ng crash khi field reference qua comodel_name="res.partner", v\u00ec module base ch\u01b0a load tr\u01b0\u1edbc \u0111\u00f3. Thi\u1ebfu key data th\u00ec view c\u1ed9ng security kh\u00f4ng apply, module install xong kh\u00f4ng th\u1ea5y menu. Thi\u1ebfu installable (default True t\u1eeb Odoo 16 tr\u1edf \u0111i) th\u00ec module \u1ea9n kh\u1ecfi UI.
Model + field
Naming convention c\u1ea7n nh\u1ea5t qu\u00e1n. Model name dx.service.request v\u1edbi d\u1ea5u ch\u1ea5m ph\u00e2n c\u00e1ch, snake_case, prefix dx_ \u0111\u1ec3 tr\u00e1nh \u0111\u1ee5ng module core. Class name ServiceRequest PascalCase. Field _description B\u1eaeT BU\u1ed8C t\u1eeb Odoo 16 tr\u1edf \u0111i. Thi\u1ebfu n\u00f3 Odoo crash khi t\u1ea1o ir.model record l\u00fac 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\u1ea5y \u0111i\u1ec3m \u0111\u00e1ng \u0111\u1ec3 \u00fd \u1edf \u0111\u00e2y.
_inherit = ["mail.thread", "mail.activity.mixin"] cho ph\u00e9p tracking field changes v\u00e0o chatter v\u00e0 t\u1ea1o activity. Field n\u00e0o mu\u1ed1n track th\u00ec th\u00eam flag tracking=True. Mixin n\u00e0y th\u00eam kho\u1ea3ng 8 columns v\u00e0o table (message_main_attachment_id, message_is_follower, c\u00e1c c\u1ed9t follow-up kh\u00e1c). \u0110\u00e1nh \u0111\u1ed5i r\u00f5 r\u00e0ng: audit log \u0111\u1ea7y \u0111\u1ee7 vs bloat database khi module c\u00f3 h\u00e0ng tri\u1ec7u record.
fields.Selection d\u00f9ng list literal c\u1ed1 \u0111\u1ecbnh. Tuy\u1ec7t \u0111\u1ed1i \u0111\u1eebng d\u00f9ng selection=lambda capture self, v\u00ec lambda \u0111\u01b0\u1ee3c evaluate l\u00fac class build time, l\u00fac \u0111\u00f3 self ch\u01b0a t\u1ed3n t\u1ea1i. N\u1ebfu c\u1ea7n dynamic selection, d\u00f9ng function reference selection="_get_states" r\u1ed3i \u0111\u1ecbnh ngh\u0129a method tr\u1ea3 v\u1ec1 list tuple.
company_id v\u1edbi default lambda self: self.env.company l\u00e0 pattern multi-company chu\u1ea9n. Lambda \u1edf \u0111\u00e2y OK v\u00ec Odoo evaluate n\u00f3 t\u1ea1i record-create time. Lambda nh\u1eadn self l\u00e0 recordset instance, kh\u00f4ng ph\u1ea3i class object nh\u01b0 \u1edf selection.
Field overrun_hours l\u00e0 computed field v\u1edbi store=True. Khi b\u1ea1n mu\u1ed1n search, group, ho\u1eb7c filter tr\u00ean computed field th\u00ec ph\u1ea3i store. \u0110\u00e1nh \u0111\u1ed5i r\u1ea5t r\u00f5. Store=True \u0111\u1ea9y compute ra write/create time v\u00e0 t\u1ed1n disk kho\u1ea3ng 8 bytes m\u1ed7i row. Kh\u00f4ng store th\u00ec compute l\u1ea1i m\u1ed7i l\u1ea7n read, g\u00e2y ch\u1eadm khi list view c\u00f3 sum ho\u1eb7c group-by.
Decorator @api.model_create_multi t\u1eeb Odoo 17 tr\u1edf \u0111i l\u00e0 batched create, nh\u1eadn vals_list (list dict) thay v\u00ec vals (single dict). Override create theo style c\u0169 ch\u1ec9 ch\u1ea1y \u0111\u00fang khi batch size b\u1eb1ng 1, g\u00e2y bug subtle khi user import 100 record c\u00f9ng l\u00fac qua data import wizard. Lu\u00f4n d\u00f9ng model_create_multi cho code m\u1edbi.
View XML
Odoo 17 tr\u1edf \u0111i \u0111\u1ed5i <tree> tag th\u00e0nh <list>. C\u0169ng b\u1ecf attrs c\u1ed9ng states attribute \u0111\u1ec3 \u01b0u ti\u00ean expression syntax tr\u1ef1c ti\u1ebfp. Odoo 19 ch\u1ec9 ch\u1ea5p nh\u1eadn:
<button invisible="state != 'draft'"/>
<field name="name" readonly="state in ('done', 'cancelled')"/>
Kh\u00f4ng c\u00f2n syntax c\u0169 ki\u1ec3u attrs="{'invisible': [('state', '!=', 'draft')]}". Code base n\u00e0o c\u00f2n d\u00f9ng pattern c\u0169 s\u1ebd crash ngay khi load XML, v\u1edbi 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\u1ee9a state buttons c\u1ed9ng statusbar widget. Class oe_highlight \u0111\u1ec3 button n\u1ed5i b\u1eadt theo style Bootstrap primary. Statusbar widget v\u1edbi statusbar_visible="draft,confirmed,in_progress,done" \u1ea9n c\u00e1c state intermediate khi record \u0111\u00e3 pass qua, gi\u00fap form kh\u00f4ng b\u1ecb clutter \u1edf stage cu\u1ed1i.
Search view c\u00f3 3 ph\u1ea7n ri\u00eang bi\u1ec7t. Element <field> cho search tr\u1ef1c ti\u1ebfp. Element <filter> cho preset filters. Element <group> cho group-by options. Filter domain="[('technician_id', '=', uid)]" d\u00f9ng built-in uid (current user ID), handy cho preset "My Tasks" filter m\u00e0 m\u1ecdi business app \u0111\u1ec1u c\u1ea7n.
Action ir.actions.act_window link model v\u1edbi search view v\u00e0 menu. Field view_mode="list,form" \u0111\u1ecbnh ngh\u0129a th\u1ee9 t\u1ef1 view, m\u1edf list tr\u01b0\u1edbc double-click v\u00e0o form. C\u1ea7n kanban view th\u00ec \u0111\u1ed5i th\u00e0nh "kanban,list,form" v\u00e0 th\u00eam record <record model="ir.ui.view"> ki\u1ec3u kanban.
Security
Security trong Odoo c\u00f3 2 t\u1ea7ng. T\u1ea7ng 1 l\u00e0 ir.model.access.csv cho CRUD permissions theo group. T\u1ea7ng 2 l\u00e0 ir.rule (record rules) cho row-level filtering. C\u1ea3 2 ph\u1ea3i c\u00f3 n\u1ebfu module x\u1eed l\u00fd d\u1eef li\u1ec7u nh\u1ea1y c\u1ea3m ho\u1eb7c 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\u1ea3i \u0111\u00fang order. C\u1ed9t id, name, model_id:id, group_id:id, r\u1ed3i 4 permission columns. Field model_id:id reference theo external ID auto-generate t\u1eeb _name. Model dx.service.request map t\u1edbi external ID model_dx_service_request (\u0111\u1ed5i d\u1ea5u ch\u1ea5m th\u00e0nh 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\u1ec5 nh\u1edb. User group c\u00f3 CRUD nh\u01b0ng kh\u00f4ng delete (perm_unlink=0). Manager c\u00f3 full access bao g\u1ed3m delete. Field implied_ids \u1edf Manager auto-th\u00eam User group, g\u00e1n Manager l\u00e0 g\u00e1n c\u1ea3 2 kh\u00f4ng c\u1ea7n manual sync.
Record rule v\u1edbi domain_force=['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] l\u00e0 multi-company pattern chu\u1ea9n. Record n\u00e0o c\u00f3 company_id=False (shared across companies) th\u00ec user n\u00e0o c\u0169ng th\u1ea5y. Record c\u00f2n l\u1ea1i filter theo company_ids c\u1ee7a current user. B\u1ecf qua rule n\u00e0y th\u00ec user c\u00f4ng ty A \u0111\u1ecdc \u0111\u01b0\u1ee3c record c\u00f4ng ty B, leak data ngay sau install l\u1ea7n \u0111\u1ea7u khi c\u00f3 \u00edt nh\u1ea5t 2 company.
Workflow / business logic
State machine \u1edf dx_service_request \u0111i qua 4 tr\u1ea1ng th\u00e1i normal c\u1ed9ng 1 nh\u00e1nh cancel.
draft -> confirmed -> in_progress -> done
| | |
v v v
cancelled cancelled cancelled
M\u1ed7i action method validate state hi\u1ec7n t\u1ea1i tr\u01b0\u1edbc khi transition. Method action_start th\u00eam precondition. Technician ph\u1ea3i \u0111\u01b0\u1ee3c assign tr\u01b0\u1edbc khi start. \u0110\u1eebng validate \u1edf UI m\u1ed9t m\u00ecnh. UI button c\u00f3 th\u1ec3 b\u1ecb invisible="..." \u1ea9n \u0111i nh\u01b0ng RPC call (/web/dataset/call_kw) v\u1eabn \u0111i qua, n\u00ean check \u1edf Python side m\u1edbi ch\u1eafc.
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\u00ec raise Exception(...) \u0111\u1ec3 Odoo hi\u1ec3n th\u1ecb dialog box th\u00e2n thi\u1ec7n thay v\u00ec stack trace cho end user. UserError rollback transaction t\u1ef1 \u0111\u1ed9ng, kh\u00f4ng c\u1ea7n self.env.cr.rollback(). \u0110\u1eb7c bi\u1ec7t KH\u00d4NG g\u1ecdi self.env.cr.commit() trong business method, v\u00ec Odoo qu\u1ea3n l\u00fd transaction boundary \u1edf request-level. Commit th\u1ee7 c\u00f4ng break atomicity, d\u1eef li\u1ec7u n\u1eeda-v\u1eddi l\u1ecdt v\u00e0o DB khi exception thrown sau \u0111\u00f3.
Computed field overrun_hours recompute m\u1ed7i l\u1ea7n estimated_hours ho\u1eb7c actual_hours thay \u0111\u1ed5i (qua @api.depends). V\u1edbi store=True, Odoo write gi\u00e1 tr\u1ecb v\u00e0o DB column l\u00fac create/write, kh\u00f4ng t\u00ednh l\u1ea1i khi read. Overhead ch\u1ec9 1 l\u1ea7n khi update field source, kh\u00f4ng c\u00f3 overhead khi list 10000 record.
Method create override g\u1ecdi self.env["ir.sequence"].next_by_code("dx.service.request") \u0111\u1ec3 l\u1ea5y reference number theo prefix SR/2026/00001. Sequence record \u0111\u01b0\u1ee3c declare \u1edf data/service_request_sequence.xml v\u1edbi flag noupdate="1". Install l\u1ea7n \u0111\u1ea7u t\u1ea1o sequence v\u1edbi counter b\u1eb1ng 1. Upgrade module l\u1ea7n sau kh\u00f4ng reset counter, gi\u1eef nguy\u00ean reference \u0111\u00e3 issue cho kh\u00e1ch h\u00e0ng.
Test th\u1ee7 c\u00f4ng
Sau khi module load th\u00e0nh c\u00f4ng, m\u00ecnh test qua UI l\u1ea7n l\u01b0\u1ee3t nh\u01b0 sau.
B\u01b0\u1edbc 1. Login Odoo v\u1edbi user admin. V\u00e0o Settings, r\u1ed3i Users & Companies, r\u1ed3i Users, ch\u1ecdn admin, tab Access Rights, tick checkbox DX Service Request / Manager. Save. Refresh trang.
B\u01b0\u1edbc 2. M\u1edf menu Service Requests \u1edf top bar (sequence=50 \u0111\u1eb7t gi\u1eefa Sales v\u00e0 Inventory). N\u1ebfu menu kh\u00f4ng hi\u1ec7n, ki\u1ec3m tra l\u1ea1i b\u01b0\u1edbc 1 (ch\u01b0a g\u00e1n group) ho\u1eb7c check log Odoo (XML view c\u00f3 th\u1ec3 fail parse, menu s\u1ebd kh\u00f4ng render).
B\u01b0\u1edbc 3. T\u1ea1o record m\u1edbi. Click New, ch\u1ecdn Customer (any partner trong res.partner), set Priority High, Save. Field Reference auto-fill SR/2026/00001. N\u1ebfu b\u1ea1n th\u1ea5y New thay v\u00ec reference \u0111\u00fang, sequence ch\u01b0a load (verify data/ order trong manifest).
B\u01b0\u1edbc 4. Test state transition. Click Confirm, state Draft chuy\u1ec3n sang Confirmed. Click Start Work, b\u00e1o l\u1ed7i "Assign a technician before starting" (validate ho\u1ea1t \u0111\u1ed9ng). Quay l\u1ea1i set Technician b\u1eb1ng admin, Start Work l\u1ea7n 2, state chuy\u1ec3n sang In Progress. Set actual_hours = 2.5, estimated_hours m\u1eb7c \u0111\u1ecbnh 1.0. Field overrun_hours auto-update th\u00e0nh 1.5 ngay sau khi blur kh\u1ecfi input.
B\u01b0\u1edbc 5. Verify \u1edf 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\u1ea1n th\u1ea5y row v\u1eeba t\u1ea1o v\u1edbi \u0111\u1ea7y \u0111\u1ee7 field. Verify r\u1eb1ng overrun_hours \u0111\u01b0\u1ee3c store th\u1eadt (c\u1ed9t t\u1ed3n t\u1ea1i trong table), kh\u00f4ng ph\u1ea3i compute on-read.
B\u01b0\u1edbc 6. Verify chatter. Refresh form view. B\u1ea1n th\u1ea5y log d\u1ea1ng "Status: Draft -> Confirmed -> In Progress" trong chatter (tracking=True tr\u00ean field state l\u00e0m vi\u1ec7c). N\u1ebfu chatter empty, c\u00f3 th\u1ec3 _inherit = ["mail.thread"] ch\u01b0a \u0111\u00fang ho\u1eb7c mixin ch\u01b0a load (depends thi\u1ebfu mail).
B\u01b0\u1edbc 7. Test security. T\u1ea1o user m\u1edbi: v\u00e0o Settings, Users, New, set group DX Service Request / User (kh\u00f4ng tick Manager). Login b\u1eb1ng user m\u1edbi qua incognito. V\u00e0o menu Service Requests. T\u1ea1o record OK. Nh\u01b0ng menu Action -> Delete b\u1ecb disable (perm_unlink=0). \u0110\u00e2y l\u00e0 b\u1eb1ng ch\u1ee9ng access rule apply \u0111\u00fang t\u1eeb CSV.
Install qua CLI thay v\u00ec 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\u1ea7n \u0111\u1ea7u. Sau \u0111\u00f3 iterate d\u00f9ng -u dx_service_request (upgrade) \u0111\u1ec3 reload XML m\u00e0 kh\u00f4ng drop DB. Log-level=debug gi\u00fap catch warning v\u1ec1 deprecated syntax s\u1edbm.
Repository
Full source at https://github.com/vytharion/odoo-tao-module-tuy-chinh.
- Commit 0 -> e5318e0 - Scaffold README c\u1ed9ng .gitignore
- Commit 1 -> 781e730 - Manifest c\u1ed9ng module skeleton (
__init__.py,__manifest__.py) - Commit 2 -> 4a9105b - Model fields c\u1ed9ng state machine actions (
models/service_request.py) - Commit 3 -> 8696420 - Security groups c\u1ed9ng access rules (
security/ir.model.access.csvc\u1ed9ngsecurity/security.xml) - Commit 4 -> 85e62fc - Views, menus, sequence (
views/service_request_views.xmlc\u1ed9ngdata/service_request_sequence.xml)
Tham kh\u1ea3o 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\u1ebft lu\u1eadn c\u1ed9ng B\u01b0\u1edbc ti\u1ebfp
Module 200-d\u00f2ng dx_service_request cover \u0111\u01b0\u1ee3c ph\u1ea7n l\u1edbn boilerplate c\u1ea7n thi\u1ebft khi build custom module Odoo 19. Manifest \u0111\u00fang schema. Model v\u1edbi typed fields v\u00e0 state machine c\u00f3 validate Python-side. Security 2 t\u1ea7ng (group c\u1ed9ng record rule). View d\u00f9ng expression-based attribute thay attrs c\u0169. Sequence auto-numbering v\u1edbi prefix theo n\u0103m. Chatter tracking qua mixin. Pattern n\u00e0y scale t\u1ed1t \u0111\u1ebfn module 5-10 model tr\u01b0\u1edbc khi c\u1ea7n t\u00e1ch th\u00e0nh nhi\u1ec1u addon ho\u1eb7c rewrite controllers/wizards ri\u00eang.
B\u01b0\u1edbc ti\u1ebfp b\u1ea1n c\u00f3 th\u1ec3 t\u1ef1 l\u00e0m \u0111\u1ec3 m\u1edf r\u1ed9ng module:
- Th\u00eam wizard
dx.service.request.assign.wizard\u0111\u1ec3 bulk-assign technician cho nhi\u1ec1u record c\u00f9ng l\u00fac. - \u0110\u1ea9y reports XLSX qua
report_xlsxmodule (c\u00e0i qua OCA repository). - T\u1ea1o REST endpoint
/api/v1/service-requestsqua controller c\u1ed9ng@http.route(auth='user', type='json')\u0111\u1ec3 mobile app fetch data. - Vi\u1ebft unit test v\u1edbi
tests/test_service_request.pyk\u1ebf th\u1eebaTransactionCase, test c\u00e1c action method quawith self.assertRaises(UserError).
Clone repo, copy v\u00e0o addons path, install l\u1ea7n \u0111\u1ea7u qua -i dx_service_request, s\u1eeda theo nhu c\u1ea7u business c\u1ee7a b\u1ea1n. Module n\u00e0y l\u00e0 starter template, kh\u00f4ng ph\u1ea3i production-ready end-product. Add th\u00eam reports, validation rules, cron jobs theo t\u1eebng use case c\u1ee5 th\u1ec3.