Files
edr-platform/apps/edr-freight-api/src/modules/reports/operations-classification.ts
Nathnael 05efdd5d54 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.
2026-08-20 11:36:42 +00:00

595 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { OperationsStandard } from '../operations-reporting/entities/operations-standard.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
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 { resolvePeriod, yardOptions } from './revenue-classification';
/**
* The shared vocabulary and SQL behind every operations report — turnaround,
* delay, trainset, TEU and cargo volume.
*
* The fact table is `wagon_booking_allocations`: one row is one booking's cargo
* on one wagon of one departure. That is the marshalling record — what was
* actually put on the train — and it is the only grain that can answer both
* "how many TEU moved" and "how many wagons did it take", which the volume and
* trainset reports need together.
*
* Every consumer builds its FROM through {@link allocationLedgerQb}, so the
* table aliases below (`wba tsw ts b ct oy dy std`) are a fixed contract and
* the fragments here reference them directly.
*
* This is deliberately a SECOND classification module rather than an extension
* of `revenue-classification.ts`. That one classifies invoice lines by charge
* code; this one classifies physical cargo by booking and cargo type. The two
* answer different questions and a row that is one revenue category can be a
* different operational category — an incidental charge on a container booking,
* for instance, is INCIDENTAL revenue but container tonnage.
*/
// ---------------------------------------------------------------------------
// Cargo categories
// ---------------------------------------------------------------------------
export const CARGO_CATEGORIES: ReportFilterOption[] = [
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' },
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' },
{ value: 'CONTAINER_EXPORT', label: 'Export container' },
{ value: 'EMPTY_CONTAINER', label: 'Empty container' },
{ value: 'FERTILIZER', label: 'Fertilizer' },
{ value: 'RORO', label: 'RoRo' },
{ value: 'BREAK_BULK', label: 'Break bulk' },
{ value: 'SAND', label: 'Sand' },
{ value: 'BULK', label: 'Bulk' },
{ value: 'OTHER_IMPORT', label: 'Other imports' },
{ value: 'OTHER_EXPORT', label: 'Other export cargo' },
{ value: 'UNCLASSIFIED', label: 'Unclassified' },
];
/**
* Container classes for the TEU report — the four the spec names.
* `EMPTY_CONTAINER_RETURN` is the empty re-export leg.
*/
export const CONTAINER_CLASSES: ReportFilterOption[] = [
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Multimodal container import' },
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Unimodal container import' },
{ value: 'CONTAINER_EXPORT', label: 'Full export container' },
{ value: 'EMPTY_CONTAINER_RETURN', label: 'Empty container return' },
];
/** `cargo_types.code` is admin-managed, so each set absorbs every spelling seeded so far. */
export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO'];
export const BREAK_BULK_CODES = [
'BREAK_BULK',
'STEEL_BILLET',
'STEEL',
'MACHINERY',
'PIPES',
'TIMBER',
];
export const FERTILIZER_CODES = ['FERTILIZER'];
export const SAND_CODES = ['SAND'];
/** Cargo charged at the lighter per-wagon rate — vegetables, milk, meat, livestock. */
export const PERISHABLE_CODES = ['PERISHABLE', 'LIVESTOCK'];
const quote = (values: string[]): string => values.map((v) => `'${v}'`).join(', ');
/**
* A booking whose equipment_return is RETURN is the empty-container movement
* itself; WITH_RETURN / WITHOUT_RETURN describe a laden booking's obligation.
* This is the only booking-level marker of an empty box — no table records
* laden-vs-empty on the container row.
*/
const IS_EMPTY_CONTAINER = "b.equipment_return = 'RETURN'";
/**
* Multimodal means a named sea carrier is on the booking — the same proxy the
* revenue reports use. There is no explicit multimodal flag; confirm with the
* business before treating this as definitive.
*/
const IS_MULTIMODAL = 'b.shipping_line_id IS NOT NULL';
const IS_CONTAINER = "COALESCE(b.freight_type, wba.load_type) = 'CONTAINER'";
export const CARGO_CATEGORY_EXPR = `CASE
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER'
WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL'
WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL'
WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER'
WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO'
WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
WHEN ct.code IN (${quote(SAND_CODES)}) THEN 'SAND'
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT'
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT'
WHEN b.id IS NOT NULL THEN 'BULK'
ELSE 'UNCLASSIFIED'
END`;
export const CONTAINER_CLASS_EXPR = `CASE
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
WHEN ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL'
ELSE 'CONTAINER_IMPORT_UNIMODAL'
END`;
/**
* Every fixed key a planner may enter on the targets screen — the category and
* container-class vocabularies. Station targets are keyed on a yard code, which
* is reference data rather than a fixed list, so they are not enumerated here.
*/
export const TARGET_DIMENSION_KEYS: string[] = [
...CARGO_CATEGORIES.map((o) => o.value),
...CONTAINER_CLASSES.map((o) => o.value),
];
/** Turns a key-emitting CASE into a label-emitting one, so a report shows business names. */
const labelCase = (keyExpr: string, options: ReportFilterOption[]): string =>
`CASE ${options
.map((o) => `WHEN (${keyExpr}) = '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`)
.join(' ')} ELSE (${keyExpr}) END`;
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
// ---------------------------------------------------------------------------
/**
* A standard, read off the joined `operations_standards` row.
*
* The fallback is not decoration: the row is seeded by migration, but a report
* must not return zeros — or divide by zero — on an environment where the seed
* has not run. The fallbacks are the spec's own figures.
*/
const stdRow = (column: string, fallback: number): string =>
`COALESCE(std.${column}, ${fallback})`;
/**
* The same value in an aggregate select. `std` is a single joined row, so the
* column is constant across the group — but Postgres still demands it be
* grouped or aggregated, and wrapping it in MAX() is cheaper than dragging it
* through every report's GROUP BY.
*/
const stdAgg = (column: string, fallback: number): string =>
`MAX(COALESCE(std.${column}, ${fallback}))`;
/** Standard hours a train may stand at a station, by the station's country. */
export const STATION_STANDARD_HOURS_EXPR = `CASE
WHEN y.country = 'Djibouti' THEN ${stdRow('station_standard_hours_djibouti', 13)}
ELSE ${stdRow('station_standard_hours_ethiopia', 10)}
END`;
/** Whichever end of the corridor is on the Djibouti side, if either is. */
export const DJIBOUTI_YARD_CODE_EXPR = `CASE
WHEN oy.country = 'Djibouti' THEN oy.code
WHEN dy.country = 'Djibouti' THEN dy.code
END`;
/** True when the departure carried any container allocation. */
export const SCHEDULE_IS_CONTAINER = `EXISTS (
SELECT 1 FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons w ON w.id = a.train_set_wagon_id AND w.deleted_at IS NULL
WHERE w.train_set_id = ts.train_set_id
AND a.deleted_at IS NULL AND a.load_type = 'CONTAINER'
)`;
/**
* Standard turn-around cycle for a departure, in hours. Container trains run
* the 65-hour cycle; a bulk cycle depends on which Djibouti terminal it works.
* Schedule grain — it reads `ts`, `oy` and `dy`, not the allocation aliases.
*/
export const CYCLE_STANDARD_HOURS_EXPR = `CASE
WHEN ${SCHEDULE_IS_CONTAINER} THEN ${stdRow('cycle_standard_hours_container', 65)}
WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'DORALEH_MULTIPURPOSE_PORT_DMP' THEN ${stdRow('cycle_standard_hours_bulk_dmp', 88)}
WHEN ${DJIBOUTI_YARD_CODE_EXPR} = 'BCC' THEN ${stdRow('cycle_standard_hours_bulk_bcc', 96)}
ELSE ${stdRow('cycle_standard_hours_bulk_nagad', 96)}
END`;
export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`;
/**
* Joins the single standards row. Restricted by id to the earliest live row so
* a stray second row could never fan a report's result out.
*/
export const STANDARDS_JOIN = `std.id = (
SELECT s.id FROM freight.operations_standards s
WHERE s.deleted_at IS NULL ORDER BY s.created_at ASC LIMIT 1
)`;
// ---------------------------------------------------------------------------
// Distance
// ---------------------------------------------------------------------------
/**
* Configured rail distance for a yard pair, in km. Symmetric: `yard_distances`
* stores one row per pair and an A→B row governs B→A.
*
* Returns NULL when the pair is not configured, and every caller must let that
* null through rather than coalescing to zero — a missing distance is not a
* zero distance, and Ton/Km computed from one would understate silently.
*/
export const distanceKmBetween = (fromCol: string, toCol: string): string => `(
SELECT yd.distance_km FROM freight.yard_distances yd
WHERE yd.deleted_at IS NULL
AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol})
OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol}))
LIMIT 1
)`;
/** Standard running time for a leg, falling back to the default leg standard. */
export const legStandardHours = (fromCol: string, toCol: string): string => `COALESCE((
SELECT yd.standard_hours FROM freight.yard_distances yd
WHERE yd.deleted_at IS NULL
AND ((yd.from_yard_id = ${fromCol} AND yd.to_yard_id = ${toCol})
OR (yd.from_yard_id = ${toCol} AND yd.to_yard_id = ${fromCol}))
LIMIT 1
), ${stdRow('default_leg_standard_hours', 21)})`;
/** The schedule's own corridor, origin to destination. */
export const SCHEDULE_KM_EXPR = distanceKmBetween('ts.origin_station_id', 'ts.destination_station_id');
// ---------------------------------------------------------------------------
// Volume — TEU, charged and actual
// ---------------------------------------------------------------------------
/** Per-allocation aggregate over its container items. */
const containerItems = (selection: string): string => `(
SELECT ${selection}
FROM freight.wagon_allocation_container_items ci
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL
)`;
/**
* TEU for one allocation: a 40ft box is two twenty-foot equivalents, anything
* else one.
*
* Note this is the third TEU derivation in the codebase and the only one taken
* from the marshalling record. `revenue-classification.ts` derives TEU from the
* charge code's size suffix (billing truth, blind to unsized codes) and
* `wagon-teu-utilization.report.ts` from a wagon's currently pinned containers
* (live state). This one answers "what did we actually move", which is what the
* reporting spec asks for.
*/
export const ALLOC_TEU = containerItems(
'COALESCE(SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END), 0)',
);
export const ALLOC_CONTAINERS_20 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft = 20)');
export const ALLOC_CONTAINERS_40 = containerItems('COUNT(*) FILTER (WHERE cty.size_ft >= 40)');
export const ALLOC_CONTAINERS = containerItems('COUNT(*)');
export const TEU_EXPR = `COALESCE(SUM(${ALLOC_TEU}), 0)::int`;
export const CONTAINERS_EXPR = `COALESCE(SUM(${ALLOC_CONTAINERS}), 0)::int`;
/**
* Actual volume — "loading capacity from marshalling" in the spec.
* `allocated_weight_tons` is what the allocation flow recorded onto the wagon,
* and is populated for every allocation in the system.
*/
export const ACTUAL_TONS_EXPR = 'COALESCE(SUM(wba.allocated_weight_tons), 0)::float8';
const IS_PERISHABLE = `COALESCE(ct.code, '') IN (${quote(PERISHABLE_CODES)})`;
const IS_BULK_LOAD = "wba.load_type <> 'CONTAINER'";
/**
* Charged volume — the standard weight capacity the spec bills against, not
* what was weighed.
*
* Containers are charged per box (20/40 tons laden, 2.24/3.88 empty). Bulk is
* charged per WAGON (70 tons, or 38 for perishables), so it counts distinct
* wagons rather than allocations: two bookings sharing one wagon are one
* wagon's charge, not two.
*/
export const CHARGED_TONS_EXPR = `(
COALESCE(SUM(
CASE WHEN ${IS_BULK_LOAD} THEN 0 ELSE
${ALLOC_CONTAINERS_20} * CASE WHEN ${IS_EMPTY_CONTAINER}
THEN ${stdRow('charged_tons_empty_20ft', 2.24)}
ELSE ${stdRow('charged_tons_full_20ft', 20)} END
+ ${ALLOC_CONTAINERS_40} * CASE WHEN ${IS_EMPTY_CONTAINER}
THEN ${stdRow('charged_tons_empty_40ft', 3.88)}
ELSE ${stdRow('charged_tons_full_40ft', 40)} END
END), 0)
+ COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND ${IS_PERISHABLE})
* ${stdAgg('charged_tons_per_wagon_perishable', 38)}
+ COUNT(DISTINCT tsw.id) FILTER (WHERE ${IS_BULK_LOAD} AND NOT ${IS_PERISHABLE})
* ${stdAgg('charged_tons_per_wagon_general', 70)}
)::float8`;
/** Wagons actually carrying cargo in the grouped set. */
export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int';
/**
* Wagons on the departure with nothing allocated to them — the Vehicle-Km base.
*
* A train-level figure: it belongs to the departure, not to any one cargo type
* riding on it, so a report grouped finer than the schedule repeats it rather
* than splitting it. Callers that need a total must de-duplicate by schedule.
*/
export const SCHEDULE_EMPTY_WAGONS = `(
SELECT COUNT(*) FROM freight.train_set_wagons tw
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.wagon_booking_allocations a
WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL)
)`;
/**
* Trainsets operated: wagons loaded divided by a full trainset for this cargo.
* Seven full multimodal trains plus 30 of a 50-wagon set reads 7.6 — the
* fraction the spec's worked example asks for.
*/
export const TRAINSETS_EXPR = `ROUND(
COUNT(DISTINCT tsw.id)::numeric
/ NULLIF(MAX(COALESCE(ct.full_trainset_wagons, ${stdRow('default_full_trainset_wagons', 50)})), 0)
, 2)::float8`;
// ---------------------------------------------------------------------------
// Rates
// ---------------------------------------------------------------------------
/**
* Implement rate — operated against plan, as a percentage.
*
* NULL when there is no plan, never 100 and never 0: an unplanned period has no
* achievement to report, and coercing a missing plan to zero would read as
* infinite achievement.
*/
export const implementRateExpr = (operated: string, planned: string): string =>
`ROUND(100 * (${operated})::numeric / NULLIF(${planned}, 0), 1)::float8`;
/**
* Turn-around implement rate, the spec's own formula:
* `[((SC AD) / SC) + 1] × 100`. Finishing exactly on standard scores 100;
* a cycle an hour quicker than a 65-hour standard scores ~101.5.
*/
export const cycleRateExpr = (actual: string, standard: string): string =>
`ROUND((((${standard}) - (${actual})) / NULLIF(${standard}, 0) + 1) * 100, 1)::float8`;
/** Hours between two timestamps, one decimal place. */
export const hoursBetween = (from: string, to: string): string =>
`ROUND(EXTRACT(EPOCH FROM ((${to}) - (${from})))::numeric / 3600, 1)::float8`;
// ---------------------------------------------------------------------------
// Filters and the shared ledger
// ---------------------------------------------------------------------------
/**
* The date every operations report buckets and filters on: when the train
* actually left, falling back to the plan for a departure not yet dispatched.
*/
export const OPS_DATE = 'COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)';
export const DIRECTION_FILTER: ReportFilterDef = {
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
};
export const COUNTRY_FILTER: ReportFilterDef = {
key: 'country',
label: 'Country',
type: 'select',
options: [
{ value: 'Ethiopia', label: 'Ethiopia' },
{ value: 'Djibouti', label: 'Djibouti' },
],
};
/** Shared by every operations report, so they read the same way side by side. */
export const OPERATIONS_FILTERS: ReportFilterDef[] = [
{ key: 'date', label: 'Departure', type: 'daterange' },
DIRECTION_FILTER,
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions },
];
export const CARGO_CATEGORY_FILTER: ReportFilterDef = {
key: 'categories',
label: 'Cargo category',
type: 'multiselect',
options: CARGO_CATEGORIES,
};
/** Schedule states that never represent an operated train. */
const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED'];
/**
* Every operations report starts here: one wagon allocation, joined out to the
* departure that carried it and the booking that explains it.
*
* The booking is LEFT joined — a wagon can be allocated before its booking data
* is complete, and dropping those rows would understate wagon usage.
*/
export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(WagonBookingAllocation, 'wba')
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL')
.leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL')
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
.where('wba.deleted_at IS NULL')
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
});
applyOperationsFilters(qb, params);
applyDirectionScope(qb, 'COALESCE(b.trade_direction, ts.direction)', directions);
return qb;
}
/**
* The schedule-grain query, for reports that measure trains rather than cargo —
* turnaround, delay, station stay. Same aliases, minus the allocation.
*/
export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
.where('ts.deleted_at IS NULL')
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
});
applyOperationsFilters(qb, params);
applyDirectionScope(qb, 'ts.direction', directions);
return qb;
}
export function applyOperationsFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
params: Record<string, unknown>,
): void {
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', {
trainNumber: `%${params.trainNumber as string}%`,
});
}
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination });
}
/**
* Restricts an allocation-grain query to a set of cargo categories. Kept
* separate from {@link applyOperationsFilters} because the schedule-grain
* query has no cargo to filter by.
*/
export function applyCategoryFilter(
qb: SelectQueryBuilder<ObjectLiteral>,
params: Record<string, unknown>,
): void {
const categories = params.categories as string[] | null;
if (categories?.length) {
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
}
}
/**
* The planned rows for a metric, as a derived table.
*
* 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.
*
* The even spread is an assumption, and the only one available: a monthly
* figure carries no information about which days inside it were busier.
*
* The share is clipped to the user's date filter as well as to the bucket, so
* the plan always covers exactly the span the operated figure beside it covers.
* Without that, filtering to July and viewing by year would put a whole year's
* plan next to one month's work.
*
* The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report
* does exactly that — NagadDire Dawa is planned 2,106 t and operated none, and
* publishes as 0%. Dropping the row would hide a total miss, which is the one
* thing a plan-versus-actual table exists to show.
*
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter.
*/
/**
* 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>,
): 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 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,
});