feat(freight-api): add 6 booking/train-lifecycle reports

booking-status-breakdown (dedupes the same 'status per port/train/
cargo/contract' ask across 4 dashboards), train-schedule-status,
train-turnaround, wagon-teu-utilization, loaded-capacity,
global-logistics-wagons.

Dropped freight-weight-variance from this batch: the schema has no
'charged weight' distinct from VGM/actual, so a charged-vs-actual
variance report isn't buildable without a product decision on what
'charged' means here.
This commit is contained in:
Nathnael
2026-08-13 08:14:17 +00:00
parent c0cdf80560
commit a53a9c7152
8 changed files with 534 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BookingStatus } from '@edr/types';
import { Booking } from '../../bookings/entities/booking.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// One resolver behind "Booking per status, per port/train/date/cargo/contract
// type" — the same breakdown Operation, Marketing, Global Logistics and the
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Booking, 'b')
.leftJoin(Yard, 'o', 'o.id = b.origin_yard_id')
.leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id')
.where('b.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('b.status IN (:...statuses)', { statuses });
if (directions !== null) {
qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions });
}
return qb;
}
export const bookingStatusBreakdownReport: ReportDefinition = {
key: 'booking-status-breakdown',
title: 'Bookings by Status',
description: 'Booking counts by status, direction, origin station, cargo and contract type',
group: 'Commercial',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{
key: 'freightType',
label: 'Freight type',
type: 'select',
options: [
{ value: 'CONTAINER', label: 'Container' },
{ value: 'BULK', label: 'Bulk' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true, sortExpr: 'b.trade_direction' },
{ key: 'originStation', label: 'Origin', type: 'string', sortable: true },
{ key: 'cargoType', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'contractKind', label: 'Contract type', type: 'string', sortable: true },
{ key: 'bookings', label: 'Bookings', type: 'number', sortable: true },
{ key: 'tons', label: 'Tonnage', type: 'tons', sortable: true },
{ key: 'amount', label: 'Amount', type: 'money', sortable: true },
],
defaultSort: { key: 'bookings', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('b.status', 'status')
.addSelect('b.trade_direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'originStation')
.addSelect("COALESCE(cty.cargo_type_name, 'Other')", 'cargoType')
.addSelect("COALESCE(b.contract_kind, 'SPOT')", 'contractKind')
.addSelect('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.groupBy('b.status')
.addGroupBy('b.trade_direction')
.addGroupBy('o.label')
.addGroupBy('cty.cargo_type_name')
.addGroupBy('b.contract_kind');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'bookings')
.addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons')
.addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'amount')
.getRawOne();
return [
{ label: 'Bookings', value: Number(row?.bookings ?? 0) },
{ label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' },
{ label: 'Amount', value: Number(row?.amount ?? 0), unit: 'ETB' },
];
},
};

View File

@@ -0,0 +1,68 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ADD = allocated, REMOVE = cancelled. SWITCH (a physical wagon swap, net
// count unchanged) is excluded — it's neither an allocation nor a cancellation.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(ScheduleWagonAdjustmentLog, 'l')
.leftJoin(TrainSchedule, 'ts', 'ts.id = l.train_schedule_id')
.where('l.deleted_at IS NULL')
.andWhere("l.action IN ('ADD', 'REMOVE')");
if (params.dateFrom) qb.andWhere('l.occurred_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('l.occurred_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const globalLogisticsWagonsReport: ReportDefinition = {
key: 'global-logistics-wagons',
title: 'Wagon Allocations by Day',
description: 'Wagons allocated vs. cancelled per day, by direction',
group: 'Operations',
filters: [
{ key: 'date', label: 'Date', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'date', label: 'Date', type: 'date', sortable: true, sortExpr: `date_trunc('day', l.occurred_at)` },
{ key: 'direction', label: 'Direction', type: 'string', sortable: true },
{ key: 'allocated', label: 'Allocated', type: 'number', sortable: true },
{ key: 'cancelled', label: 'Cancelled', type: 'number', sortable: true },
],
defaultSort: { key: 'date', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select(`to_char(date_trunc('day', l.occurred_at), 'YYYY-MM-DD')`, 'date')
.addSelect("COALESCE(ts.direction, 'Unknown')", 'direction')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.groupBy(`date_trunc('day', l.occurred_at)`)
.addGroupBy('ts.direction');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select("COUNT(*) FILTER (WHERE l.action = 'ADD')::int", 'allocated')
.addSelect("COUNT(*) FILTER (WHERE l.action = 'REMOVE')::int", 'cancelled')
.getRawOne();
return [
{ label: 'Allocated', value: Number(row?.allocated ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,78 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// train_set_wagons.assigned_weight_tons is the planned load per slot, already
// maintained by the wagon-allocation flow — no need to re-derive it from
// bulk/container line items.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSetWagon, 'tsw')
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id')
.leftJoin(WagonType, 'wt', 'wt.id = tsw.wagon_type_id')
.where('tsw.deleted_at IS NULL AND ts.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const loadedCapacityReport: ReportDefinition = {
key: 'loaded-capacity',
title: 'Loaded Capacity',
description: 'Nameplate vs. loaded capacity per train, by wagon type',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'capacityTons', label: 'Capacity', type: 'tons', sortable: true },
{ key: 'loadedTons', label: 'Loaded', type: 'tons', sortable: true },
{ key: 'utilizationPct', label: 'Utilization', type: 'percent', sortable: true },
],
defaultSort: { key: 'loadedTons', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.addSelect(
`CASE WHEN COALESCE(SUM(tsw.capacity_tons), 0) > 0
THEN ROUND(SUM(tsw.assigned_weight_tons) / SUM(tsw.capacity_tons) * 100)::float8 END`,
'utilizationPct',
)
.groupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date')
.addGroupBy('wt.name');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'wagons')
.addSelect('COALESCE(SUM(tsw.capacity_tons), 0)::float8', 'capacityTons')
.addSelect('COALESCE(SUM(tsw.assigned_weight_tons), 0)::float8', 'loadedTons')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Capacity', value: Number(row?.capacityTons ?? 0), unit: 't' },
{ label: 'Loaded', value: Number(row?.loadedTons ?? 0), unit: 't' },
];
},
};

View File

@@ -0,0 +1,100 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule, TRAIN_SCHEDULE_STATUSES } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// ITLMS's spec lists Scheduled/Dispatched/In Transit/Arrived/Cancelled as the
// train lifecycle. The platform tracks DRAFT/SCHEDULED/DISPATCHED/ARRIVED/
// CANCELLED — no separate "in transit" status exists (a dispatched schedule
// with no actual_arrival_at yet *is* in transit; reported as DISPATCHED).
const STATUS_OPTIONS = TRAIN_SCHEDULE_STATUSES.map((v) => ({ value: v, label: v }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL');
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) {
qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
}
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('ts.status IN (:...statuses)', { statuses });
return qb;
}
export const trainScheduleStatusReport: ReportDefinition = {
key: 'train-schedule-status',
title: 'Train Schedules',
description: 'Scheduled, dispatched, arrived and cancelled train departures',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departure', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'reference', label: 'Reference', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ts.status' },
{ key: 'direction', label: 'Direction', type: 'string' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'scheduledDeparture',
label: 'Scheduled dep.',
type: 'date',
sortable: true,
sortExpr: 'ts.scheduled_departure_date',
},
{ key: 'actualDeparture', label: 'Actual dep.', type: 'date' },
{ key: 'actualArrival', label: 'Actual arr.', type: 'date' },
],
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect('ts.reference', 'reference')
.addSelect('ts.status', 'status')
.addSelect('ts.direction', 'direction')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'scheduledDeparture')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :scheduled)::int', 'scheduled')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :dispatched)::int', 'dispatched')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :arrived)::int', 'arrived')
.addSelect('COUNT(*) FILTER (WHERE ts.status = :cancelled)::int', 'cancelled')
.setParameters({ scheduled: 'SCHEDULED', dispatched: 'DISPATCHED', arrived: 'ARRIVED', cancelled: 'CANCELLED' })
.getRawOne();
return [
{ label: 'Total', value: Number(row?.total ?? 0) },
{ label: 'Scheduled', value: Number(row?.scheduled ?? 0) },
{ label: 'Dispatched', value: Number(row?.dispatched ?? 0) },
{ label: 'Arrived', value: Number(row?.arrived ?? 0) },
{ label: 'Cancelled', value: Number(row?.cancelled ?? 0) },
];
},
};

