feat(reports): list unbilled categories at zero in revenue-by-category

A category with no invoice lines in a period simply had no row, so a
category going quiet was indistinguishable from one that never existed,
and filtering to a category that was never billed returned an empty table.

The query is now three levels. The aggregate groups as before. A grid
crosses every period that saw revenue with every category the filter
allows, and LEFT JOINs the aggregate onto it so a missing combination
lands at zero. The wrapper does the display rounding and the labelling.

Two things had to move for that to be correct:

- The lag() window is now in the wrapper. A window function only sees the
  rows its own query level produces, so left on the aggregate it would
  skip a category's silent periods — billed in January and March, it
  would read March's prior as January and report flat growth.
- The category filter is off the aggregate and enforced by the grid's
  category list. Filtering the aggregate too would make the period axis
  depend on the selection, which is what left the table empty when the
  selected category had never been billed.

Periods come from the data, not generate_series over the date filter: a
twelve-month range over one billed month would otherwise publish eleven
months of pure zeros, and daily granularity would multiply that by thirty.

The Categories KPI is now "Categories with revenue" — a bare count of live
categories reads as a contradiction next to a table listing all fourteen.

EXPLAIN-validated against the dev database across seven filter shapes,
including the empty-array case (hence unnest(ARRAY[...]) over VALUES,
which is a syntax error when empty).

Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr
This commit is contained in:
ghost2023
2026-08-21 15:51:18 +03:00
parent 3ce58d4c57
commit 91c9c3e513

View File

