"Can we just configure that?"
It gets asked in every scoping session, usually hopefully, and it usually gets answered with a feeling. It deserves better, because the question has a technically checkable answer.
Standard Odoo covers more construction requirements than most people assume. It also stops covering them at identifiable points, and those points are properties of the data model rather than judgements about difficulty. Below are five signals that a requirement has crossed the line — each one a structural fact you can check before anyone estimates anything.
Signal 1: you need the history of a value, not its current state
This is the one that catches most construction projects, and percent complete is where it shows up.
A field like this looks reasonable:
class ConstructionContract(models.Model):
_name = "construction.contract"
percent_complete = fields.Float(compute="_compute_percent_complete")
@api.depends("line_ids.completed_qty", "line_ids.contract_qty")
def _compute_percent_complete(self):
for contract in self:
total = sum(contract.line_ids.mapped("contract_qty"))
done = sum(contract.line_ids.mapped("completed_qty"))
contract.percent_complete = (done / total * 100) if total else 0.0
Two problems, and they are both structural rather than stylistic.
First, computed fields are not stored by default — they are calculated and returned when requested. Search and grouping are unavailable on them unless you write a custom search method. So the moment someone asks to group jobs by completion band, this field cannot do it.
Adding store=True fixes searching and grouping. It does not fix the second problem, which is worse: a stored field holds one value, the current one. Construction needs to know what percent complete was at the end of each billing period, because that is what was invoiced against and what the next invoice is measured from. Overwrite it monthly and the history is gone.
The tell is easy to check. If a stakeholder ever says "as at" — as at last month, as at the previous application, as at year end — a scalar field is the wrong shape regardless of how it is computed. You need a record per period:
class ConstructionProgressLine(models.Model):
_name = "construction.progress.line"
_order = "period_end desc"
contract_id = fields.Many2one("construction.contract", required=True, ondelete="cascade")
period_end = fields.Date(required=True, index=True)
percent_complete = fields.Float(required=True)
recorded_by = fields.Many2one("res.users", default=lambda self: self.env.user)
That is a new model. It is not configuration, and no amount of Studio gets you there — because the requirement is temporal, and the standard model has no time dimension on that value.
One related gotcha while you are in this territory: computed fields run with compute_sudo=True by default, meaning the computation executes with superuser privileges regardless of who is asking. If your computed value aggregates records the requesting user is not supposed to see, that is worth thinking about before it ships.
Signal 2: the fact belongs to the relationship, not to either record
A subcontractor works on several of your jobs. A job uses several subcontractors. The obvious modelling instinct is a many-to-many, and Odoo will happily give you one.
Then the requirements arrive. What is this sub's scope on this job? What is the awarded value on this job? What retention percentage applies here, which is different from the one on the job across town? When does their compliance for this specific site expire?
None of those belong to the subcontractor. None belong to the job. They belong to the pairing — and a many-to-many produces a bare join table with two foreign key columns and nowhere to put them.
The moment a relationship needs attributes, you need an explicit model for the relationship itself:
class ConstructionSubcontract(models.Model):
_name = "construction.subcontract"
_description = "Subcontractor engagement on a specific job"
job_id = fields.Many2one("construction.contract", required=True, ondelete="cascade")
partner_id = fields.Many2one("res.partner", string="Subcontractor", required=True)
scope = fields.Text()
awarded_value = fields.Monetary()
retention_percent = fields.Float()
compliance_expiry = fields.Date()
currency_id = fields.Many2one("res.currency", required=True)
This one is worth catching early because retrofitting it is genuinely painful. Data already entered against a many-to-many has no attributes to migrate, so somebody reconstructs them from contracts and email, by hand, for every live job.
The general test: if you catch yourself wanting to write a field name like retention_percent_for_this_job_only, the relationship needs to be a record.
If you want the functional context behind these requirements rather than the modelling argument, there is a non-technical overview of Odoo for contractors that covers the workflow side.
Signal 3: you need different state semantics than the ones shipped
Odoo's Project app looks like an obvious home for construction work, and for task management it often is. The boundary here is specific and easy to miss.
Task stages are configurable — they are project-specific and can be shared across projects that follow the same workflow. Task statuses are not. Odoo ships five fixed statuses — In Progress, Changes Requested, Approved, Canceled and Done — and they cannot be customised. There is also behaviour attached: Changes Requested and Approved clear automatically when a task moves to another Kanban stage, reverting to In Progress, while Done and Canceled persist.
That is fine for tasks. It is not a contractual state machine, and construction has several of those — a subcontract that moves through awarded, mobilised, in progress, complete and closed out, with rules about which transitions are legal and what each one triggers.
Trying to bend task status into that shape is the classic mistake. The status field will keep resetting itself underneath you because it was designed to, and you will end up writing overrides that fight the framework at every stage change.
Model your own state field on your own model instead, with explicit transitions:
class ConstructionSubcontract(models.Model):
_inherit = "construction.subcontract"
state = fields.Selection(
[("draft", "Draft"), ("awarded", "Awarded"), ("mobilised", "Mobilised"),
("in_progress", "In Progress"), ("complete", "Complete"), ("closed", "Closed Out")],
default="draft", required=True, tracking=True,
)
def action_award(self):
for rec in self:
if rec.state != "draft":
raise UserError(_("Only draft subcontracts can be awarded."))
if not rec.compliance_expiry or rec.compliance_expiry < fields.Date.today():
raise UserError(_("Cannot award: subcontractor compliance is missing or expired."))
rec.state = "awarded"
Leave the shipped task status alone and let it do the job it was built for.
Signal 4: the rule has to hold at the database, not in the form
Odoo gives you two places to enforce a rule, and the choice matters more in construction than in most domains because the data outlives the people who entered it.
@api.constrains runs Python validation after fields are written. It is flexible, it can look at related records, and it produces good error messages. It is also bypassable — direct SQL, a bad migration script, or a code path that writes with the constraint deferred will all go straight past it.
_sql_constraints pushes the rule into PostgreSQL, where nothing gets past it:
class ConstructionContract(models.Model):
_inherit = "construction.contract"
_sql_constraints = [
("job_number_uniq", "unique(company_id, job_number)",
"Job number must be unique within the company."),
("retention_range", "CHECK(retention_percent >= 0 AND retention_percent <= 100)",
"Retention percentage must be between 0 and 100."),
]
Use the database for anything that would corrupt your reporting if it were ever violated — uniqueness of job numbers, non-negative quantities, percentages within range. Use Python for rules that need context, like the compliance check in the example above.
If a requirement can only be expressed as a database constraint, that is a customization by definition. There is no configuration screen for a CHECK constraint.
Signal 5: the number has to be reconstructable, not just current
This is signal 1 generalised, and it is the one most worth internalising because it catches requirements before they are written down.
Ask of any number the business cares about: can we reproduce what this was on an arbitrary past date? If the answer is no and someone will eventually need it, the model is missing a dimension.
Cost committed as at each month end. Contract value before and after each variation. Which subcontractors were compliant on the day of an incident. Every one of those is a question the business will ask exactly once, at the worst possible moment, and a current-state model cannot answer any of them.
Odoo gives you some of this free. Fields with tracking=True write to the chatter, which is an audit trail and is fine for "who changed this". It is not a reporting structure — you cannot group or aggregate across it usefully. If the historical value needs to appear in a report, it needs to be a record, not a message.
Where standard genuinely holds
Worth saying plainly, because the signals above are easy to over-apply.
Multi-dimensional cost analysis, document management, purchasing workflows, approval routing, resource scheduling and most reporting are configuration problems in Odoo, not code problems. Reaching for a custom model when a configuration option exists is a decision to maintain something forever that Odoo would have maintained for you.
Two framework limits worth knowing before you assume something is impossible, though. Related fields cannot chain through Many2many or One2many in their dependency paths — that limitation catches people who try to reach across two hops and conclude the framework cannot do it, when a stored computed field with an explicit @api.depends usually can. And a non-stored computed field that "cannot be searched" often just needs store=True plus a correct depends list rather than a new model.
Check both before escalating.
The test to run before anyone opens an editor
For each requirement, answer four questions.
Does it need a value as at a past date? Does it need attributes on a relationship rather than on a record? Does it need a state machine other than the one shipped? Does it need a rule enforced below the application layer?
Any yes means you have left standard behind, and that is fine — it just means the item belongs in a build scope with an owner and a maintenance plan rather than in a configuration checklist. Scoping Odoo ERP customization honestly at this stage is considerably cheaper than discovering the boundary halfway through a rollout, when the data is already in and the retrofit is a migration.
All four answers no, and someone should go and find the configuration option, because it is probably there.
Top comments (0)