mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
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:
@@ -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 — Nagad–Dire 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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user