View File

@@ -0,0 +1,86 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// "Turnaround" here is departure-to-arrival transit time on the actual (not
// scheduled) timestamps. Station dwell time (arrival -> the SAME train's next
// departure) would need pairing consecutive schedules by physical train,
// which isn't tracked directly — deferred, not modeled as a shortcut.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(TrainSchedule, 'ts')
.leftJoin(Yard, 'o', 'o.id = ts.origin_station_id')
.leftJoin(Yard, 'd', 'd.id = ts.destination_station_id')
.where('ts.deleted_at IS NULL')
.andWhere('ts.actual_departure_at IS NOT NULL')
.andWhere('ts.actual_arrival_at IS NOT NULL');
if (params.dateFrom) qb.andWhere('ts.actual_departure_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('ts.actual_departure_at < :dateTo', { dateTo: params.dateTo });
if (params.direction) qb.andWhere('ts.direction = :direction', { direction: params.direction });
return qb;
}
export const trainTurnaroundReport: ReportDefinition = {
key: 'train-turnaround',
title: 'Train Turnaround',
description: 'Actual departure-to-arrival transit time per schedule',
group: 'Operations',
filters: [
{ key: 'date', label: 'Departed', type: 'daterange' },
{
key: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' },
{ value: 'DOMESTIC', label: 'Domestic' },
],
},
],
columns: [
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'origin', label: 'Origin', type: 'string' },
{ key: 'destination', label: 'Destination', type: 'string' },
{
key: 'actualDeparture',
label: 'Departed',
type: 'date',
sortable: true,
sortExpr: 'ts.actual_departure_at',
},
{ key: 'actualArrival', label: 'Arrived', type: 'date' },
{ key: 'transitHours', label: 'Transit (hrs)', type: 'number', sortable: true },
],
defaultSort: { key: 'actualDeparture', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('ts.train_number', 'trainNumber')
.addSelect("COALESCE(o.label, 'Unknown')", 'origin')
.addSelect("COALESCE(d.label, 'Unknown')", 'destination')
.addSelect(`to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI')`, 'actualDeparture')
.addSelect(`to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI')`, 'actualArrival')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric / 3600, 1)::float8`,
'transitHours',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'trips')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at)))::numeric / 3600, 1)::float8`,
'avgHours',
)
.getRawOne();
return [
{ label: 'Trips', value: Number(row?.trips ?? 0) },
{ label: 'Avg transit', value: Number(row?.avgHours ?? 0), unit: 'h' },
];
},
};

