feat(reports): cascade an unmet plan onto the periods that remain

A target is a quota, not a flat allowance. The Plan column spread it evenly
and kept asking for the same twelfth of a yearly figure no matter how far
behind the year had fallen, so the one number operations actually needs —
what must move per month for the rest of the year — was nowhere on the
page.

Plan now keeps its meaning and a Required column sits beside it. Plan is
the committed spread and never moves, which is the whole reason it stays:
Implement Rate is measured against it, so a month that missed still reads
as a month that missed. Required is the same target read as a quota — at
each bucket, whatever is still outstanding spread across the time still
left. A 1,200 t year 20% met by June asks 140 t of June and 960 t of
December, which is 1,200 less the 240 delivered. Over-delivery clamps to
zero rather than going negative.

Attainment is deliberately measured with the user's date bounds stripped
(attainmentCtx) and every other filter left in place. Reusing the report's
own filtered aggregate would make a July-only view read year-to-date as
nothing delivered and demand the entire year's tonnage from one month —
the failure would look like a plausible number, not an error.

Granularity gains half-year, nine-month and 90-day. Postgres has no
date_trunc for any of them, so PERIOD_UNITS entries became builders rather
than fragments to interpolate, and all eight blocks anchor to the calendar
year. Nine does not divide twelve and 90 does not divide 365: a nine-month
year is Jan-Sep plus a short Oct-Dec, and the fourth 90-day block absorbs
the remainder at 95 days. That last one is a choice — uncapped floor
division opens a five-day stub bucket every December, which is noise
rather than a period.

Two consequences of the shared unit table, both handled here:

- plannedRowsSql now generates a day at a time and groups, instead of
  stepping by the bucket width. The ragged blocks restart each January, so
  stepping 90 days from January 1st walks off the anchor in the second
  year. Day grain also gets partial-bucket overlap for free, at the same
  sub-day precision the old clipping had.
- nextPeriodOrdinalExpr asks the unit for its next block start rather than
  adding its own step. revenue-by-period evaluates a regression there, and
  a ragged unit's final block is shorter than its nominal width, so + step
  would land past the next block and forecast at the wrong x.

Verified against Postgres 16 with the entities synchronised into it: all
272 report/granularity combinations in the registry EXPLAIN clean, and the
1,200 t drill-down sums back to 1,200 at every one of the eight grains.
This commit is contained in:
ghost2023
2026-08-21 17:40:35 +03:00
parent c8b44d4d04
commit 260d5a1590
7 changed files with 395 additions and 96 deletions

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' }, { key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' },
@@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addGroupBy(originationExpr(params, 'code')) .addGroupBy(originationExpr(params, 'code'))
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade, keyed the way a station plan is: per station
// AND per cargo type. Unfiltered by date, so a mid-year view still knows
// what the station has already hauled against its target.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, params), 'bucket')
.addSelect(stationCode, 'act_key')
.addSelect(CARGO_CATEGORY_EXPR, 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, params))
.addGroupBy(stationCode)
.addGroupBy(CARGO_CATEGORY_EXPR);
// A station plan is keyed on station AND cargo type, so the join needs // A station plan is keyed on station AND cargo type, so the join needs
// both. Full outer, so a station-and-cargo line that was planned and never // both. Full outer, so a station-and-cargo line that was planned and never
// ran still reports its miss — the OCC report is full of those. // ran still reports its miss — the OCC report is full of those.
@@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'station',
params,
attained.getQuery(),
)}) p
ON p.period = o.period ON p.period = o.period
AND p.plan_key = o.station_code AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`; AND p.plan_category = o.category_key`;
@@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = {
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect('r.station', 'station') .addSelect('r.station', 'station')
.addSelect('r.origination', 'origination') .addSelect('r.origination', 'origination')
@@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true },
@@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// What the cascade measures attainment from: the same tonnage, over the
// target's whole period rather than the user's date window. Bucketed on the
// block start, not the label, so it joins the plan on a real timestamp.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// Full outer join so a planned cargo category that moved nothing still // Full outer join so a planned cargo category that moved nothing still
// reports its miss instead of disappearing from the table. // reports its miss instead of disappearing from the table.
const combined = ` const combined = `
@@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.charged_tons::float8', 'chargedTons')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')

View File

