The Gap Nobody Closes
In a service business, billing is a two-step process: work gets delivered first, invoiced later. The gap between those two events is where revenue gets lost. A line sits in a confirmed sales order with qty_delivered = 2 and qty_invoiced = 0. Nobody flags it. The invoice never gets raised. At month end, someone notices the discrepancy in the accounts — or they don't.
The problem is not that Odoo lacks the underlying data. Every confirmed service line with delivered quantity has an explicit line_invoice_status of to invoice. The data is there. What is missing is a focused operational view that surfaces it directly, without noise, ordered by age.
Why Standard Views Fall Short
Odoo CE provides two reporting surfaces that seem relevant but neither fits the daily follow-up use case.
The Sales Order List
The standard Sales > Orders list works at sale.order header level. It can show you that an order has a status of "to invoice," but it cannot tell you which lines within that order are outstanding, how much has been delivered, or how much remains unbilled. An order with ten lines where nine are invoiced and one is missed looks identical in the list to an order where nothing has been invoiced. The information you need is at line level; the view is at order level.
Sales Analysis
The Sales > Reporting > Sales Analysis action does operate at line level via sale.report. But its default view is a bar graph, and its search panel is populated with every available dimension — date ranges, teams, campaigns, companies. It is a tool for analysis, not for triage. Opening it as a daily "what needs invoicing today" check requires activating the right filters every time, dismissing the graph, switching to list view, and ignoring the columns you do not need. That friction is enough to make the check inconsistent.
What the Report Shows
The module adds a dedicated action at Sales > Reporting > Worked Not Invoiced. It opens directly in list view, pre-filtered, ordered oldest-first — the format that makes a follow-up check fast.
| Column | Field | Notes |
|---|---|---|
| Date | date |
Order date; primary sort key ascending |
| SO | name |
Bold; clicking a row opens the underlying sale order |
| Customer | partner_id |
|
| Product | product_id |
|
| Qty Delivered | qty_delivered |
Summed in footer |
| Qty Invoiced | qty_invoiced |
Summed in footer |
| Qty To Invoice | qty_to_invoice |
Summed in footer |
| Unit Price | price_unit |
Monetary widget; useful for spotting zero-priced lines |
| Untaxed To Invoice | untaxed_amount_to_invoice |
Summed in footer; the actionable revenue figure |
| Salesperson | user_id |
Hidden by default; useful for grouping |
Three fields are present in the view definition but hidden from the column display: line_invoice_status, product_type, and currency_id. They are used by filters and the monetary widget respectively, but have no value as visible columns given the pre-applied filters.
The list also supports pivot and graph views for the rare cases where aggregation is needed, but the operational default is the list.
Extending sale.report the Right Way
What sale.report Is
sale.report is not a regular Odoo model backed by a writable table. It is a SQL VIEW — a read-only query assembled by Odoo at module install time from a set of composable Python methods. The view joins sale.order, sale.order.line, product.product, product.template, and several other tables, and exposes the result as if it were a flat table of order line data.
Because it is a SQL view, you cannot add a column by defining a fields.Char and writing to it. The column has to exist in the underlying SQL query. Odoo provides two hook methods specifically for this purpose:
_select_additional_fields()— returns a dict mapping field names to SQL expressions. Each entry becomes aSELECT … AS field_nameclause in the view query._group_by_sale()— returns a SQL string appended to theGROUP BYclause. Any non-aggregate expression added via_select_additional_fieldsmust also appear here.
The full implementation for adding product_type is twelve lines:
class SaleReport(models.Model):
_inherit = "sale.report"
product_type = fields.Selection(
selection=[
("consu", "Goods"),
("service", "Service"),
("combo", "Combo"),
],
string="Product Type",
readonly=True,
)
def _select_additional_fields(self):
fields_map = super()._select_additional_fields()
fields_map["product_type"] = "t.type"
return fields_map
def _group_by_sale(self):
return f"{super()._group_by_sale()}, t.type"
t is the alias Odoo assigns to product.template in the base sale.report query. The expression t.type maps directly to the product template's type column — the same field that distinguishes services from consumables and combo products.
Why Not a New Model?
Creating a separate reporting model with its own SQL view would duplicate the join logic from sale.report and drift out of sync whenever Odoo updates the base query. Extending the existing model keeps everything in one SQL view, and the two hook methods are the designated extension points — they exist precisely so modules can add columns without forking the base query.
sale.report regenerates its SQL view on every module update. The _select_additional_fields and _group_by_sale overrides participate in that regeneration automatically — the additional column is present in the view as long as the module is installed.
Domain, Context Defaults, and the Zero-Priced Case
Domain vs. Context Search Defaults
The action that opens the report uses both a domain and a set of context search defaults. They behave differently and serve different purposes.
The action domain is a hard constraint applied at the ORM level before anything reaches the view:
<field name="domain">[('state', '=', 'sale')]</field>
This restricts the report to confirmed sales orders only. Users cannot remove it from the search bar — it is not a filter, it is part of the action definition. Quotation lines and cancelled orders are invisible regardless of what filters are active.
Context search defaults activate named filters automatically when the action opens, but users can deactivate them:
<field name="context">{
'search_default_filter_worked': 1,
'search_default_filter_to_invoice_lines': 1,
'search_default_filter_service_lines': 1
}</field>
These correspond to three named filters in the search view: qty_delivered > 0, line_invoice_status = 'to invoice', and product_type = 'service'. They are pre-checked on load, but a user who needs to see all product types or all invoice statuses can deactivate them without leaving the view.
The distinction matters operationally: the domain defines what this report is (confirmed order lines); the context defaults define the recommended starting point for daily follow-up.
The Zero-Priced Case
Not every line that is worked and not invoiced represents a billing miss. Migrated support-wallet lines confirmed at 0.00 will appear in the report because their qty_delivered is non-zero and their line_invoice_status is to invoice — both conditions are technically true. They are just intentionally not billed.
Two quick filters handle this cleanly:
- Billable Only —
price_unit > 0— the recommended default for daily invoice follow-up. Hides zero-priced lines entirely. - Zero-Priced —
price_unit = 0— shows only zero-priced lines, useful for periodic audits of carry-over and migration data.
Neither is active by default. The intended production setup is to save a personal favorite with Billable Only active alongside the three pre-checked defaults. That favorite becomes the daily check: open it, scan the list, raise the invoices, close the tab.