View File

@@ -0,0 +1,76 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { Container } from '../../container-management/entities/container.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// TEU = container size in feet / 20 (20ft -> 1 TEU, 40ft -> 2 TEU). Scoped to
// each wagon's CURRENT schedule pin — a live-state view, not a historical one.
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.innerJoin(TrainSchedule, 'ts', 'ts.id = w.current_train_schedule_id')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Container, 'c', 'c.wagon_id = w.id AND c.deleted_at IS NULL')
.leftJoin(ContainerType, 'ct', 'ct.id = c.container_type_id')
.where('w.deleted_at IS NULL');
if (params.trainNumber) {
qb.andWhere('ts.train_number ILIKE :trainNumber', { trainNumber: `%${params.trainNumber}%` });
}
if (params.dateFrom) {
qb.andWhere('ts.scheduled_departure_date >= :dateFrom', { dateFrom: params.dateFrom });
}
if (params.dateTo) qb.andWhere('ts.scheduled_departure_date < :dateTo', { dateTo: params.dateTo });
return qb;
}
export const wagonTeuUtilizationReport: ReportDefinition = {
key: 'wagon-teu-utilization',
title: 'Wagon TEU Utilization',
description: 'TEU loaded per wagon on its currently assigned train',
group: 'Operations',
filters: [
{ key: 'trainNumber', label: 'Train No.', type: 'text' },
{ key: 'date', label: 'Departure', type: 'daterange' },
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
{ key: 'departureDate', label: 'Departure', type: 'date' },
{ key: 'containers', label: 'Containers', type: 'number', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
],
defaultSort: { key: 'teu', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('ts.train_number', 'trainNumber')
.addSelect(`to_char(ts.scheduled_departure_date, 'YYYY-MM-DD')`, 'departureDate')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.groupBy('w.wagon_number')
.addGroupBy('wt.name')
.addGroupBy('ts.train_number')
.addGroupBy('ts.scheduled_departure_date');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(DISTINCT w.id)::int', 'wagons')
.addSelect('COUNT(c.id)::int', 'containers')
.addSelect('(COALESCE(SUM(ct.size_ft), 0) / 20.0)::float8', 'teu')
.getRawOne();
return [
{ label: 'Wagons', value: Number(row?.wagons ?? 0) },
{ label: 'Containers', value: Number(row?.containers ?? 0) },
{ label: 'Total TEU', value: Number(row?.teu ?? 0) },
];
},
};

View File

@@ -7,6 +7,12 @@ import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'
import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report';
import { wagonRequestsReport } from './definitions/wagon-requests.report';
import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report';
import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report';
import { trainScheduleStatusReport } from './definitions/train-schedule-status.report';
import { trainTurnaroundReport } from './definitions/train-turnaround.report';
import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report';
import { loadedCapacityReport } from './definitions/loaded-capacity.report';
import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report';
import { ReportDefinition } from './report.types';
/**
@@ -23,6 +29,12 @@ export const REPORTS: ReportDefinition[] = [
wagonStatusDurationReport,
wagonRequestsReport,
locomotiveFleetStatusReport,
bookingStatusBreakdownReport,
trainScheduleStatusReport,
trainTurnaroundReport,
wagonTeuUtilizationReport,
loadedCapacityReport,
globalLogisticsWagonsReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));

View File

@@ -63,6 +63,12 @@ export const REPORT_KEYS = [
"wagon-status-duration",
"wagon-requests",
"locomotive-fleet-status",
"booking-status-breakdown",
"train-schedule-status",
"train-turnaround",
"wagon-teu-utilization",
"loaded-capacity",
"global-logistics-wagons",
] as const;
export type ReportKey = (typeof REPORT_KEYS)[number];