@@ -10,6 +10,7 @@ import {
OPERATIONS_FILTERS, OPERATIONS_FILTERS,
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -60,6 +61,7 @@ export const teuPerformanceReport: ReportDefinition = {
{ key: "containers40", label: "40ft", type: "number", sortable: true }, { key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, { key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: "plan", label: "Plan", type: "number" }, { key: "plan", label: "Plan", type: "number" },
{ key: "planRequired", label: "Required", type: "number" },
{ key: "implementRate", label: "Implement rate", type: "percent" }, { key: "implementRate", label: "Implement rate", type: "percent" },
], ],
defaultSort: { key: "operated", dir: "DESC" }, defaultSort: { key: "operated", dir: "DESC" },
@@ -75,6 +77,16 @@ export const teuPerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR); .addGroupBy(CONTAINER_CLASS_EXPR);
// Attainment for the cascade: TEU across the target's whole period, so a
// mid-year view does not read as "nothing shipped yet".
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), "bucket")
.addSelect(CONTAINER_CLASS_EXPR, "act_key")
.addSelect("NULL::varchar", "act_category")
.addSelect(TEU_EXPR, "actual")
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CONTAINER_CLASS_EXPR);
// Full outer join so a planned container class that never moved still // Full outer join so a planned container class that never moved still
// reports, at zero rather than vanishing. // reports, at zero rather than vanishing.
const combined = ` const combined = `
@@ -83,15 +95,25 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40, COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql("TEU", "container_class", ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
"TEU",
"container_class",
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.class_key`; ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, "r") .from(`(${combined})`, "r")
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select("r.period", "period") .select("r.period", "period")
.addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect("r.class_key", "containerClassKey") .addSelect("r.class_key", "containerClassKey")
@@ -99,6 +121,7 @@ export const teuPerformanceReport: ReportDefinition = {
.addSelect("r.containers40::int", "containers40") .addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated") .addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan") .addSelect("r.plan::float8", "plan")
.addSelect("r.plan_required::float8", "planRequired")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate"); .addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
}, },
async summary(ctx) { async summary(ctx) {

View File

@@ -13,6 +13,7 @@ import {
TRAINSETS_EXPR, TRAINSETS_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' }, { key: 'plan', label: 'Plan', type: 'number' },
{ key: 'planRequired', label: 'Required', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
], ],
defaultSort: { key: 'operated', dir: 'DESC' }, defaultSort: { key: 'operated', dir: 'DESC' },
@@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade: the same trainset measure across the target's
// whole period, not just the window the viewer is looking at.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(TRAINSETS_EXPR, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// FULL OUTER JOIN so a category that was planned but never ran still shows, // FULL OUTER JOIN so a category that was planned but never ran still shows,
// at zero — TypeORM's builder has no full-outer join, hence the raw text. // at zero — TypeORM's builder has no full-outer join, hence the raw text.
const combined = ` const combined = `
@@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = {
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'TRAINSET',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
@@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
}, },
async summary(ctx) { async summary(ctx) {

View File

@@ -503,21 +503,68 @@ export function applyCategoryFilter(
} }
/** /**
* The planned rows for a metric, as a derived table. * Appended to every plan-versus-actual report's description, because neither
* the re-bucketing nor the catch-up rule is 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. ' +
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further.';
/**
* 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)";
/**
* How long one target's period runs. A target's span is exact — 90 days is 90
* days — and need not line up with the ragged year-end display blocks the
* `nine_month` and `ninety_day` granularities produce. The spread below is
* proportional, so partial overlap resolves correctly either way.
*/
const TARGET_SPAN = `CASE ot.period_type
WHEN 'day' THEN INTERVAL '1 day'
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'half_year' THEN INTERVAL '6 months'
WHEN 'nine_month' THEN INTERVAL '9 months'
WHEN 'ninety_day' THEN INTERVAL '90 days'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END`;
/**
* The planned rows for a metric, as a derived table: one row per bucket per
* planned key, carrying both a committed and a required figure.
* *
* A target is a rate over its own period, not a lump at its start: the plan is * **Plan** — a target is a rate over its own period, not a lump at its start.
* spread evenly across the days it covers, then re-gathered into the report's * The committed value is spread evenly across the days it covers and
* buckets. One rule covers every direction — three monthly targets add up to a * re-gathered into the report's buckets, so three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week * quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both months. * straddling a month boundary draws proportionally on both. The even spread is
* an assumption, and the only one available: a monthly figure carries no
* information about which days inside it were busier. This number never moves —
* Implement Rate is measured against it, so a month that missed keeps reading
* as a month that missed.
* *
* The even spread is an assumption, and the only one available: a monthly * **Required** — the same target read as a quota. At each bucket, whatever is
* figure carries no information about which days inside it were busier. * still outstanding (committed minus everything delivered in earlier buckets)
* is spread across the time still left in the period. A year 20% met at the
* halfway mark asks the remaining months for the other 80%. Over-delivery
* clamps to zero rather than going negative: a met quota requires nothing more.
* *
* The share is clipped to the user's date filter as well as to the bucket, so * `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* the plan always covers exactly the span the operated figure beside it covers. * be built **without the user's date bounds** — see {@link attainmentCtx}.
* Without that, filtering to July and viewing by year would put a whole year's * Attainment is a fact about the target's whole period; measuring it through
* plan next to one month's work. * the report's date filter would read a mid-year view as "nothing delivered
* yet" and demand the entire year's work from one month.
* *
* The reports FULL OUTER JOIN this to their operated aggregate so a category * 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 * that was planned but never ran still appears, at zero. The OCC monthly report
@@ -528,62 +575,96 @@ export function applyCategoryFilter(
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter. * with {@link plannedRowsParams} — they come from the user's date filter.
*/ */
/**
* 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 = ( export const plannedRowsSql = (
metric: string, metric: string,
dimension: string, dimension: string,
params: Record<string, unknown>, params: Record<string, unknown>,
actualsSql: string,
): string => { ): string => {
const unit = resolvePeriod(params); const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return ` return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period, WITH tgt AS (
ot.dimension_key AS plan_key, SELECT ot.id,
ot.cargo_category AS plan_category, ot.dimension_key,
SUM(ot.planned_value * ( ot.cargo_category,
GREATEST(0, EXTRACT(EPOCH FROM ( ot.planned_value,
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) ot.period_start::timestamptz AS starts,
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) FROM freight.operations_targets ot
)) AS plan_value WHERE ot.deleted_at IS NULL
FROM freight.operations_targets ot AND ot.metric = '${metric}'
CROSS JOIN LATERAL ( AND ot.dimension = '${dimension}'
SELECT ot.period_start + CASE ot.period_type AND ot.planned_value > 0
WHEN 'week' THEN INTERVAL '7 days' ),
WHEN 'month' THEN INTERVAL '1 month' -- One row per target per bucket. Generated a day at a time rather than a
WHEN 'quarter' THEN INTERVAL '3 months' -- bucket at a time: the ragged units restart their blocks each January, so
WHEN 'year' THEN INTERVAL '1 year' -- stepping by the unit's own width walks off the anchor in the second year.
ELSE INTERVAL '1 day' -- Day grain also makes a bucket that only partly overlaps the target fall out
END AS ends -- for free, at the same sub-day precision the clipping used before.
) t spread AS (
CROSS JOIN LATERAL generate_series( SELECT t.id,
date_trunc('${unit.trunc}', ot.period_start::timestamptz), t.dimension_key,
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), t.cargo_category,
INTERVAL '${unit.step}' t.planned_value,
) AS g(bucket) EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
WHERE ot.deleted_at IS NULL ${bucketOf} AS bucket,
AND ot.metric = '${metric}' SUM(GREATEST(0, EXTRACT(EPOCH FROM (
AND ot.dimension = '${dimension}' LEAST(d.day + INTERVAL '1 day', t.ends)
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} - GREATEST(d.day, t.starts))))) AS secs_full,
AND g.bucket < ${PLAN_TO} SUM(GREATEST(0, EXTRACT(EPOCH FROM (
GROUP BY 1, 2, 3 LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
HAVING SUM(ot.planned_value) > 0`; - GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in
FROM tgt t
CROSS JOIN LATERAL generate_series(
date_trunc('day', t.starts),
t.ends - INTERVAL '1 microsecond',
INTERVAL '1 day'
) AS d(day)
GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value,
t.starts, t.ends, ${bucketOf}
),
-- secs_before and actual_before are strictly-preceding running sums, so a
-- bucket's requirement is decided by what happened before it, never by its
-- own result. The frame is spelled out rather than defaulted: the default
-- RANGE frame would fold peer rows into the current one.
cascaded AS (
SELECT s.*,
COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before,
COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before
FROM spread s
LEFT JOIN (${actualsSql}) a
ON a.bucket = s.bucket
AND a.act_key = s.dimension_key
AND a.act_category IS NOT DISTINCT FROM s.cargo_category
WINDOW prior AS (
PARTITION BY s.id ORDER BY s.bucket
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
)
SELECT ${unit.labelOn('c.bucket')} AS period,
c.dimension_key AS plan_key,
c.cargo_category AS plan_category,
SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value,
SUM(GREATEST(0, c.planned_value - c.actual_before)
* c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required
FROM cascaded c
WHERE c.secs_in > 0
GROUP BY 1, 2, 3`;
}; };
/**
* The report's own ledger with the user's date bounds removed, for the
* attainment series {@link plannedRowsSql} cascades from. Every other filter
* stays applied, so the catch-up figure is measured on the same population as
* the `operated` column it sits beside.
*/
export const attainmentCtx = (ctx: ReportContext): ReportContext => ({
...ctx,
params: { ...ctx.params, dateFrom: null, dateTo: null },
});
/** The bindings {@link plannedRowsSql} expects. */ /** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = ( export const plannedRowsParams = (
params: Record<string, unknown>, params: Record<string, unknown>,

View File

@@ -86,17 +86,54 @@ describe('revenue classification', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'. // Anything unrecognised — including an injection attempt — becomes 'month'.
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( const injection = "day'); DROP TABLE freight.invoices; --";
"date_trunc('month'", expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
); expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'"); expect(periodExpr({})).toContain("date_trunc('month'");
}); });
it('offers exactly the period units the expression understands', () => { it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered.length).toBe(5); expect(offered).toEqual([
for (const unit of offered) { 'day',
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); 'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
]);
// Every offered unit resolves to its own expression rather than silently
// falling through to the month default — which is what a missing entry or a
// typo'd key would look like.
const expressions = offered.map((unit) => periodExpr({ period: unit }));
expect(new Set(expressions).size).toBe(offered.length);
});
/**
* Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are
* offset arithmetic anchored to January 1st. These pin the anchor: they are
* the SQL half of a pair whose other half is `normalisePeriodStart` in
* `operations-targets.service.ts`, and a target that snaps to a boundary the
* report does not bucket on plans against a period that does not exist.
*/
it('anchors the irregular units to the start of the calendar year', () => {
for (const unit of ['half_year', 'nine_month', 'ninety_day']) {
const expr = periodExpr({ period: unit });
expect(expr).toContain("date_trunc('year'");
expect(expr).not.toContain(`date_trunc('${unit}'`);
} }
// Six- and nine-month blocks count whole months from January.
expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'");
expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'");
// 90-day blocks count days, and cap at the fourth so the last days of
// December widen block four instead of forming a 5-day stub of their own.
const ninety = periodExpr({ period: 'ninety_day' });
expect(ninety).toContain("INTERVAL '90 days'");
expect(ninety).toContain('LEAST(');
expect(ninety).toContain('/ 90, 3)');
}); });
}); });

View File

@@ -230,22 +230,103 @@ END`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string * A granularity, as SQL builders rather than fragments to interpolate.
* or null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of five compile-time constants does.
* *
* Every format is zero-padded, so lexicographic order equals chronological * Five of the eight are plain `date_trunc` units. The other three — half-year,
* order. The growth window depends on that. * nine-month, ninety-day — have no `date_trunc` equivalent in Postgres, so they
* are offset arithmetic from the start of the calendar year. Builders let both
* kinds live behind one interface.
*/ */
const PERIOD_UNITS = { interface PeriodUnit {
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, label: string;
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, /** Interval one whole block wide. Only exact for the six regular units. */
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, step: string;
/** Timestamp expression → the start of the block that timestamp falls in. */
truncOn: (dateExpr: string) => string;
/** Block-start expression → its display label. */
labelOn: (truncExpr: string) => string;
/**
* Block-start expression → the start of the NEXT block. Not always
* `+ step`: a ragged unit's final block of the year is shorter than its own
* step, so stepping past it overshoots into the wrong block.
*/
nextStartOn: (truncExpr: string) => string;
}
const regular = (trunc: string, fmt: string, label: string, step: string): PeriodUnit => ({
label,
step,
truncOn: (dateExpr) => `date_trunc('${trunc}', ${dateExpr})`,
labelOn: (truncExpr) => `to_char(${truncExpr}, '${fmt}')`,
nextStartOn: (truncExpr) => `(${truncExpr} + INTERVAL '${step}')`,
});
/**
* Blocks of `months` months counted from January, so they reset every calendar
* year. Six divides twelve and nine does not: a nine-month year is JanSep plus
* a short OctDec. That ragged tail is inherent to the unit — the alternative
* is blocks that drift out of the calendar, which is not what "calendar
* anchored" means.
*/
const monthBlocks = (months: number, marker: string, label: string): PeriodUnit => ({
label,
step: `${months} months`,
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (((EXTRACT(MONTH FROM ${dateExpr})::int - 1) / ${months}) * INTERVAL '${months} months'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-${marker}' ||` +
` ((EXTRACT(MONTH FROM ${truncExpr})::int - 1) / ${months} + 1)::text)`,
nextStartOn: (truncExpr) =>
`LEAST(${truncExpr} + INTERVAL '${months} months',` +
` date_trunc('year', ${truncExpr}) + INTERVAL '1 year')`,
});
/**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string or
* null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of eight compile-time constants does.
*
* Every label is zero-padded or single-digit-bounded, so lexicographic order
* equals chronological order. The growth windows depend on that.
*/
const PERIOD_UNITS: Record<string, PeriodUnit> = {
day: regular('day', 'YYYY-MM-DD', 'Daily', '1 day'),
week: regular('week', 'IYYY-"W"IW', 'Weekly', '1 week'),
month: regular('month', 'YYYY-MM', 'Monthly', '1 month'),
// `quarter` is a valid date_trunc unit but NOT a valid interval unit — // `quarter` is a valid date_trunc unit but NOT a valid interval unit —
// INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months.
quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'),
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, half_year: monthBlocks(6, 'H', 'Half-yearly'),
} as const; nine_month: monthBlocks(9, 'N', 'Nine-monthly'),
/**
* Four 90-day blocks from January 1st: days 1, 91, 181, 271.
*
* The block index is capped at 3 on purpose. Uncapped, `(doy - 1) / 90` puts
* December 27th onwards in a fifth block — a 5-day stub bucket at the end of
* every year, which is noise rather than a period. Capping instead lets the
* fourth block absorb the remainder and run 95 or 96 days.
*
* The label carries the zero-padded start day-of-year, which keeps it sorting
* chronologically and — unlike an ordinal — says out loud that the blocks are
* day-counted rather than month-aligned.
*/
ninety_day: {
label: '90-day',
step: '90 days',
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (LEAST((EXTRACT(DOY FROM ${dateExpr})::int - 1) / 90, 3) * INTERVAL '90 days'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-D' || lpad(EXTRACT(DOY FROM ${truncExpr})::int::text, 3, '0'))`,
// The fourth block ends with the year, not 90 days after it started.
nextStartOn: (truncExpr) =>
`(CASE WHEN EXTRACT(DOY FROM ${truncExpr})::int >= 271` +
` THEN date_trunc('year', ${truncExpr}) + INTERVAL '1 year'` +
` ELSE ${truncExpr} + INTERVAL '90 days' END)`,
},
year: regular('year', 'YYYY', 'Yearly', '1 year'),
};
export const PERIOD_FILTER: ReportFilterDef = { export const PERIOD_FILTER: ReportFilterDef = {
key: 'period', key: 'period',
@@ -274,10 +355,8 @@ export function periodExpr(params: Record<string, unknown>): string {
return periodExprOn(REVENUE_DATE, params); return periodExprOn(REVENUE_DATE, params);
} }
export function resolvePeriod( export function resolvePeriod(params: Record<string, unknown>): PeriodUnit {
params: Record<string, unknown>, const key = String(params.period ?? '');
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; return PERIOD_UNITS[key] ?? PERIOD_UNITS.month;
} }
@@ -287,10 +366,10 @@ export function resolvePeriod(
* these units so a month means the same thing on both sides of the product. * these units so a month means the same thing on both sides of the product.
*/ */
export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params));
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; resolvePeriod(params).truncOn(dateExpr);
/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */
export const periodTruncExpr = (params: Record<string, unknown>): string => export const periodTruncExpr = (params: Record<string, unknown>): string =>
@@ -305,9 +384,16 @@ export const periodTruncExpr = (params: Record<string, unknown>): string =>
export const periodOrdinalExpr = (params: Record<string, unknown>): string => export const periodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
/** Same scale, one period later — where a one-step-ahead projection lands. */ /**
* Same scale, one period later — where a one-step-ahead projection lands.
*
* Asks the unit rather than adding its step, because the two differ for the
* ragged units: a nine-month year's second block is three months long, and a
* 90-day year's fourth is 95, so `+ step` would land past the next block start
* and evaluate the regression at the wrong x.
*/
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string => export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; `EXTRACT(EPOCH FROM ${resolvePeriod(params).nextStartOn(periodTruncExpr(params))})`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Volume — measured at line grain, never joined from the booking // Volume — measured at line grain, never joined from the booking