diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts index dd111d22a..c63a28f05 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { { key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'teu', label: 'TEU', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' }, @@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addGroupBy(originationExpr(params, 'code')) .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 // 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. @@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, 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 - 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 AND p.plan_key = o.station_code AND p.plan_category = o.category_key`; @@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = { return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(params), + }) .select('r.period', 'period') .addSelect('r.station', 'station') .addSelect('r.origination', 'origination') @@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.teu::int', 'teu') .addSelect('r.wagons::int', 'wagons') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts index 68c255c11..2bb02b859 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = { { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true }, @@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = { .groupBy(bucket) .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 // reports its miss instead of disappearing from the table. const combined = ` @@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, 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 - 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`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.teu::int', 'teu') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 6bec4b29a..adc108a02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -10,6 +10,7 @@ import { OPERATIONS_FILTERS, TEU_EXPR, allocationLedgerQb, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -60,6 +61,7 @@ export const teuPerformanceReport: ReportDefinition = { { key: "containers40", label: "40ft", type: "number", sortable: true }, { key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, { key: "plan", label: "Plan", type: "number" }, + { key: "planRequired", label: "Required", type: "number" }, { key: "implementRate", label: "Implement rate", type: "percent" }, ], defaultSort: { key: "operated", dir: "DESC" }, @@ -75,6 +77,16 @@ export const teuPerformanceReport: ReportDefinition = { .groupBy(bucket) .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 // reports, at zero rather than vanishing. const combined = ` @@ -83,15 +95,25 @@ export const teuPerformanceReport: ReportDefinition = { COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers40, 0) AS containers40, 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 - 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`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, "r") - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select("r.period", "period") .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") .addSelect("r.class_key", "containerClassKey") @@ -99,6 +121,7 @@ export const teuPerformanceReport: ReportDefinition = { .addSelect("r.containers40::int", "containers40") .addSelect("r.operated::int", "operated") .addSelect("r.plan::float8", "plan") + .addSelect("r.plan_required::float8", "planRequired") .addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate"); }, async summary(ctx) { diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts index 8e1c52434..385b69dc2 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -13,6 +13,7 @@ import { TRAINSETS_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = { { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'planRequired', label: 'Required', type: 'number' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, ], defaultSort: { key: 'operated', dir: 'DESC' }, @@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = { .groupBy(bucket) .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, // at zero — TypeORM's builder has no full-outer join, hence the raw text. const combined = ` @@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = { COALESCE(o.trains, 0) AS trains, COALESCE(o.wagons, 0) AS wagons, 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 - 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`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') @@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = { .addSelect('r.wagons::int', 'wagons') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); }, async summary(ctx) { diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index ed39536b7..1c8f8504c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -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, + 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, diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts index 42475d591..abc63fc04 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -86,17 +86,54 @@ describe('revenue classification', () => { expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); // Anything unrecognised — including an injection attempt — becomes 'month'. - expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( - "date_trunc('month'", - ); + const injection = "day'); DROP TABLE freight.invoices; --"; + expect(periodExpr({ period: injection })).toContain("date_trunc('month'"); + expect(periodExpr({ period: injection })).not.toContain('DROP TABLE'); expect(periodExpr({})).toContain("date_trunc('month'"); }); it('offers exactly the period units the expression understands', () => { const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); - expect(offered.length).toBe(5); - for (const unit of offered) { - expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + expect(offered).toEqual([ + 'day', + '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)'); }); }); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 7beb79334..e3ba0a65d 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -230,22 +230,103 @@ END`; // --------------------------------------------------------------------------- /** - * 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 five compile-time constants does. + * A granularity, as SQL builders rather than fragments to interpolate. * - * Every format is zero-padded, so lexicographic order equals chronological - * order. The growth window depends on that. + * Five of the eight are plain `date_trunc` units. The other three — half-year, + * 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 = { - day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, - week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, - month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, +interface PeriodUnit { + label: string; + /** Interval one whole block wide. Only exact for the six regular units. */ + 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 Jan–Sep plus + * a short Oct–Dec. 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 = { + 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 — // 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' }, - year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, -} as const; + quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'), + half_year: monthBlocks(6, 'H', 'Half-yearly'), + 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 = { key: 'period', @@ -274,10 +355,8 @@ export function periodExpr(params: Record): string { return periodExprOn(REVENUE_DATE, params); } -export function resolvePeriod( - params: Record, -): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { - const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; +export function resolvePeriod(params: Record): PeriodUnit { + const key = String(params.period ?? ''); 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. */ export const periodExprOn = (dateExpr: string, params: Record): string => - `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params)); export const periodTruncExprOn = (dateExpr: string, params: Record): 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. */ export const periodTruncExpr = (params: Record): string => @@ -305,9 +384,16 @@ export const periodTruncExpr = (params: Record): string => export const periodOrdinalExpr = (params: Record): string => `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 => - `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