How the numbers are calculated
How Dead Stock & Inventory Ageing calculates what it reports, and why.
Every figure in this report is derived from data Odoo already holds. Nothing is stored, cached or generated overnight, so there is no state that can be stale and nothing to rebuild after an import.
The grain
One row per quant — Odoo's own unit of on-hand stock, which is a (product, location, lot, package, owner) combination. Matching Odoo's grain means the quantities in this report reconcile exactly with Inventory → Reporting → Stock, with no explaining to do.
Which rows appear
WHERE location.usage = 'internal' AND quant.quantity > 0- Internal locations only. Customer, supplier, production, inventory-loss, scrap and view locations are not stock you are holding. Transit belongs to neither end of a transfer, so it is excluded too.
- Positive quantities only. A quant that has been emptied is not stock; a negative quant is a counting error, and reporting it as dead stock would be misleading.
Days in Stock — ageing
NOW()::date - quant.in_date::datein_date is Odoo's own incoming date on the quant: when the stock currently sitting there arrived. This is what "how old is it" means for a shelf.
When stock of the same product arrives at different times, Odoo merges quants and keeps the oldest relevant incoming date, which is the conservative and correct behaviour for ageing.
Days Since Last Movement Out — deadness
NOW()::date - COALESCE(last_outgoing_date, quant.in_date)::datewhere last_outgoing_date is
MAX(stock_move_line.date)
WHERE product = this product
AND source location = this location
AND state = 'done'Only done move lines count; a reserved or draft move has not shipped anything. Only the source location matches, so a transfer into this location does not reset the clock — it did not sell anything.
When there is no outgoing history at all, the fallback is how long the stock has been sitting. That is the only honest answer available: a product that arrived yesterday and has never shipped is not a year dead, it is one day untested. Those rows are also flagged Never Shipped so you can separate them.
Age bands
Counted from Days in Stock, with the upper bound of each band exclusive:
| Band | Days |
|---|---|
| 0-30 days | 0 to 30 |
| 31-60 days | 31 to 60 |
| 61-90 days | 61 to 90 |
| 91-180 days | 91 to 180 |
| 181-365 days | 181 to 365 |
| Over a year | 366 and up |
The boundaries are defined once, in Python, and the SQL CASE is generated from that list — so the labels and the bands cannot drift apart. Every boundary day has a test.
Value
quantity × product.standard_price (for this quant's company)standard_price is Odoo's Cost field. It is company-dependent, stored in Odoo 18 as a JSON column keyed by company, and the view reads the key for the quant's own company — so a two-company database does not read one company's cost against another's stock. There is a test for exactly that.
The currency is the company's currency.
What this is not
This is not a stock-accounting valuation. It does not read stock.valuation.layer, and on products whose cost has changed it will differ from an FIFO or AVCO valuation report.
That is deliberate. This report answers "how much money is stuck in stock that is not moving", which is a purchasing and planning question, and current cost is the right basis for it. If you need a figure that ties to the general ledger, use Odoo's own valuation report — the two answer different questions and it is better to be clear about which one you are reading.
Products with no cost set show a value of zero rather than being hidden. A line you cannot value is still a line you may need to act on.
Freshness
The view is queried live. The ORM is told which tables it reads (_depends), so even a quant created in the same transaction is flushed to the database before the report is read. There is no refresh button because there is nothing to refresh.
Performance
The whole report is one SELECT with two lateral joins. The two that matter are indexed by Odoo's own schema: stock_quant.product_id, stock_quant.location_id and stock_move_line.product_id all carry indexes as shipped.
Because it is a view, PostgreSQL pushes your filters down into it: filtering to one warehouse does not compute the whole company first. Grouping and summing happen in the database, never in Python.