@@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ReportContext, ReportDefinition } from '../report.types'; import { ReportContext, ReportDefinition } from '../report.types';
import { import {
AVG_PER_UNIT_EXPR, AVG_PER_UNIT_EXPR,
CATEGORY_LABEL_EXPR, CATEGORY_LABEL_OF,
CONTAINERS_EXPR, CONTAINERS_EXPR,
PERIOD_FILTER, PERIOD_FILTER,
REVENUE_CATEGORIES,
REVENUE_CATEGORY_EXPR, REVENUE_CATEGORY_EXPR,
REVENUE_FILTERS, REVENUE_FILTERS,
REVENUE_SUM, REVENUE_SUM,
@@ -21,19 +22,35 @@ import {
const REVENUE = 'SUM(il.amount)'; const REVENUE = 'SUM(il.amount)';
/** /**
* Previous period's revenue for the same category. * Previous period's revenue for the same category, over the zero-filled grid.
* *
* Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is * The window runs in the OUTER query, not alongside the aggregate. `lag()` only
* legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the * ever sees the rows its own query level produces, so computing it inside the
* ORDER BY must repeat their grouping expressions verbatim: ordering by the * aggregate would skip straight over a category's silent periods — a category
* inner `date_trunc` when the group key is the `to_char` wrapper fails, and * billed in January and March would read March's prior as January and report
* ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window * flat growth, hiding the month it earned nothing. Against the grid, February
* clause, silently producing an unordered partition. * exists at zero and both comparisons are real.
*/ */
const priorRevenue = (period: string): string => const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.period)';
`lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`;
const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); /**
* Every category the grid must carry, narrowed to the caller's selection.
*
* This is where the `categories` filter is enforced for the table — the grid
* lists only what the caller asked for, and the join back to the aggregate
* drops the rest. See {@link revenueByCategoryReport.query} for why the filter
* cannot also be left on the aggregate.
*
* Intersected in JS against the constant list rather than interpolating the
* request's own values: the grid spells its categories into the SQL text, and a
* user-supplied string must never land there. An unrecognised value simply
* drops out — the ledger would match nothing on it anyway.
*/
const gridCategoryKeys = (params: Record<string, unknown>): string[] => {
const selected = params.categories as string[] | null;
const all = REVENUE_CATEGORIES.map((c) => c.value);
return selected?.length ? all.filter((key) => selected.includes(key)) : all;
};
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return revenueLedgerQb(ctx); return revenueLedgerQb(ctx);
@@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = {
title: 'Revenue by Category', title: 'Revenue by Category',
description: description:
'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' +
'period-over-period growth. Growth compares against the previous period inside the ' + 'period-over-period growth. Every category is listed in every period that has revenue, ' +
'at zero when it was not billed, so a category going quiet reads as a drop rather than ' +
'a missing row. Growth compares against the previous period inside the ' +
'selected date range, so the earliest period always reads zero. ' + 'selected date range, so the earliest period always reads zero. ' +
'Multimodal means a named sea carrier is on the booking.', 'Multimodal means a named sea carrier is on the booking.',
group: 'Finance', group: 'Finance',
@@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = {
}, },
query(ctx) { query(ctx) {
const period = periodExpr(ctx.params); const period = periodExpr(ctx.params);
return baseQuery(ctx)
/*
* One row per period/category that actually has lines. Revenue stays
* unrounded here so the growth window below divides the same numbers the
* old single-level query did; the display rounding happens in the wrapper.
*
* The category filter is deliberately dropped from this aggregate and
* applied by the grid instead. The period axis is built from whatever
* periods this aggregate produces, so filtering here would make the axis
* depend on the selection — pick a category that was never billed and
* there would be no periods left to hang its zero rows on, which is
* exactly the empty table the grid exists to prevent. Unselected
* categories still cost nothing: the grid never lists them, so the join
* drops them.
*/
const agg = revenueLedgerQb({ ...ctx, params: { ...ctx.params, categories: null } })
.select(period, 'period') .select(period, 'period')
.addSelect(CATEGORY_LABEL_EXPR, 'category') .addSelect(REVENUE_CATEGORY_EXPR, 'category_key')
.addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') .addSelect(REVENUE, 'revenue')
.addSelect(`ROUND(${REVENUE})::float8`, 'revenue')
.addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct')
.addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons')
.addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu')
.addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers')
.addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avg_per_unit')
.addSelect(UNIT_LABEL_EXPR, 'unit') .addSelect(UNIT_LABEL_EXPR, 'unit')
.addSelect('COUNT(*)::int', 'lines') .addSelect('COUNT(*)::int', 'lines')
.groupBy(period) .groupBy(period)
.addGroupBy(REVENUE_CATEGORY_EXPR); .addGroupBy(REVENUE_CATEGORY_EXPR);
const categoryKeys = gridCategoryKeys(ctx.params)
.map((key) => `'${key}'`)
.join(', ');
/*
* The grid: every period that has revenue at all, crossed with every
* category the filter allows, then LEFT JOINed back to the aggregate so an
* unbilled category lands at zero instead of vanishing.
*
* Periods come from the data, NOT from generate_series over the date
* filter. A default twelve-month range over a database with one billed
* month would otherwise publish eleven months of pure zeros, and a daily
* granularity would multiply that by thirty. A period that saw no revenue
* in ANY category is still absent; a category that saw none in a live
* period is not — and because the aggregate above ignores the category
* filter, "live" means live for the business, not live for the selection.
*
* `unnest(ARRAY[...])` rather than `VALUES` because an empty array is legal
* and yields no rows — `VALUES` with nothing in it is a syntax error, and a
* filter naming only unrecognised categories produces exactly that list.
*/
const grid = `
WITH agg AS (${agg.getQuery()})
SELECT g.period,
g.category_key,
COALESCE(a.revenue, 0) AS revenue,
COALESCE(a.tons, 0) AS tons,
COALESCE(a.teu, 0) AS teu,
COALESCE(a.containers, 0) AS containers,
COALESCE(a.avg_per_unit, 0) AS avg_per_unit,
COALESCE(a.unit, '') AS unit,
COALESCE(a.lines, 0) AS lines
FROM (
SELECT p.period, c.category_key
FROM (SELECT DISTINCT period FROM agg) p
CROSS JOIN unnest(ARRAY[${categoryKeys}]::text[]) AS c(category_key)
) g
LEFT JOIN agg a ON a.period = g.period AND a.category_key = g.category_key`;
return ctx.ds
.createQueryBuilder()
.from(`(${grid})`, 'r')
.setParameters(agg.getParameters())
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('ROUND(r.revenue)::float8', 'revenue')
.addSelect(`ROUND(COALESCE(${PRIOR_REVENUE}, 0))::float8`, 'priorRevenue')
.addSelect(`COALESCE(${growthPctExpr('r.revenue', PRIOR_REVENUE)}, 0)`, 'growthPct')
.addSelect('r.tons::float8', 'tons')
.addSelect('r.teu::int', 'teu')
.addSelect('r.containers::int', 'containers')
.addSelect('r.avg_per_unit::float8', 'avgPerUnit')
.addSelect('r.unit', 'unit')
.addSelect('r.lines::int', 'lines');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
@@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = {
const currency = currencyOf(ctx.params); const currency = currencyOf(ctx.params);
return [ return [
{ label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency },
{ label: 'Categories', value: Number(row?.categories ?? 0) }, // "with revenue" is not decoration: the table now lists every category in
// every live period, so a bare "Categories: 6" next to fourteen rows
// would read as a contradiction rather than as the count of live ones.
{ label: 'Categories with revenue', value: Number(row?.categories ?? 0) },
// Always shown, even at zero: an audit report must never quietly drop money. // Always shown, even at zero: an audit report must never quietly drop money.
{ label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency },
]; ];