Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions sale_timesheet_costs_revenues/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
=================================
Sale Timesheet Costs and Revenues
=================================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:779d71c58807731bc55d839d6f18fa7a6e606d0ef78c55101468ce851c5d3123
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ftimesheet-lightgray.png?logo=github
:target: https://github.com/OCA/timesheet/tree/18.0/sale_timesheet_costs_revenues
:alt: OCA/timesheet
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/timesheet-18-0/timesheet-18-0-sale_timesheet_costs_revenues
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/timesheet&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

A pivot report of **billed and unbilled timesheet value** for
time-and-material projects, alongside the cost of the time logged.

For each project and period it shows the hours logged, their cost, and
the billable value of that time, split into what is still **to invoice**
and what has already been **invoiced**, so you can see how much
delivered work is still waiting to be billed and whether it is
profitable.

It is inspired by the *Project Costs and Revenues* report that was
available under **Project → Reporting** in Odoo 14.0 and earlier, and
removed in later versions.

**Table of contents**

.. contents::
:local:

Usage
=====

View
----

|Sale Timesheet Costs Revenues|

Columns
-------

========================= ===========================================
Column Meaning
========================= ===========================================
Timesheet Duration hours logged on the timesheet
Timesheet Cost cost of that time (shown negative)
Untaxed Amount to Invoice billable value of time **not yet** invoiced
Untaxed Amount Invoiced billable value of time **already** invoiced
========================= ===========================================

The billable value of a timesheet is its duration — converted into the
sale order line's unit of measure — times the line's unit price, less
the line discount. It counts as *to invoice* until the timesheet is
linked to an invoice, after which it counts as *invoiced*.

Using the report
----------------

The report opens under **Project → Reporting → Timesheet Costs and
Revenues**, grouped by month, with the **To Invoice** filter on by
default so you only see periods that still have unbilled time. Clear
that filter to see everything, switch on **Invoiced** to review what has
been billed, and use **Group By** to slice by project, customer, sale
order, analytic account, product, and more.

Assumptions
-----------

- This is a **time-and-material** report: revenue is only counted for
sale order lines whose delivered quantity comes from timesheets.
Fixed-price and milestone lines still contribute cost and hours, but
no revenue.
- A **single currency** is assumed — the sale price and timesheet cost
are used as-is, with no conversion.
- **Invoiced** is the same calculation as **to invoice**, flipped once a
timesheet is linked to an invoice. It is not read back from the posted
invoice line, so it can differ if a price changed or an invoice was
edited afterwards.

.. |Sale Timesheet Costs Revenues| image:: https://raw.githubusercontent.com/OCA/timesheet/18.0/sale_timesheet_costs_revenues/static/img/sale_timesheet_costs_revenues.png

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/timesheet/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`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**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Innovara

Contributors
------------

- `Innovara <https://innovara.tech>`__:

- Manuel Fombuena <mfombuena@innovara.tech>

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

.. |maintainer-innovara| image:: https://github.com/innovara.png?size=40px
:target: https://github.com/innovara
:alt: innovara

Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-innovara|

