本文目前仅提供英文版本。翻译即将推出。
By the end of this recipe, your Odoo 19 instance will compute real-time project profitability — revenue minus all direct costs (employee time, contractor expenses, billable expenses, third-party costs) — with project-level burn rate, forecasted-to-completion margin, and red/yellow/green health flags. Skill required: Odoo administrator with project + analytic accounting context. Time required: 3 hours setup, 30 minutes per project review. ECOSIRE has built this for service businesses (agencies, consultancies, professional services) and we use this internally to track every client engagement, and the recipe below is the playbook.
The reason DIY project profitability fails: people look at sale order amount minus tracked timesheets and miss expenses, third-party costs, and unbilled work-in-progress. The recipe below uses Odoo's analytic accounts as the canonical aggregation layer and reads from every source (Sales, Timesheets, Expenses, Vendor Bills) in lockstep.
What you will need
- Odoo version: 17, 18, or 19 with
project,sale_timesheet,hr_timesheet,hr_expense, andaccountinstalled. - Analytic accounts enabled on the company.
- Project = Analytic Account: every project must have an associated analytic account.
- Time: 3 hours.
Step-by-step
1. Enforce project-to-analytic-account mapping
Make analytic_account_id required on project.project:
class Project(models.Model):
_inherit = 'project.project'
@api.constrains('analytic_account_id')
def _check_analytic_required(self):
for p in self.filtered(lambda x: not x.analytic_account_id and x.allow_billable):
raise ValidationError(f'Project {p.name} must have an analytic account')
Verification: trying to create a billable project without an analytic account raises an error.
2. Build the revenue view
Project revenue = sum of invoiced amounts on the project's analytic account:
CREATE OR REPLACE VIEW bi.project_revenue AS
SELECT
aa.id AS analytic_account_id,
aa.name AS project_name,
SUM(aml.price_subtotal) AS invoiced_revenue,
SUM(CASE WHEN am.state = 'posted' AND am.payment_state = 'paid'
THEN aml.price_subtotal ELSE 0 END) AS collected_revenue
FROM account_analytic_account aa
LEFT JOIN account_move_line aml ON aml.analytic_distribution ? aa.id::text
LEFT JOIN account_move am ON aml.move_id = am.id
WHERE am.move_type IN ('out_invoice', 'out_refund')
AND am.state IN ('posted')
GROUP BY aa.id, aa.name;
Verification: the invoiced revenue per project matches the project form's "Invoiced" KPI.
3. Build the cost view (multiple sources)
Costs aggregate from analytic lines. Each timesheet, expense, and vendor bill line creates an account.analytic.line automatically.
CREATE OR REPLACE VIEW bi.project_cost AS
SELECT
aa.id AS analytic_account_id,
aa.name AS project_name,
SUM(CASE WHEN aal.category = 'service' THEN ABS(aal.amount) ELSE 0 END) AS labor_cost,
SUM(CASE WHEN aal.category = 'expense' THEN ABS(aal.amount) ELSE 0 END) AS expense_cost,
SUM(CASE WHEN aal.category = 'other' THEN ABS(aal.amount) ELSE 0 END) AS other_cost,
SUM(ABS(aal.amount)) AS total_cost
FROM account_analytic_account aa
LEFT JOIN account_analytic_line aal ON aal.account_id = aa.id
WHERE aal.amount < 0 -- costs are negative; revenue is positive
GROUP BY aa.id, aa.name;
Verification: total_cost matches the project form's "Cost" KPI.
4. Compute profitability
CREATE OR REPLACE VIEW bi.project_profitability AS
SELECT
p.id AS project_id,
p.name AS project_name,
p.partner_id AS customer_id,
rp.name AS customer_name,
p.user_id AS pm_id,
p.date AS deadline,
rev.invoiced_revenue,
rev.collected_revenue,
cost.labor_cost,
cost.expense_cost,
cost.other_cost,
cost.total_cost,
rev.invoiced_revenue - cost.total_cost AS gross_margin,
CASE WHEN rev.invoiced_revenue > 0
THEN ROUND(100.0 * (rev.invoiced_revenue - cost.total_cost) / rev.invoiced_revenue, 1)
ELSE NULL END AS margin_pct,
p.allocated_hours AS budgeted_hours,
SUM(ts.unit_amount) AS actual_hours
FROM project_project p
LEFT JOIN res_partner rp ON p.partner_id = rp.id
LEFT JOIN bi.project_revenue rev ON p.analytic_account_id = rev.analytic_account_id
LEFT JOIN bi.project_cost cost ON p.analytic_account_id = cost.analytic_account_id
LEFT JOIN account_analytic_line ts ON ts.account_id = p.analytic_account_id AND ts.category = 'service'
GROUP BY p.id, rp.name, rev.invoiced_revenue, rev.collected_revenue,
cost.labor_cost, cost.expense_cost, cost.other_cost, cost.total_cost;
Verification: margin_pct is sensible — positive for profitable projects.
5. Add health flags
Red/yellow/green based on actual cost vs budgeted:
CASE
WHEN cost.total_cost > rev.invoiced_revenue * 0.95 THEN 'RED' -- using > 95% of revenue
WHEN cost.total_cost > rev.invoiced_revenue * 0.80 THEN 'YELLOW' -- > 80%
ELSE 'GREEN'
END AS health
Verification: a project that's 100% complete but only invoiced 50% of budget shows RED.
6. Build burn-rate forecast
For active projects, project the cost-to-completion based on current daily burn:
WITH burn_rate AS (
SELECT
aa.id,
AVG(daily_cost) AS avg_daily_burn
FROM (
SELECT account_id AS aa_id,
DATE_TRUNC('day', date)::date AS day,
SUM(ABS(amount)) AS daily_cost
FROM account_analytic_line
WHERE date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY 1, 2
) AS d
JOIN account_analytic_account aa ON d.aa_id = aa.id
GROUP BY aa.id
)
SELECT
p.name,
p.date - CURRENT_DATE AS days_remaining,
br.avg_daily_burn,
cost.total_cost AS cost_to_date,
cost.total_cost + (br.avg_daily_burn * (p.date - CURRENT_DATE)::int) AS forecasted_cost,
rev.invoiced_revenue - (cost.total_cost + (br.avg_daily_burn * (p.date - CURRENT_DATE)::int)) AS forecasted_margin
FROM project_project p
JOIN bi.project_revenue rev ON p.analytic_account_id = rev.analytic_account_id
JOIN bi.project_cost cost ON p.analytic_account_id = cost.analytic_account_id
LEFT JOIN burn_rate br ON p.analytic_account_id = br.id
WHERE p.date > CURRENT_DATE;
Verification: a project burning $1,000/day with 30 days remaining and $20,000 budget left shows forecasted overrun.
7. Build Power BI dashboard
Connect Power BI to bi.project_profitability. Create:
- Card: company-wide gross margin
- Bar chart: top 10 projects by absolute margin
- Heatmap: customer × project margin
- List: red-flagged projects sorted by overrun
8. Schedule weekly PM review
Cron emails the project list to the PMO every Monday with the worst-performing projects highlighted.
@api.model
def _cron_project_report(self):
self.env.cr.execute("""
SELECT project_name, customer_name, margin_pct, health
FROM bi.project_profitability
WHERE health IN ('RED', 'YELLOW')
ORDER BY margin_pct ASC
""")
rows = self.env.cr.fetchall()
body = '<h3>Projects needing attention:</h3><table border=1>'
for r in rows:
body += f'<tr><td>{r[0]}</td><td>{r[1]}</td><td>{r[2]}%</td><td>{r[3]}</td></tr>'
body += '</table>'
self.env['mail.mail'].create({
'subject': 'Weekly Project Health Report',
'body_html': body,
'email_to': '[email protected]',
}).send()
Verification: PMO receives the email on Monday.
Common mistakes
- Forgetting to track expenses against the project's analytic account. Expenses booked to a default analytic make project margins look unrealistically high.
- Using cost.total_cost without including unbilled WIP. WIP (work done but not yet invoiced) is hidden cost.
- Different time costing rates per employee. Some firms use a single hourly rate. We recommend per-employee costs (loaded with overhead) for accuracy.
- Computing margin on uninvoiced revenue. Recognized revenue per ASC 606 / IFRS 15 may differ from invoiced amount on long projects.
- Ignoring project taxes. Net-of-tax revenue is what flows to margin; gross overstates.
Going further
Effort-based revenue recognition: recognize revenue as work is performed, not as invoices issued. Critical for fixed-fee projects. Compute percentage-of-completion as cost-to-date divided by estimated-total-cost. Recognize that fraction of contract value as revenue. Booked invoices reconcile against recognized revenue at month-end via deferred-revenue / unbilled-receivables accounts.
Phase-based reporting: split the project into phases and track each phase's margin separately. Useful for "discovery" + "implementation" + "support" engagements where each phase has different economics.
Customer profitability: roll up project margins by customer to identify low-margin clients to renegotiate or fire. Sometimes a "big logo" customer is actually unprofitable when full cost is loaded.
Capacity planning: pair with timesheet data to forecast bench utilization 3 to 6 months out. Plot active project hours required vs available hours per skill. Reveals when to hire (or when to slow down sales).
Realization rate tracking: ratio of billed hours to worked hours. Realization < 90% indicates scope creep, free work, or under-billing. ECOSIRE clients run this weekly.
Project burnout indicators: pair profitability with team well-being signals (overtime hours, weekend logins, support ticket aggression). Projects can be financially profitable but burning out the team — sustainability matters too.
Predictive overrun risk: machine-learn from historical projects which characteristics (size, customer industry, team composition) correlate with overruns. Score new projects at kickoff so PMs can plan mitigations.
Resource optimization: which staff members deliver highest margin per hour? Schedule them on the most strategic projects. Avoid burning your A-players on low-margin support work.
Multi-currency project P&L: international projects with costs in one currency and revenue in another need FX revaluation. Pin the FX rate at project kickoff to avoid surprise FX losses on long projects.
Subcontractor margin transparency: when subcontracting parts of a project, the subcontractor's invoice flows through your AP and shows on the project's expense_cost. Track subcontractor margin as a separate KPI.
For full project profitability rollouts including effort-based revenue recognition and capacity planning, ECOSIRE Odoo customization builds the entire stack. Pair this with how to track sales rep commission.
Frequently Asked Questions
What if a project spans multiple sales orders?
Map each SO to the same analytic account. The aggregation logic above sums all of them.
How do I handle change orders?
Change orders are additional sale order lines on the same SO (or a new SO with the same project). The analytic account aggregation includes both.
What about non-billable internal projects?
They have zero invoiced revenue and a clear cost. Profit = -cost. These are R&D investments and need a different evaluation framework (NPV, payback) but the cost tracking pattern is identical.
How do I compare actuals to original budget?
Store budgeted_revenue and budgeted_cost on the project record. Compare against the views above. Variance = actual - budget.
For complex project ops including capacity forecasting and customer profitability, ECOSIRE Odoo customization builds the full stack. Or read how to track project profitability for the customer-economics view.
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
如何将自定义按钮添加到 Odoo 表单视图 (2026)
将自定义操作按钮添加到 Odoo 19 表单视图:Python 操作方法、视图继承、条件可见性、确认对话框。经过生产测试。
如何在没有 Studio 的情况下在 Odoo 中添加自定义字段 (2026)
通过 Odoo 19 中的自定义模块添加自定义字段:模型继承、视图扩展、计算字段、存储/非存储决策。代码优先,版本控制。
如何使用外部布局在 Odoo 中添加自定义报告
使用 web.external_layout 在 Odoo 19 中构建品牌 PDF 报告:QWeb 模板、paperformat、操作绑定。带有印刷徽标+页脚覆盖。