Skip to content

Commit a507ca3

Browse files
committed
[ADD] sale_timesheet_costs_revenues: costs and revenues pivot report
Add a read-only pivot under Project > Reporting that shows, per project and period, the hours logged on time-and-material work, their cost, and the billable value of that time split into untaxed amount to invoice and untaxed amount invoiced. Data is exposed through a SQL view over the timesheet (analytic) lines, enriched with the linked sale order line. The billable value is the timesheet duration, converted into the sale order line's unit of measure, times its price less discount; it counts as to invoice until the timesheet is linked to an invoice, then as invoiced, so the figures update live as time is logged and billed. Inspired by the "Project Costs and Revenues" report available under Project > Reporting in Odoo 14.0 and earlier. Signed-off-by: Manuel Fombuena <mfombuena@innovara.tech>
1 parent fb6ee4a commit a507ca3

15 files changed

Lines changed: 1142 additions & 0 deletions
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
=================================
2+
Sale Timesheet Costs and Revenues
3+
=================================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:779d71c58807731bc55d839d6f18fa7a6e606d0ef78c55101468ce851c5d3123
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ftimesheet-lightgray.png?logo=github
20+
:target: https://github.com/OCA/timesheet/tree/18.0/sale_timesheet_costs_revenues
21+
:alt: OCA/timesheet
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/timesheet-18-0/timesheet-18-0-sale_timesheet_costs_revenues
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=18.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
A pivot report of **billed and unbilled timesheet value** for
32+
time-and-material projects, alongside the cost of the time logged.
33+
34+
For each project and period it shows the hours logged, their cost, and
35+
the billable value of that time, split into what is still **to invoice**
36+
and what has already been **invoiced**, so you can see how much
37+
delivered work is still waiting to be billed and whether it is
38+
profitable.
39+
40+
It is inspired by the *Project Costs and Revenues* report that was
41+
available under **Project → Reporting** in Odoo 14.0 and earlier, and
42+
removed in later versions.
43+
44+
**Table of contents**
45+
46+
.. contents::
47+
:local:
48+
49+
Usage
50+
=====
51+
52+
Columns
53+
-------
54+
55+
========================= ===========================================
56+
Column Meaning
57+
========================= ===========================================
58+
Timesheet Duration hours logged on the timesheet
59+
Timesheet Cost cost of that time (shown negative)
60+
Untaxed Amount to Invoice billable value of time **not yet** invoiced
61+
Untaxed Amount Invoiced billable value of time **already** invoiced
62+
========================= ===========================================
63+
64+
The billable value of a timesheet is its duration — converted into the
65+
sale order line's unit of measure — times the line's unit price, less
66+
the line discount. It counts as *to invoice* until the timesheet is
67+
linked to an invoice, after which it counts as *invoiced*.
68+
69+
Using the report
70+
----------------
71+
72+
The report opens under **Project → Reporting → Timesheet Costs and
73+
Revenues**, grouped by month, with the **To Invoice** filter on by
74+
default so you only see periods that still have unbilled time. Clear
75+
that filter to see everything, switch on **Invoiced** to review what has
76+
been billed, and use **Group By** to slice by project, customer, sale
77+
order, analytic account, product, and more.
78+
79+
Assumptions
80+
-----------
81+
82+
- This is a **time-and-material** report: revenue is only counted for
83+
sale order lines whose delivered quantity comes from timesheets.
84+
Fixed-price and milestone lines still contribute cost and hours, but
85+
no revenue.
86+
- A **single currency** is assumed — the sale price and timesheet cost
87+
are used as-is, with no conversion.
88+
- **Invoiced** is the same calculation as **to invoice**, flipped once a
89+
timesheet is linked to an invoice. It is not read back from the posted
90+
invoice line, so it can differ if a price changed or an invoice was
91+
edited afterwards.
92+
93+
Bug Tracker
94+
===========
95+
96+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/timesheet/issues>`_.
97+
In case of trouble, please check there if your issue has already been reported.
98+
If you spotted it first, help us to smash it by providing a detailed and welcomed
99+
`feedback <https://github.com/OCA/timesheet/issues/new?body=module:%20sale_timesheet_costs_revenues%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
100+
101+
Do not contact contributors directly about support or help with technical issues.
102+
103+
Credits
104+
=======
105+
106+
Authors
107+
-------
108+
109+
* Innovara
110+
111+
Contributors
112+
------------
113+
114+
- `Innovara <https://innovara.tech>`__:
115+
116+
- Manuel Fombuena <mfombuena@innovara.tech>
117+
118+
Maintainers
119+
-----------
120+
121+
This module is maintained by the OCA.
122+
123+
.. image:: https://odoo-community.org/logo.png
124+
:alt: Odoo Community Association
125+
:target: https://odoo-community.org
126+
127+
OCA, or the Odoo Community Association, is a nonprofit organization whose
128+
mission is to support the collaborative development of Odoo features and
129+
promote its widespread use.
130+
131+
.. |maintainer-innovara| image:: https://github.com/innovara.png?size=40px
132+
:target: https://github.com/innovara
133+
:alt: innovara
134+
135+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
136+
137+
|maintainer-innovara|
138+
139+
This module is part of the `OCA/timesheet <https://github.com/OCA/timesheet/tree/18.0/sale_timesheet_costs_revenues>`_ project on GitHub.
140+
141+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "Sale Timesheet Costs and Revenues",
3+
"version": "18.0.1.0.0",
4+
"development_status": "Beta",
5+
"summary": "Pivot report including billed and unbilled timesheet revenue and"
6+
" cost, by project and period",
7+
"category": "Sales",
8+
"author": "Innovara, Odoo Community Association (OCA)",
9+
"maintainers": ["innovara"],
10+
"license": "AGPL-3",
11+
"website": "https://github.com/OCA/timesheet",
12+
"depends": ["project", "hr_timesheet", "sale_timesheet"],
13+
"data": [
14+
"security/ir.model.access.csv",
15+
"views/sale_timesheet_costs_revenues_views.xml",
16+
],
17+
"installable": True,
18+
"application": False,
19+
"auto_install": False,
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import sale_timesheet_costs_revenues
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Copyright 2026 Innovara Ltd - Manuel Fombuena
2+
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
3+
4+
from psycopg2 import sql
5+
6+
from odoo import fields, models, tools
7+
8+
9+
class SaleTimesheetCostsRevenues(models.Model):
10+
"""Read-only reporting model backed by a SQL view over timesheet
11+
(analytic) lines, enriched with sale-order and project data. One row per
12+
timesheet line; the timesheet date drives the period grouping."""
13+
14+
_name = "sale.timesheet.costs.revenues"
15+
_description = "Timesheet Costs and Revenues"
16+
_auto = False # this model is a database VIEW, not a table
17+
_rec_name = "project_id"
18+
_order = "date desc"
19+
20+
# --- grouping dimensions (use as pivot rows/columns) ---
21+
project_id = fields.Many2one("project.project", readonly=True)
22+
account_id = fields.Many2one(
23+
"account.analytic.account", string="Analytic Account", readonly=True
24+
)
25+
partner_id = fields.Many2one("res.partner", string="Customer", readonly=True)
26+
project_user_id = fields.Many2one(
27+
"res.users", string="Project Manager", readonly=True
28+
)
29+
company_id = fields.Many2one("res.company", string="Project Company", readonly=True)
30+
currency_id = fields.Many2one(
31+
"res.currency", string="Project Currency", readonly=True
32+
)
33+
product_id = fields.Many2one("product.product", readonly=True)
34+
sale_order_id = fields.Many2one("sale.order", readonly=True)
35+
so_line_id = fields.Many2one(
36+
"sale.order.line", string="Sale Order Line", readonly=True
37+
)
38+
date = fields.Date(readonly=True)
39+
so_confirmation_date = fields.Datetime(
40+
string="Sales Order Confirmation Date", readonly=True
41+
)
42+
# extras not present in the original report, handy for drill-down:
43+
task_id = fields.Many2one("project.task", readonly=True)
44+
employee_id = fields.Many2one("hr.employee", readonly=True)
45+
46+
# --- measures ---
47+
timesheet_duration = fields.Float(readonly=True)
48+
timesheet_cost = fields.Monetary(readonly=True, currency_field="currency_id")
49+
amount_to_invoice = fields.Monetary(
50+
string="Untaxed Amount to Invoice", readonly=True, currency_field="currency_id"
51+
)
52+
amount_invoiced = fields.Monetary(
53+
string="Untaxed Amount Invoiced", readonly=True, currency_field="currency_id"
54+
)
55+
56+
def init(self):
57+
# The analytic-account column on account.analytic.line is named
58+
# `account_id`; guard it so the view still builds if a given install
59+
# has reworked analytic plans and renamed/removed it.
60+
self.env.cr.execute(
61+
"""
62+
SELECT 1 FROM information_schema.columns
63+
WHERE table_name = 'account_analytic_line'
64+
AND column_name = 'account_id'
65+
"""
66+
)
67+
account_col = "aal.account_id" if self.env.cr.fetchone() else "NULL::integer"
68+
69+
tools.drop_view_if_exists(self.env.cr, self._table)
70+
# Notes on the billable value:
71+
# - Only sale order lines billed on delivered timesheets count as
72+
# revenue (qty_delivered_method in timesheet/manual). Fixed-price and
73+
# milestone lines still contribute cost and hours, but no revenue,
74+
# since hours x price is meaningless for them.
75+
# - Timesheet hours (in the timesheet encoding UoM) are converted into
76+
# the sale order line's UoM before multiplying by its price, so a
77+
# per-day rate billed against hour-encoded timesheets is correct.
78+
# Conversion only applies within a UoM category; otherwise the raw
79+
# duration is used.
80+
# - Identifiers are composed with psycopg2.sql so the dynamic table
81+
# name and the (code-chosen) analytic-account column cannot be a
82+
# SQL-injection vector.
83+
self.env.cr.execute(
84+
sql.SQL(
85+
"""
86+
CREATE OR REPLACE VIEW {table} AS (
87+
SELECT
88+
aal.id AS id,
89+
aal.project_id AS project_id,
90+
{account_col} AS account_id,
91+
p.partner_id AS partner_id,
92+
p.user_id AS project_user_id,
93+
p.company_id AS company_id,
94+
c.currency_id AS currency_id,
95+
sol.product_id AS product_id,
96+
sol.order_id AS sale_order_id,
97+
aal.so_line AS so_line_id,
98+
aal.task_id AS task_id,
99+
aal.employee_id AS employee_id,
100+
aal.date AS date,
101+
so.date_order AS so_confirmation_date,
102+
aal.unit_amount AS timesheet_duration,
103+
aal.amount AS timesheet_cost,
104+
CASE
105+
WHEN aal.timesheet_invoice_id IS NULL
106+
AND aal.so_line IS NOT NULL
107+
AND sol.qty_delivered_method IN ('timesheet', 'manual')
108+
THEN aal.unit_amount
109+
* CASE
110+
WHEN ts_uom.category_id = sol_uom.category_id
111+
AND ts_uom.factor <> 0
112+
THEN sol_uom.factor / ts_uom.factor
113+
ELSE 1.0
114+
END
115+
* sol.price_unit
116+
* (1.0 - COALESCE(sol.discount, 0.0) / 100.0)
117+
ELSE 0.0
118+
END AS amount_to_invoice,
119+
CASE
120+
WHEN aal.timesheet_invoice_id IS NOT NULL
121+
AND aal.so_line IS NOT NULL
122+
AND sol.qty_delivered_method IN ('timesheet', 'manual')
123+
THEN aal.unit_amount
124+
* CASE
125+
WHEN ts_uom.category_id = sol_uom.category_id
126+
AND ts_uom.factor <> 0
127+
THEN sol_uom.factor / ts_uom.factor
128+
ELSE 1.0
129+
END
130+
* sol.price_unit
131+
* (1.0 - COALESCE(sol.discount, 0.0) / 100.0)
132+
ELSE 0.0
133+
END AS amount_invoiced
134+
FROM account_analytic_line aal
135+
JOIN project_project p
136+
ON p.id = aal.project_id
137+
LEFT JOIN res_company c
138+
ON c.id = COALESCE(p.company_id, aal.company_id)
139+
LEFT JOIN sale_order_line sol
140+
ON sol.id = aal.so_line
141+
LEFT JOIN sale_order so
142+
ON so.id = sol.order_id
143+
LEFT JOIN uom_uom ts_uom
144+
ON ts_uom.id = aal.product_uom_id
145+
LEFT JOIN uom_uom sol_uom
146+
ON sol_uom.id = sol.product_uom
147+
WHERE aal.project_id IS NOT NULL
148+
)
149+
"""
150+
).format(
151+
table=sql.Identifier(self._table),
152+
account_col=sql.SQL(account_col),
153+
)
154+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- [Innovara](https://innovara.tech):
2+
- Manuel Fombuena \<mfombuena@innovara.tech\>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
A pivot report of **billed and unbilled timesheet value** for time-and-material
2+
projects, alongside the cost of the time logged.
3+
4+
For each project and period it shows the hours logged, their cost, and the
5+
billable value of that time, split into what is still **to invoice** and what
6+
has already been **invoiced**, so you can see how much delivered work is still
7+
waiting to be billed and whether it is profitable.
8+
9+
It is inspired by the *Project Costs and Revenues* report that was available
10+
under **Project → Reporting** in Odoo 14.0 and earlier, and removed in later
11+
versions.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## Columns
2+
3+
| Column | Meaning |
4+
|---|---|
5+
| Timesheet Duration | hours logged on the timesheet |
6+
| Timesheet Cost | cost of that time (shown negative) |
7+
| Untaxed Amount to Invoice | billable value of time **not yet** invoiced |
8+
| Untaxed Amount Invoiced | billable value of time **already** invoiced |
9+
10+
The billable value of a timesheet is its duration — converted into the sale
11+
order line's unit of measure — times the line's unit price, less the line
12+
discount. It counts as *to invoice* until the timesheet is linked to an
13+
invoice, after which it counts as *invoiced*.
14+
15+
## Using the report
16+
17+
The report opens under **Project → Reporting → Timesheet Costs and Revenues**, grouped by
18+
month, with the **To Invoice** filter on by default so you only see periods that
19+
still have unbilled time. Clear that filter to see everything, switch on
20+
**Invoiced** to review what has been billed, and use **Group By** to slice by
21+
project, customer, sale order, analytic account, product, and more.
22+
23+
## Assumptions
24+
25+
- This is a **time-and-material** report: revenue is only counted for sale order
26+
lines whose delivered quantity comes from timesheets. Fixed-price and
27+
milestone lines still contribute cost and hours, but no revenue.
28+
- A **single currency** is assumed — the sale price and timesheet cost are used
29+
as-is, with no conversion.
30+
- **Invoiced** is the same calculation as **to invoice**, flipped once a
31+
timesheet is linked to an invoice. It is not read back from the posted invoice
32+
line, so it can differ if a price changed or an invoice was edited afterwards.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2+
access_sale_timesheet_costs_revenues_user,sale.timesheet.costs.revenues.user,model_sale_timesheet_costs_revenues,project.group_project_user,1,0,0,0
3+
access_sale_timesheet_costs_revenues_manager,sale.timesheet.costs.revenues.manager,model_sale_timesheet_costs_revenues,project.group_project_manager,1,0,0,0

0 commit comments

Comments
 (0)