mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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:
@@ -5,7 +5,7 @@ import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
CATEGORY_LABEL_OF,
|
||||
COUNTRY_FILTER,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPS_DATE,
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedValueExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
/** The two sides of the line. Anything else is ignored rather than interpolated. */
|
||||
const COUNTRIES = ['Ethiopia', 'Djibouti'];
|
||||
@@ -73,7 +75,8 @@ export const cargoVolumeByStationReport: ReportDefinition = {
|
||||
'the Ethiopian view (GMP, Modjo, Dire Dawa, Adama, Sebeta) and the Djibouti view ' +
|
||||
'(DMP, DCT, Nagad), which changes which end of the corridor counts as the station and ' +
|
||||
'which counts as the origination. Plan comes from Operational targets, keyed on the ' +
|
||||
'station’s yard code.',
|
||||
'station’s yard code.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, COUNTRY_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
@@ -94,25 +97,17 @@ export const cargoVolumeByStationReport: ReportDefinition = {
|
||||
const { params } = ctx;
|
||||
const bucket = periodTruncExprOn(OPS_DATE, params);
|
||||
const stationCode = stationExpr(params, 'code');
|
||||
const plan = plannedValueExpr(
|
||||
'VOLUME_TONS',
|
||||
'station',
|
||||
stationCode,
|
||||
`'${resolvePeriod(params).trunc}'`,
|
||||
bucket,
|
||||
);
|
||||
|
||||
return baseQuery(ctx)
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, params), 'period')
|
||||
.addSelect(stationCode, 'station_code')
|
||||
.addSelect(`COALESCE(${stationExpr(params, 'label')}, ${stationCode}, '?')`, 'station')
|
||||
.addSelect(
|
||||
`COALESCE(${originationExpr(params, 'label')}, ${originationExpr(params, 'code')}, '?')`,
|
||||
'origination',
|
||||
)
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||
.addSelect(`${plan}::float8`, 'plan')
|
||||
.addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
@@ -122,6 +117,45 @@ export const cargoVolumeByStationReport: ReportDefinition = {
|
||||
.addGroupBy(originationExpr(params, 'label'))
|
||||
.addGroupBy(originationExpr(params, 'code'))
|
||||
.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.
|
||||
const combined = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.station_code, p.plan_key) AS station_code,
|
||||
COALESCE(o.station,
|
||||
(SELECT y2.label FROM freight.yards y2
|
||||
WHERE y2.code = p.plan_key AND y2.deleted_at IS NULL LIMIT 1),
|
||||
p.plan_key) AS station,
|
||||
COALESCE(o.origination, '—') AS origination,
|
||||
COALESCE(o.category_key, p.plan_category) AS category_key,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
COALESCE(o.teu, 0) AS teu,
|
||||
COALESCE(o.wagons, 0) AS wagons,
|
||||
COALESCE(o.trains, 0) AS trains,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p
|
||||
ON p.period = o.period
|
||||
AND p.plan_key = o.station_code
|
||||
AND p.plan_category = o.category_key`;
|
||||
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${combined})`, 'r')
|
||||
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect('r.station', 'station')
|
||||
.addSelect('r.origination', 'origination')
|
||||
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
|
||||
.addSelect('r.category_key', 'categoryKey')
|
||||
.addSelect('r.operated::float8', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
|
||||
.addSelect('r.teu::int', 'teu')
|
||||
.addSelect('r.wagons::int', 'wagons')
|
||||
.addSelect('r.trains::int', 'trains');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
CATEGORY_LABEL_OF,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPS_DATE,
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedValueExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
@@ -24,15 +26,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return qb;
|
||||
}
|
||||
|
||||
const planned = (params: Record<string, unknown>): string =>
|
||||
plannedValueExpr(
|
||||
'VOLUME_TONS',
|
||||
'cargo_category',
|
||||
CARGO_CATEGORY_EXPR,
|
||||
`'${resolvePeriod(params).trunc}'`,
|
||||
periodTruncExprOn(OPS_DATE, params),
|
||||
);
|
||||
|
||||
export const cargoVolumePerformanceReport: ReportDefinition = {
|
||||
key: 'cargo-volume-performance',
|
||||
title: 'Cargo Volume Performance',
|
||||
@@ -40,7 +33,8 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
|
||||
'Tonnage moved per cargo category against plan. Operated is the actual loaded weight ' +
|
||||
'from the marshalling record; charged volume is the standard weight capacity the same ' +
|
||||
'cargo is billed on. Plan comes from Operational targets and is measured against the ' +
|
||||
'actual, not the charged, tonnage.',
|
||||
'actual, not the charged, tonnage.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
@@ -58,20 +52,46 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
|
||||
chart: { type: 'bar', x: 'category', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
|
||||
const plan = planned(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 1)::float8`, 'operated')
|
||||
.addSelect(`${plan}::float8`, 'plan')
|
||||
.addSelect(implementRateExpr(ACTUAL_TONS_EXPR, plan), 'implementRate')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'chargedTons')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 1)::float8`, 'charged_tons')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.groupBy(bucket)
|
||||
.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 = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.category_key, p.plan_key) AS category_key,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
COALESCE(o.charged_tons, 0) AS charged_tons,
|
||||
COALESCE(o.teu, 0) AS teu,
|
||||
COALESCE(o.wagons, 0) AS wagons,
|
||||
COALESCE(o.trains, 0) AS trains,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) 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) })
|
||||
.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(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
|
||||
.addSelect('r.charged_tons::float8', 'chargedTons')
|
||||
.addSelect('r.teu::int', 'teu')
|
||||
.addSelect('r.wagons::int', 'wagons')
|
||||
.addSelect('r.trains::int', 'trains');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
|
||||
import { OperationsStandard } from '../../operations-reporting/entities/operations-standard.entity';
|
||||
import { TrainCheckpointEvent } from '../../train-scheduling/entities/train-checkpoint-event.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
@@ -15,36 +16,45 @@ import {
|
||||
hoursBetween,
|
||||
} from '../operations-classification';
|
||||
|
||||
const ARRIVAL = "MIN(ev.occurred_at) FILTER (WHERE ev.kind = 'ARRIVED')";
|
||||
const DEPARTURE = "MAX(ev.occurred_at) FILTER (WHERE ev.kind = 'DEPARTED')";
|
||||
const STAYING_HOURS = hoursBetween(ARRIVAL, DEPARTURE);
|
||||
const STANDARD_HOURS = `MAX(${STATION_STANDARD_HOURS_EXPR})`;
|
||||
|
||||
/**
|
||||
* Station staying time, from the checkpoints staff log as a train works a stop:
|
||||
* departure minus arrival at the same station.
|
||||
* A stay is an ARRIVED followed by the next DEPARTED at the same station by the
|
||||
* same physical train — NOT by the same schedule.
|
||||
*
|
||||
* The spec also asks for total loading and unloading time and for "other
|
||||
* activity" (staying time minus the two). Neither is built here, because
|
||||
* nothing in the schema records when loading or unloading STARTED and ENDED —
|
||||
* the checkpoint kinds are only ARRIVED, DEPARTED and PASSED, and
|
||||
* `facility_handling_events` stamps a single moment per booking, not a window.
|
||||
* Those two columns arrive when that capture does; the staying time this report
|
||||
* measures is unaffected by their absence.
|
||||
* When a train turns around at a station the two halves belong to different
|
||||
* departures: the arrival closes the inbound schedule and the departure opens
|
||||
* the outbound one. Pairing within a schedule finds only pass-through stops and
|
||||
* silently drops every turnaround, which is the longest stay a train makes.
|
||||
*/
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const TRAIN_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
|
||||
const STAY_WINDOW = `PARTITION BY ${TRAIN_KEY}, ev.yard_id ORDER BY ev.occurred_at`;
|
||||
|
||||
const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at');
|
||||
const STANDARD_HOURS = 's.standard_hours';
|
||||
|
||||
/** Every logged stop, with the event that followed it at the same station. */
|
||||
function stopsQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(TrainCheckpointEvent, 'ev')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.id = ev.train_schedule_id AND ts.deleted_at IS NULL')
|
||||
.leftJoin(TrainSet, 'tset', 'tset.id = ts.train_set_id AND tset.deleted_at IS NULL')
|
||||
.innerJoin(Yard, 'y', 'y.id = ev.yard_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('ev.deleted_at IS NULL')
|
||||
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')");
|
||||
.andWhere("ev.kind IN ('ARRIVED', 'DEPARTED')")
|
||||
.select('ts.train_number', 'train_number')
|
||||
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
|
||||
.addSelect("COALESCE(y.country, '—')", 'country')
|
||||
.addSelect('ev.kind', 'kind')
|
||||
.addSelect('ev.occurred_at', 'arrived_at')
|
||||
.addSelect(`lead(ev.occurred_at) OVER (${STAY_WINDOW})`, 'departed_at')
|
||||
.addSelect(`lead(ev.kind) OVER (${STAY_WINDOW})`, 'next_kind')
|
||||
.addSelect(`ROUND(${STATION_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours')
|
||||
.addSelect("COALESCE(ev.note, '')", 'note');
|
||||
|
||||
if (params.dateFrom) qb.andWhere(`${OPS_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere(`${OPS_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
@@ -61,18 +71,26 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** Only stops where both ends of the stay were logged can be measured. */
|
||||
const COMPLETE_STOP = `${ARRIVAL} IS NOT NULL AND ${DEPARTURE} IS NOT NULL`;
|
||||
/** Only completed stops — an arrival whose departure was also logged. */
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const inner = stopsQuery(ctx);
|
||||
return ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(`(${inner.getQuery()})`, 's')
|
||||
.setParameters(inner.getParameters())
|
||||
.where("s.kind = 'ARRIVED'")
|
||||
.andWhere("s.next_kind = 'DEPARTED'");
|
||||
}
|
||||
|
||||
export const stationStayingTimeReport: ReportDefinition = {
|
||||
key: 'station-staying-time',
|
||||
title: 'Station Staying Time',
|
||||
description:
|
||||
'How long each train stood at each station — departure minus arrival on the logged ' +
|
||||
'checkpoints — against the standard for that side of the line (10h Ethiopia, 13h ' +
|
||||
'Djibouti, both editable in Operating standards). A stop over standard needs a reason. ' +
|
||||
'Loading and unloading times are not shown: nothing in the system records when they ' +
|
||||
'start and end yet.',
|
||||
'How long each train stood at each station — the logged arrival to the same train’s ' +
|
||||
'next departure from that station — against the standard for that side of the line ' +
|
||||
'(10h Ethiopia, 13h Djibouti, both editable in Operating standards). A stop over ' +
|
||||
'standard needs a reason. Loading and unloading times are not split out: nothing in ' +
|
||||
'the system records when they start and end yet.',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
@@ -82,10 +100,10 @@ export const stationStayingTimeReport: ReportDefinition = {
|
||||
COUNTRY_FILTER,
|
||||
],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 'y.label' },
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 's.train_number' },
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true, sortExpr: 's.station' },
|
||||
{ key: 'country', label: 'Country', type: 'string' },
|
||||
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: ARRIVAL },
|
||||
{ key: 'arrivedAt', label: 'Arrived', type: 'date', sortable: true, sortExpr: 's.arrived_at' },
|
||||
{ key: 'departedAt', label: 'Departed', type: 'date' },
|
||||
{ key: 'stayingHours', label: 'Staying (hrs)', type: 'number', sortable: true, sortExpr: STAYING_HOURS },
|
||||
{ key: 'standardHours', label: 'Standard (hrs)', type: 'number' },
|
||||
@@ -96,47 +114,31 @@ export const stationStayingTimeReport: ReportDefinition = {
|
||||
defaultSort: { key: 'arrivedAt', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('ts.train_number', 'trainNumber')
|
||||
.addSelect("COALESCE(y.label, y.code, '—')", 'station')
|
||||
.addSelect("COALESCE(y.country, '—')", 'country')
|
||||
.addSelect(`to_char(${ARRIVAL}, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||
.addSelect(`to_char(${DEPARTURE}, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.select("COALESCE(s.train_number, '—')", 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect(`to_char(s.arrived_at, 'YYYY-MM-DD HH24:MI')`, 'arrivedAt')
|
||||
.addSelect(`to_char(s.departed_at, 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||
.addSelect(`ROUND(${STANDARD_HOURS}, 1)::float8`, 'standardHours')
|
||||
.addSelect(`${STANDARD_HOURS}::float8`, 'standardHours')
|
||||
.addSelect(`ROUND((${STAYING_HOURS})::numeric - ${STANDARD_HOURS}, 1)::float8`, 'varianceHours')
|
||||
.addSelect(
|
||||
`CASE WHEN (${STAYING_HOURS})::numeric <= ${STANDARD_HOURS}
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'verdict',
|
||||
)
|
||||
// The note staff leave on a checkpoint is the only free text on the stop,
|
||||
// The note staff leave on the checkpoint is the only free text on a stop,
|
||||
// so it is where a reason for an over-standard stay is recorded today.
|
||||
.addSelect("COALESCE(MAX(ev.note) FILTER (WHERE ev.note IS NOT NULL), '')", 'reason')
|
||||
.groupBy('ts.id')
|
||||
.addGroupBy('ts.train_number')
|
||||
.addGroupBy('y.id')
|
||||
.addGroupBy('y.label')
|
||||
.addGroupBy('y.code')
|
||||
.addGroupBy('y.country')
|
||||
.having(COMPLETE_STOP);
|
||||
.addSelect('s.note', 'reason');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const inner = baseQuery(ctx)
|
||||
.select('1', 'one')
|
||||
.addSelect(STAYING_HOURS, 'staying')
|
||||
.addSelect(STANDARD_HOURS, 'standard')
|
||||
.groupBy('ts.id')
|
||||
.addGroupBy('y.id')
|
||||
.addGroupBy('y.country')
|
||||
.having(COMPLETE_STOP);
|
||||
|
||||
const row = await ctx.ds
|
||||
.createQueryBuilder()
|
||||
const row = await baseQuery(ctx)
|
||||
.select('COUNT(*)::int', 'stops')
|
||||
.addSelect('ROUND(AVG(s.staying)::numeric, 1)::float8', 'avgHours')
|
||||
.addSelect('COUNT(*) FILTER (WHERE s.staying > s.standard)::int', 'overStandard')
|
||||
.from(`(${inner.getQuery()})`, 's')
|
||||
.setParameters(inner.getParameters())
|
||||
.addSelect(`ROUND(AVG((${STAYING_HOURS})::numeric), 1)::float8`, 'avgHours')
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE (${STAYING_HOURS})::numeric > ${STANDARD_HOURS})::int`,
|
||||
'overStandard',
|
||||
)
|
||||
.getRawOne<{ stops: number; avgHours: number; overStandard: number }>();
|
||||
|
||||
return [
|
||||
|
||||
@@ -4,16 +4,18 @@ import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CONTAINER_CLASSES,
|
||||
CONTAINER_CLASS_EXPR,
|
||||
CONTAINER_CLASS_LABEL_EXPR,
|
||||
CONTAINER_CLASS_LABEL_OF,
|
||||
CONTAINERS_EXPR,
|
||||
OPS_DATE,
|
||||
OPERATIONS_FILTERS,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedValueExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
const CONTAINERS_20 = `COALESCE(SUM((
|
||||
SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
|
||||
@@ -36,15 +38,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return qb;
|
||||
}
|
||||
|
||||
const planned = (params: Record<string, unknown>): string =>
|
||||
plannedValueExpr(
|
||||
'TEU',
|
||||
'container_class',
|
||||
CONTAINER_CLASS_EXPR,
|
||||
`'${resolvePeriod(params).trunc}'`,
|
||||
periodTruncExprOn(OPS_DATE, params),
|
||||
);
|
||||
|
||||
export const teuPerformanceReport: ReportDefinition = {
|
||||
key: 'teu-performance',
|
||||
title: 'TEU Performance',
|
||||
@@ -52,7 +45,8 @@ export const teuPerformanceReport: ReportDefinition = {
|
||||
'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' +
|
||||
'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' +
|
||||
'marshalling record — the containers actually allocated to wagons — not from the ' +
|
||||
'billing lines. Plan comes from Operational targets.',
|
||||
'billing lines. Plan comes from Operational targets.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
PERIOD_FILTER,
|
||||
@@ -73,19 +67,43 @@ export const teuPerformanceReport: ReportDefinition = {
|
||||
chart: { type: 'bar', x: 'containerClass', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
|
||||
const plan = planned(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
|
||||
.addSelect(CONTAINER_CLASS_LABEL_EXPR, 'containerClass')
|
||||
.addSelect(CONTAINER_CLASS_EXPR, 'containerClassKey')
|
||||
.addSelect(CONTAINER_CLASS_EXPR, 'class_key')
|
||||
.addSelect(CONTAINERS_20, 'containers20')
|
||||
.addSelect(CONTAINERS_40, 'containers40')
|
||||
.addSelect(CONTAINERS_EXPR, 'containers')
|
||||
.addSelect(TEU_EXPR, 'operated')
|
||||
.addSelect(`${plan}::float8`, 'plan')
|
||||
.addSelect(implementRateExpr(TEU_EXPR, plan), 'implementRate')
|
||||
.groupBy(bucket)
|
||||
.addGroupBy(CONTAINER_CLASS_EXPR);
|
||||
|
||||
// Full outer join so a planned container class that never moved still
|
||||
// reports, at zero rather than vanishing.
|
||||
const combined = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.class_key, p.plan_key) AS class_key,
|
||||
COALESCE(o.containers20, 0) AS containers20,
|
||||
COALESCE(o.containers40, 0) AS containers40,
|
||||
COALESCE(o.containers, 0) AS containers,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) 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) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass')
|
||||
.addSelect('r.class_key', 'containerClassKey')
|
||||
.addSelect('r.containers20::int', 'containers20')
|
||||
.addSelect('r.containers40::int', 'containers40')
|
||||
.addSelect('r.containers::int', 'containers')
|
||||
.addSelect('r.operated::int', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -6,17 +6,19 @@ import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
CATEGORY_LABEL_OF,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPS_DATE,
|
||||
OPERATIONS_FILTERS,
|
||||
TRAINSETS_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
implementRateExpr,
|
||||
plannedValueExpr,
|
||||
plannedRowsParams,
|
||||
plannedRowsSql,
|
||||
} from '../operations-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn, resolvePeriod } from '../revenue-classification';
|
||||
import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification';
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
@@ -24,15 +26,6 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
return qb;
|
||||
}
|
||||
|
||||
const planned = (params: Record<string, unknown>): string =>
|
||||
plannedValueExpr(
|
||||
'TRAINSET',
|
||||
'cargo_category',
|
||||
CARGO_CATEGORY_EXPR,
|
||||
`'${resolvePeriod(params).trunc}'`,
|
||||
periodTruncExprOn(OPS_DATE, params),
|
||||
);
|
||||
|
||||
export const trainsetPerformanceReport: ReportDefinition = {
|
||||
key: 'trainset-performance',
|
||||
title: 'Trainset Performance',
|
||||
@@ -41,7 +34,8 @@ export const trainsetPerformanceReport: ReportDefinition = {
|
||||
'loaded divided by a full trainset for that cargo (37 for vehicles, 22 for sand, ' +
|
||||
'otherwise the default of 50 — all editable on Cargo Types and Operating standards), so ' +
|
||||
'30 wagons of a 50-wagon set reads 0.6. Plan comes from Operational targets; a period ' +
|
||||
'with no target shows no plan rather than a zero.',
|
||||
'with no target shows no plan rather than a zero.' +
|
||||
PLAN_GRANULARITY_NOTE,
|
||||
group: 'Operations',
|
||||
filters: [PERIOD_FILTER, ...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
columns: [
|
||||
@@ -57,18 +51,40 @@ export const trainsetPerformanceReport: ReportDefinition = {
|
||||
chart: { type: 'bar', x: 'category', y: ['operated'] },
|
||||
query(ctx) {
|
||||
const bucket = periodTruncExprOn(OPS_DATE, ctx.params);
|
||||
const plan = planned(ctx.params);
|
||||
return baseQuery(ctx)
|
||||
const operated = baseQuery(ctx)
|
||||
.select(periodExprOn(OPS_DATE, ctx.params), 'period')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'categoryKey')
|
||||
.addSelect(CARGO_CATEGORY_EXPR, 'category_key')
|
||||
.addSelect('COUNT(DISTINCT ts.id)::int', 'trains')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(TRAINSETS_EXPR, 'operated')
|
||||
.addSelect(`${plan}::float8`, 'plan')
|
||||
.addSelect(implementRateExpr(TRAINSETS_EXPR, plan), 'implementRate')
|
||||
.groupBy(bucket)
|
||||
.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 = `
|
||||
SELECT COALESCE(o.period, p.period) AS period,
|
||||
COALESCE(o.category_key, p.plan_key) AS category_key,
|
||||
COALESCE(o.trains, 0) AS trains,
|
||||
COALESCE(o.wagons, 0) AS wagons,
|
||||
COALESCE(o.operated, 0) AS operated,
|
||||
p.plan_value AS plan
|
||||
FROM (${operated.getQuery()}) o
|
||||
FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) 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) })
|
||||
.select('r.period', 'period')
|
||||
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
|
||||
.addSelect('r.category_key', 'categoryKey')
|
||||
.addSelect('r.trains::int', 'trains')
|
||||
.addSelect('r.wagons::int', 'wagons')
|
||||
.addSelect('r.operated::float8', 'operated')
|
||||
.addSelect('r.plan::float8', 'plan')
|
||||
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -11,10 +11,17 @@ import {
|
||||
} from '../operations-classification';
|
||||
|
||||
/**
|
||||
* A turn-around cycle is a whole out-and-back: Djibouti → Ethiopia → Djibouti
|
||||
* (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation). That spans TWO
|
||||
* departures, so the cycle's end is the NEXT departure's arrival for the same
|
||||
* physical train — `lead()` over the train's departures.
|
||||
* A turn-around cycle is a whole out-and-back, measured departure to the SAME
|
||||
* train's next departure from the same end: Djibouti → Ethiopia → Djibouti
|
||||
* (DCT1 → GMP1 → GMP2 → DCT2 in the spec's notation).
|
||||
*
|
||||
* That is departure-to-departure two legs later, NOT departure-to-arrival. The
|
||||
* standard is built that way — the 65-hour container cycle is 21 travel + 13
|
||||
* working Nagad + 21 travel + 10 working Indode, and the closing 10 hours only
|
||||
* exist if the cycle ends at the next departure. Ending it at the arrival would
|
||||
* measure 55 against a 65-hour standard and report every train as early.
|
||||
* EDR's own July 2026 figure checks out this way: 22:52 travel + 31:21 at DCT +
|
||||
* 22:52 travel + 7:12 at Gelan = 84:17, against the 84:07 published average.
|
||||
*
|
||||
* Trains are paired by `train_sets.train_id`, the physical consist. A train set
|
||||
* is one-to-one with a departure, so pairing by set alone would never find a
|
||||
@@ -23,8 +30,8 @@ import {
|
||||
*/
|
||||
const CYCLE_KEY = 'COALESCE(tset.train_id::text, ts.train_set_id::text)';
|
||||
const CYCLE_ORDER = 'ts.actual_departure_at';
|
||||
const lead = (column: string): string =>
|
||||
`lead(${column}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`;
|
||||
const lead = (column: string, offset = 1): string =>
|
||||
`lead(${column}, ${offset}) OVER (PARTITION BY ${CYCLE_KEY} ORDER BY ${CYCLE_ORDER})`;
|
||||
|
||||
/**
|
||||
* Hours a train stood still on one side of the line during the cycle.
|
||||
@@ -43,7 +50,7 @@ const stayHours = (country: string): string => `(
|
||||
JOIN freight.yards yy ON yy.id = e.yard_id
|
||||
WHERE e.deleted_at IS NULL
|
||||
AND yy.country = '${country}'
|
||||
AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id)
|
||||
AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id)
|
||||
GROUP BY e.train_schedule_id, e.yard_id
|
||||
) q
|
||||
WHERE q.arr IS NOT NULL AND q.dep IS NOT NULL
|
||||
@@ -65,8 +72,10 @@ function cycleQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
.addSelect("COALESCE(oy.label, oy.code, '?')", 'origin')
|
||||
.addSelect("COALESCE(dy.label, dy.code, '?')", 'destination')
|
||||
.addSelect('ts.actual_departure_at', 'cycle_start')
|
||||
.addSelect(lead('ts.actual_arrival_at'), 'cycle_end')
|
||||
// Two legs on: the train is back where it started and leaving again.
|
||||
.addSelect(lead('ts.actual_departure_at', 2), 'cycle_end')
|
||||
.addSelect(lead('ts.id'), 'return_schedule_id')
|
||||
.addSelect(lead('ts.id', 2), 'next_cycle_schedule_id')
|
||||
.addSelect(`ROUND(${CYCLE_STANDARD_HOURS_EXPR}, 1)`, 'standard_hours');
|
||||
}
|
||||
|
||||
@@ -84,11 +93,13 @@ export const turnaroundCycleReport: ReportDefinition = {
|
||||
key: 'turnaround-cycle',
|
||||
title: 'Turnaround Cycle',
|
||||
description:
|
||||
'Full out-and-back cycle per train: actual duration against the standard cycle ' +
|
||||
'(65h container, 88h bulk via DMP, 96h via Negad or BCC — editable in Operating ' +
|
||||
'standards). Implement rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on ' +
|
||||
'standard scores 100. The Ethiopia, Djibouti and travelling split comes from logged ' +
|
||||
'station checkpoints and reads zero for a train whose stops were never logged.',
|
||||
'Full out-and-back cycle per train, measured from one departure to the same train’s ' +
|
||||
'departure two legs later — the way the standard is built, so the closing station ' +
|
||||
'stay is inside the cycle. Compared against the standard cycle (65h container, 88h ' +
|
||||
'bulk via DMP, 96h via Negad or BCC — editable in Operating standards). Implement ' +
|
||||
'rate is [(SC − AD) / SC + 1] × 100, so finishing exactly on standard scores 100. ' +
|
||||
'The Ethiopia, Djibouti and travelling split comes from logged station checkpoints ' +
|
||||
'and reads zero for a train whose stops were never logged.',
|
||||
group: 'Operations',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Departure', type: 'daterange' },
|
||||
|
||||
@@ -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