This module is part of the `OCA/timesheet <https://github.com/OCA/timesheet/tree/18.0/sale_timesheet_costs_revenues>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions sale_timesheet_costs_revenues/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions sale_timesheet_costs_revenues/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Sale Timesheet Costs and Revenues",
"version": "18.0.1.0.0",
"development_status": "Beta",
"summary": "Pivot report including billed and unbilled timesheet revenue and"
" cost, by project and period",
"category": "Sales",
"author": "Innovara, Odoo Community Association (OCA)",
"maintainers": ["innovara"],
"license": "AGPL-3",
"website": "https://github.com/OCA/timesheet",
"depends": ["project", "hr_timesheet", "sale_timesheet"],
"data": [
"security/ir.model.access.csv",
"views/sale_timesheet_costs_revenues_views.xml",
],
"installable": True,
"application": False,
"auto_install": False,
}
1 change: 1 addition & 0 deletions sale_timesheet_costs_revenues/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import sale_timesheet_costs_revenues
154 changes: 154 additions & 0 deletions sale_timesheet_costs_revenues/models/sale_timesheet_costs_revenues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Copyright 2026 Innovara Ltd - Manuel Fombuena
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html

from psycopg2 import sql

from odoo import fields, models, tools


class SaleTimesheetCostsRevenues(models.Model):
"""Read-only reporting model backed by a SQL view over timesheet
(analytic) lines, enriched with sale-order and project data. One row per
timesheet line; the timesheet date drives the period grouping."""

_name = "sale.timesheet.costs.revenues"
_description = "Timesheet Costs and Revenues"
_auto = False # this model is a database VIEW, not a table
_rec_name = "project_id"
_order = "date desc"

# --- grouping dimensions (use as pivot rows/columns) ---
project_id = fields.Many2one("project.project", readonly=True)
account_id = fields.Many2one(
"account.analytic.account", string="Analytic Account", readonly=True
)
partner_id = fields.Many2one("res.partner", string="Customer", readonly=True)
project_user_id = fields.Many2one(
"res.users", string="Project Manager", readonly=True
)
company_id = fields.Many2one("res.company", string="Project Company", readonly=True)
currency_id = fields.Many2one(
"res.currency", string="Project Currency", readonly=True
)
product_id = fields.Many2one("product.product", readonly=True)
sale_order_id = fields.Many2one("sale.order", readonly=True)
so_line_id = fields.Many2one(
"sale.order.line", string="Sale Order Line", readonly=True
)
date = fields.Date(readonly=True)
so_confirmation_date = fields.Datetime(
string="Sales Order Confirmation Date", readonly=True
)
# extras not present in the original report, handy for drill-down:
task_id = fields.Many2one("project.task", readonly=True)
employee_id = fields.Many2one("hr.employee", readonly=True)

# --- measures ---
timesheet_duration = fields.Float(readonly=True)
timesheet_cost = fields.Monetary(readonly=True, currency_field="currency_id")
amount_to_invoice = fields.Monetary(
string="Untaxed Amount to Invoice", readonly=True, currency_field="currency_id"
)
amount_invoiced = fields.Monetary(
string="Untaxed Amount Invoiced", readonly=True, currency_field="currency_id"
)

def init(self):
# The analytic-account column on account.analytic.line is named
# `account_id`; guard it so the view still builds if a given install
# has reworked analytic plans and renamed/removed it.
self.env.cr.execute(
"""
SELECT 1 FROM information_schema.columns
WHERE table_name = 'account_analytic_line'
AND column_name = 'account_id'
"""
)
account_col = "aal.account_id" if self.env.cr.fetchone() else "NULL::integer"

tools.drop_view_if_exists(self.env.cr, self._table)
# Notes on the billable value:
# - Only sale order lines billed on delivered timesheets count as
# revenue (qty_delivered_method in timesheet/manual). Fixed-price and
# milestone lines still contribute cost and hours, but no revenue,
# since hours x price is meaningless for them.
# - Timesheet hours (in the timesheet encoding UoM) are converted into
# the sale order line's UoM before multiplying by its price, so a
# per-day rate billed against hour-encoded timesheets is correct.
# Conversion only applies within a UoM category; otherwise the raw
# duration is used.
# - Identifiers are composed with psycopg2.sql so the dynamic table
# name and the (code-chosen) analytic-account column cannot be a
# SQL-injection vector.
self.env.cr.execute(
sql.SQL(
"""
CREATE OR REPLACE VIEW {table} AS (
SELECT
aal.id AS id,
aal.project_id AS project_id,
{account_col} AS account_id,
p.partner_id AS partner_id,
p.user_id AS project_user_id,
p.company_id AS company_id,
c.currency_id AS currency_id,
sol.product_id AS product_id,
sol.order_id AS sale_order_id,
aal.so_line AS so_line_id,
aal.task_id AS task_id,
aal.employee_id AS employee_id,
aal.date AS date,
so.date_order AS so_confirmation_date,
aal.unit_amount AS timesheet_duration,
aal.amount AS timesheet_cost,
CASE
WHEN aal.timesheet_invoice_id IS NULL
AND aal.so_line IS NOT NULL
AND sol.qty_delivered_method IN ('timesheet', 'manual')
THEN aal.unit_amount
* CASE
WHEN ts_uom.category_id = sol_uom.category_id
AND ts_uom.factor <> 0
THEN sol_uom.factor / ts_uom.factor
ELSE 1.0
END
* sol.price_unit
* (1.0 - COALESCE(sol.discount, 0.0) / 100.0)
ELSE 0.0
END AS amount_to_invoice,
CASE
WHEN aal.timesheet_invoice_id IS NOT NULL
AND aal.so_line IS NOT NULL
AND sol.qty_delivered_method IN ('timesheet', 'manual')
THEN aal.unit_amount
* CASE
WHEN ts_uom.category_id = sol_uom.category_id
AND ts_uom.factor <> 0
THEN sol_uom.factor / ts_uom.factor
ELSE 1.0
END
* sol.price_unit
* (1.0 - COALESCE(sol.discount, 0.0) / 100.0)
ELSE 0.0
END AS amount_invoiced
FROM account_analytic_line aal
JOIN project_project p
ON p.id = aal.project_id
LEFT JOIN res_company c
ON c.id = COALESCE(p.company_id, aal.company_id)
LEFT JOIN sale_order_line sol
ON sol.id = aal.so_line
LEFT JOIN sale_order so
ON so.id = sol.order_id
LEFT JOIN uom_uom ts_uom
ON ts_uom.id = aal.product_uom_id
LEFT JOIN uom_uom sol_uom
ON sol_uom.id = sol.product_uom
WHERE aal.project_id IS NOT NULL
)
"""
).format(
table=sql.Identifier(self._table),
account_col=sql.SQL(account_col),
)
)
3 changes: 3 additions & 0 deletions sale_timesheet_costs_revenues/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
2 changes: 2 additions & 0 deletions sale_timesheet_costs_revenues/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- [Innovara](https://innovara.tech):
- Manuel Fombuena \<mfombuena@innovara.tech\>
11 changes: 11 additions & 0 deletions sale_timesheet_costs_revenues/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
A pivot report of **billed and unbilled timesheet value** for time-and-material
projects, alongside the cost of the time logged.

For each project and period it shows the hours logged, their cost, and the
billable value of that time, split into what is still **to invoice** and what
has already been **invoiced**, so you can see how much delivered work is still
waiting to be billed and whether it is profitable.

It is inspired by the *Project Costs and Revenues* report that was available
under **Project → Reporting** in Odoo 14.0 and earlier, and removed in later
versions.
36 changes: 36 additions & 0 deletions sale_timesheet_costs_revenues/readme/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## View

![Sale Timesheet Costs Revenues](../static/img/sale_timesheet_costs_revenues.png)

## Columns

| Column | Meaning |
|---|---|
| Timesheet Duration | hours logged on the timesheet |
| Timesheet Cost | cost of that time (shown negative) |
| Untaxed Amount to Invoice | billable value of time **not yet** invoiced |
| Untaxed Amount Invoiced | billable value of time **already** invoiced |

The billable value of a timesheet is its duration — converted into the sale
order line's unit of measure — times the line's unit price, less the line
discount. It counts as *to invoice* until the timesheet is linked to an
invoice, after which it counts as *invoiced*.

## Using the report

The report opens under **Project → Reporting → Timesheet Costs and Revenues**, grouped by
month, with the **To Invoice** filter on by default so you only see periods that
still have unbilled time. Clear that filter to see everything, switch on
**Invoiced** to review what has been billed, and use **Group By** to slice by
project, customer, sale order, analytic account, product, and more.

## Assumptions

- This is a **time-and-material** report: revenue is only counted for sale order
lines whose delivered quantity comes from timesheets. Fixed-price and
milestone lines still contribute cost and hours, but no revenue.
- A **single currency** is assumed — the sale price and timesheet cost are used
as-is, with no conversion.
- **Invoiced** is the same calculation as **to invoice**, flipped once a
timesheet is linked to an invoice. It is not read back from the posted invoice
line, so it can differ if a price changed or an invoice was edited afterwards.
3 changes: 3 additions & 0 deletions sale_timesheet_costs_revenues/security/ir.model.access.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sale_timesheet_costs_revenues_user,sale.timesheet.costs.revenues.user,model_sale_timesheet_costs_revenues,project.group_project_user,1,0,0,0
access_sale_timesheet_costs_revenues_manager,sale.timesheet.costs.revenues.manager,model_sale_timesheet_costs_revenues,project.group_project_manager,1,0,0,0
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading