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

@@ -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
* 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
* **Plan** — a target is a rate over its own period, not a lump at its start.
* The committed value is spread evenly across the days it covers and
* 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
* 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
* figure carries no information about which days inside it were busier.
* **Required** — the same target read as a quota. At each bucket, whatever is
* 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
* 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.
* `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* be built **without the user's date bounds** — see {@link attainmentCtx}.
* Attainment is a fact about the target's whole period; measuring it through
* 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
* 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
* 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 = (
metric: string,
dimension: string,
params: Record<string, unknown>,
actualsSql: string,
): string => {
const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
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 g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM}
AND g.bucket < ${PLAN_TO}
GROUP BY 1, 2, 3
HAVING SUM(ot.planned_value) > 0`;
WITH tgt AS (
SELECT ot.id,
ot.dimension_key,
ot.cargo_category,
ot.planned_value,
ot.period_start::timestamptz AS starts,
ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
FROM freight.operations_targets ot
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND ot.planned_value > 0
),
-- One row per target per bucket. Generated a day at a time rather than a
-- bucket at a time: the ragged units restart their blocks each January, so
-- stepping by the unit's own width walks off the anchor in the second year.
-- Day grain also makes a bucket that only partly overlaps the target fall out
-- for free, at the same sub-day precision the clipping used before.
spread AS (
SELECT t.id,
t.dimension_key,
t.cargo_category,
t.planned_value,
EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
${bucketOf} AS bucket,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends)
- GREATEST(d.day, t.starts))))) AS secs_full,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
- 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. */
export const plannedRowsParams = (
params: Record<string, unknown>,