feat(reports): spread a plan across its own period, then re-bucket it

plannedValueExpr matched a target only when its period_type and
period_start equalled the report's bucket exactly, so a monthly plan
vanished the moment you viewed by quarter, by year, or by day. The plan
column simply went empty and the implement rate read 0%.

plannedRowsSql replaces it with a derived table: each target is spread
evenly over the days it covers, then re-gathered into whichever bucket
the report shows. Three monthly targets add up to a quarter exactly, a
daily view gets a thirty-first of the month, and a week straddling a
month boundary draws proportionally on both. The even spread is an
assumption and the only one available — a monthly figure says nothing
about which days inside it were busier — so PLAN_GRANULARITY_NOTE says so
in each report's description.

The share is clipped to the user's date filter as well as to the bucket,
or filtering to July and viewing by year would sit a whole year's plan
next to one month's work. Reports FULL OUTER JOIN it so a category that
was planned but never ran still publishes, at 0% — dropping the row would
hide a total miss, which is the one thing a plan-versus-actual table is
for.
This commit is contained in:
Nathnael
2026-08-20 11:36:42 +00:00
parent f28b6faf29
commit 05efdd5d54
7 changed files with 335 additions and 165 deletions

View File

@@ -9,7 +9,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
import { Yard } from '../rule-engine/entities/yard.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
import { yardOptions } from './revenue-classification';
import { resolvePeriod, yardOptions } from './revenue-classification';
/**
* The shared vocabulary and SQL behind every operations report — turnaround,
@@ -139,6 +139,15 @@ const labelCase = (keyExpr: string, options: ReportFilterOption[]): string =>
export const CARGO_CATEGORY_LABEL_EXPR = labelCase(CARGO_CATEGORY_EXPR, CARGO_CATEGORIES);
export const CONTAINER_CLASS_LABEL_EXPR = labelCase(CONTAINER_CLASS_EXPR, CONTAINER_CLASSES);
/**
* The same labelling applied to a key that is already a column — for reports
* that classify in a subquery and label in the wrapper.
*/
export const CATEGORY_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, CARGO_CATEGORIES);
export const CONTAINER_CLASS_LABEL_OF = (keyExpr: string): string =>
labelCase(keyExpr, CONTAINER_CLASSES);
// ---------------------------------------------------------------------------
// Standards
// ---------------------------------------------------------------------------
@@ -494,32 +503,92 @@ export function applyCategoryFilter(
}
/**
* The planned value for a group, as a correlated subselect against
* `operations_targets`.
* The planned rows for a metric, as a derived table.
*
* Correlated rather than joined because the period bucket is an expression, not
* a column: joining would need the same `date_trunc` repeated in the ON clause
* and in the GROUP BY, and a mismatch between the two silently drops targets.
* A target is a rate over its own period, not a lump at its start: the plan is
* spread evenly across the days it covers, then re-gathered into the report's
* buckets. One rule covers every direction — three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both months.
*
* Wrapped in MAX() so the correlated references sit inside an aggregate's
* argument. Postgres does not recognise a grouped EXPRESSION as grouped when it
* appears inside a subquery — `subquery uses ungrouped column` — and an
* aggregate argument is the one place ungrouped columns are legal. The value is
* constant within the group, so MAX() picks it exactly.
* The even spread is an assumption, and the only one available: a monthly
* figure carries no information about which days inside it were busier.
*
* The share is clipped to the user's date filter as well as to the bucket, so
* the plan always covers exactly the span the operated figure beside it covers.
* Without that, filtering to July and viewing by year would put a whole year's
* plan next to one month's work.
*
* The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report
* does exactly that — NagadDire Dawa is planned 2,106 t and operated none, and
* publishes as 0%. Dropping the row would hide a total miss, which is the one
* thing a plan-versus-actual table exists to show.
*
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter.
*/
export const plannedValueExpr = (
/**
* Appended to every plan-versus-actual report's description, because the
* re-bucketing rule is not guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
export const plannedRowsSql = (
metric: string,
dimension: string,
dimensionKeyExpr: string,
periodTypeExpr: string,
periodStartExpr: string,
): string => `MAX((
SELECT ot.planned_value FROM freight.operations_targets ot
params: Record<string, unknown>,
): string => {
const unit = resolvePeriod(params);
return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period,
ot.dimension_key AS plan_key,
ot.cargo_category AS plan_category,
SUM(ot.planned_value * (
GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO})
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM}))))
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0)
)) AS plan_value
FROM freight.operations_targets ot
CROSS JOIN LATERAL (
SELECT ot.period_start + CASE ot.period_type
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END AS ends
) t
CROSS JOIN LATERAL generate_series(
date_trunc('${unit.trunc}', ot.period_start::timestamptz),
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'),
INTERVAL '${unit.step}'
) AS g(bucket)
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND ot.dimension_key = ${dimensionKeyExpr}
AND ot.period_type = ${periodTypeExpr}
AND ot.period_start = (${periodStartExpr})::date
LIMIT 1
))`;
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM}
AND g.bucket < ${PLAN_TO}
GROUP BY 1, 2, 3
HAVING SUM(ot.planned_value) > 0`;
};
/** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = (
params: Record<string, unknown>,
): Record<string, unknown> => ({
planFrom: params.dateFrom ?? null,
planTo: params.dateTo ?? null,
});