Odoo ERP Custom Module Development: A Developer's Guide for Indonesian Business Workflows

Photo by Unsplash

Photo by Unsplash
Odoo's modular architecture makes it one of the most extensible ERP platforms available, but adapting it to Indonesian regulatory requirements — NPWP tax IDs, NIK citizen numbers, and company-specific approval hierarchies — requires a solid understanding of the framework's ORM and inheritance system. This guide walks through building a complete custom module for a PT (Perseroan Terbatas) company's purchase approval workflow from directory scaffold to production deployment.
Every Odoo module is a Python package with a mandatory __manifest__.py file that declares the module's metadata, dependencies, and data files. A well-organized module separates concerns across models/, views/, security/, controllers/, and static/ directories. Keeping this structure consistent makes your module maintainable by other developers and compatible with Odoo's upgrade mechanism.
The manifest declares your module's name, version, author, license, and critically its depends list — which other Odoo apps must be installed for your module to function. Always pin the minimum Odoo version with 'version': '17.0.1.0.0' format. Including 'auto_install': False ensures the module only activates when manually installed, preventing unexpected behavior in production.
The security/ir.model.access.csv file defines which user groups can create, read, write, or delete records in your custom models. Record rules (ir.rule) provide row-level security, for example restricting a purchase request to only the creator's branch office. Always define security first — deploying a module without proper ACLs exposes all data to all internal users.
Use 'odoo scaffold module_name .' to generate the full directory skeleton automatically. This creates __manifest__.py, __init__.py, models/__init__.py, views/, and security/ with the correct imports, saving 10–15 minutes of boilerplate setup per module.
Odoo's ORM maps Python classes inheriting from models.Model directly to PostgreSQL tables, handling migrations automatically when you bump the module version. Fields are declared as class attributes using field types like fields.Char, fields.Many2one, and fields.One2many. Computed fields using @api.depends recalculate automatically when their dependencies change, keeping your UI reactive without manual triggers.
Indonesian tax law requires NPWP (Nomor Pokok Wajib Pajak) in the format XX.XXX.XXX.X-XXX.XXX for business transactions above certain thresholds. Using @api.constrains to validate this format at the ORM level ensures invalid data never reaches the database, regardless of whether a record is created via the web UI, XML-RPC, or a data import. Similarly, NIK (Nomor Induk Kependudukan) must be exactly 16 digits — a constraint worth enforcing for audit compliance.
# models/purchase_approval.py
from odoo import models, fields, api
from odoo.exceptions import ValidationError
import re
class PurchaseApproval(models.Model):
_name = 'custom.purchase.approval'
_description = 'Custom Purchase Approval for PT Companies'
_inherit = ['mail.thread', 'mail.activity.mixin']
name = fields.Char(string='Reference', required=True, copy=False,
readonly=True, default='New')
vendor_id = fields.Many2one('res.partner', string='Vendor', required=True)
npwp = fields.Char(string='NPWP Vendor', size=20,
help='Nomor Pokok Wajib Pajak (15-digit tax ID)')
nik_pic = fields.Char(string='NIK PIC', size=16,
help='Nomor Induk Kependudukan of responsible person')
amount_total = fields.Float(string='Total Amount (IDR)', required=True)
approval_level = fields.Selection([
('manager', 'Manager (< 50jt)'),
('director', 'Director (50jt – 500jt)'),
('board', 'Board of Directors (> 500jt)'),
], string='Required Approval Level', compute='_compute_approval_level',
store=True)
state = fields.Selection([
('draft', 'Draft'),
('submitted', 'Submitted'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='draft', tracking=True)
@api.depends('amount_total')
def _compute_approval_level(self):
for rec in self:
if rec.amount_total < 50_000_000:
rec.approval_level = 'manager'
elif rec.amount_total < 500_000_000:
rec.approval_level = 'director'
else:
rec.approval_level = 'board'
@api.constrains('npwp')
def _check_npwp(self):
for rec in self:
if rec.npwp and not re.match(r'^\d{2}\.\d{3}\.\d{3}\.\d-\d{3}\.\d{3}$',
rec.npwp):
raise ValidationError(
'Format NPWP tidak valid. Gunakan format: XX.XXX.XXX.X-XXX.XXX'
)
@api.model
def create(self, vals):
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'custom.purchase.approval') or 'New'
return super().create(vals)Indonesian PT companies typically require tiered purchase approvals: manager for amounts below Rp 50 million, director up to Rp 500 million, and board of directors above. Encoding this logic in a stored computed field (store=True) means the approval level is persisted to the database, enabling fast filtering and reporting without recomputing on every view load. The @api.depends decorator ensures the field recalculates whenever amount_total changes.
Views in Odoo are XML records stored in the ir.ui.view model. A form view defines the layout of individual records, a tree view shows the list, and a search view adds filter options to the list. All three are typically required for a new model to be fully usable. Reference field widgets like many2one, statusbar, and stat_button significantly improve UX with minimal extra code.
When inheriting from an existing model like purchase.order, you can inject new fields into the existing form view using an inherit_id pointing to the original view and xpath expressions to precisely target insertion points. Use position='after', position='before', or position='inside' to control placement. Avoid replacing entire view sections — target the smallest possible node to minimize upgrade conflicts.
Editing files inside the Odoo source tree (addons/account/, addons/purchase/, etc.) will be overwritten on every upgrade, destroying your changes and potentially breaking the entire installation. Always use _inherit to extend existing models and inherit_id in XML to extend existing views. This is non-negotiable for any production deployment that will ever receive security patches.
Odoo exposes both JSON-RPC and XML-RPC endpoints out of the box, but sometimes you need custom HTTP routes — for mobile apps, webhook receivers, or third-party integrations like e-invoice (e-Faktur) submission to DJP. Controllers inheriting from http.Controller use @http.route() decorators with auth='user' or auth='public' depending on the endpoint's access requirements.
Indonesian business documents require formatted reference numbers (e.g., PA/2026/05/0001 for purchase approvals). Define an ir.sequence record in your data XML with prefix and padding settings, then call self.env['ir.sequence'].next_by_code() in the model's create() override. Using 'New' as the default and replacing it on save is the Odoo convention that prevents sequence gaps from draft records that are discarded.
TransientModel (models.TransientModel) creates wizard forms that do not persist to the database long-term — Odoo auto-purges them after a configurable period. Use wizards for approval confirmation dialogs, bulk operations, or any multi-step action that requires user input before committing. Binding a wizard to an action via binding_model_id makes it appear as a contextual Action button on the relevant list view.
Run 'odoo-bin -d your_db -u your_module --test-enable --stop-after-init' in your CI pipeline to execute unit tests on every push. Odoo's test framework supports TransactionCase for isolated tests and tagged test suites, making it straightforward to automate regression testing for custom module logic.
A production Odoo module should include at minimum a smoke test that installs the module on a clean database and creates one record through the full workflow. Odoo's test framework supports both unit tests (TransactionCase) and tour tests (HttpCase) for UI automation. Integrating these into a GitLab CI or GitHub Actions pipeline catches regressions before they reach the production instance.
Package your module as a ZIP file for clients who do not have direct server access, or deploy via Git by adding your module directory to addons_path in odoo.conf. For production environments on Indonesian hosting providers like Biznet or IDCloudHost, use a dedicated odoo user, run the server behind Nginx with TLS, and store custom addons outside the Odoo source tree to survive upgrades cleanly.
Key terms in this article include models.Model, NPWP, _inherit, and computed field.