feat(freight-api): add 4 fleet-ops reports

wagon-fleet-status, wagon-status-duration, wagon-requests,
locomotive-fleet-status. First batch off the ITLMS dashboard spec —
fleet data (wagons/locomotives/transfer-requests) needed no schema
work, just resolvers. No frontend changes: catalog is server-driven.
This commit is contained in:
Nathnael
2026-08-13 08:08:13 +00:00
parent 9930bef8aa
commit c0cdf80560
6 changed files with 334 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Locomotive, LOCOMOTIVE_STATUSES } from '../../locomotives/entities/locomotive.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = LOCOMOTIVE_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Locomotive, 'l')
.leftJoin(Yard, 'y', 'y.id = l.current_yard_id')
.where('l.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('l.status IN (:...statuses)', { statuses });
return qb;
}
export const locomotiveFleetStatusReport: ReportDefinition = {
key: 'locomotive-fleet-status',
title: 'Locomotive Fleet Status',
description: 'Locomotive counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'locomotiveType', label: 'Type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('l.locomotive_type', 'locomotiveType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('l.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('l.locomotive_type')
.addGroupBy('y.label')
.addGroupBy('l.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE l.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE l.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE l.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE l.status = :outOfService)::int', 'outOfService')
.setParameters({
available: 'AVAILABLE',
assigned: 'ASSIGNED',
maintenance: 'MAINTENANCE',
outOfService: 'OUT_OF_SERVICE',
})
.getRawOne();
return [
{ label: 'Total locomotives', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,76 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
.where('w.deleted_at IS NULL');
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('w.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonFleetStatusReport: ReportDefinition = {
key: 'wagon-fleet-status',
title: 'Wagon Fleet Status',
description: 'Wagon counts by type, station and status',
group: 'Operations',
filters: [{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }],
columns: [
{ key: 'wagonType', label: 'Wagon type', type: 'string', sortable: true },
{ key: 'station', label: 'Station', type: 'string', sortable: true },
{ key: 'status', label: 'Status', type: 'string', sortable: true },
{ key: 'count', label: 'Count', type: 'number', sortable: true },
],
defaultSort: { key: 'count', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('COALESCE(wt.name, \'Unknown\')', 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('wt.name')
.addGroupBy('y.label')
.addGroupBy('w.status');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'total')
.addSelect('COUNT(*) FILTER (WHERE w.status = :available)::int', 'available')
.addSelect('COUNT(*) FILTER (WHERE w.status = :assigned)::int', 'assigned')
.addSelect('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect('COUNT(*) FILTER (WHERE w.status = :outOfService)::int', 'outOfService')
.setParameters({
available: WagonStatus.Available,
assigned: WagonStatus.Assigned,
maintenance: WagonStatus.Maintenance,
detained: WagonStatus.Detained,
outOfService: WagonStatus.OutOfService,
})
.getRawOne();
return [
{ label: 'Total wagons', value: Number(row?.total ?? 0) },
{ label: 'Available', value: Number(row?.available ?? 0) },
{ label: 'Assigned', value: Number(row?.assigned ?? 0) },
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Out of service', value: Number(row?.outOfService ?? 0) },
];
},
};

View File

@@ -0,0 +1,85 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonTransferRequestStatus } from '@edr/types';
import { WagonTransferRequest } from '../../wagons/entities/wagon-transfer-request.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const STATUS_OPTIONS = Object.values(WagonTransferRequestStatus).map((v) => ({
value: v,
label: v.replace(/_/g, ' '),
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(WagonTransferRequest, 'r')
.leftJoin(Yard, 'fy', 'fy.id = r.from_yard_id')
.leftJoin(Yard, 'ty', 'ty.id = r.to_yard_id')
.leftJoin(WagonType, 'wt', 'wt.id = r.wagon_type_id')
.where('r.deleted_at IS NULL');
if (params.dateFrom) qb.andWhere('r.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('r.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('r.status IN (:...statuses)', { statuses });
return qb;
}
export const wagonRequestsReport: ReportDefinition = {
key: 'wagon-requests',
title: 'Wagon Requests',
description: 'Inter-yard wagon transfer requests and fulfilment delay',
group: 'Operations',
filters: [
{ key: 'date', label: 'Requested', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
],
columns: [
{ key: 'fromYard', label: 'From', type: 'string', sortable: true, sortExpr: 'fy.label' },
{ key: 'toYard', label: 'To', type: 'string', sortable: true, sortExpr: 'ty.label' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'quantity', label: 'Requested', type: 'number' },
{ key: 'fulfilledQuantity', label: 'Fulfilled', type: 'number' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'r.status' },
{ key: 'requestedAt', label: 'Requested at', type: 'date', sortable: true, sortExpr: 'r.created_at' },
{ key: 'fulfilledAt', label: 'Fulfilled at', type: 'date' },
{ key: 'delayDays', label: 'Delay (days)', type: 'number', sortable: true },
],
defaultSort: { key: 'requestedAt', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('fy.label', 'fromYard')
.addSelect('ty.label', 'toYard')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect('r.quantity', 'quantity')
.addSelect('r.fulfilled_quantity', 'fulfilledQuantity')
.addSelect('r.status', 'status')
.addSelect(`to_char(r.created_at, 'YYYY-MM-DD')`, 'requestedAt')
.addSelect(`to_char(r.fulfilled_at, 'YYYY-MM-DD')`, 'fulfilledAt')
.addSelect(
`ROUND(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400, 1)::float8`,
'delayDays',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*)::int', 'requests')
.addSelect('COUNT(*) FILTER (WHERE r.status IN (:...openStatuses))::int', 'open')
.addSelect(
`ROUND(AVG(EXTRACT(EPOCH FROM (COALESCE(r.fulfilled_at, now()) - r.created_at))::numeric / 86400), 1)::float8`,
'avgDelayDays',
)
.setParameters({
openStatuses: [WagonTransferRequestStatus.Pending, WagonTransferRequestStatus.PartiallyFulfilled],
})
.getRawOne();
return [
{ label: 'Requests', value: Number(row?.requests ?? 0) },
{ label: 'Still open', value: Number(row?.open ?? 0) },
{ label: 'Avg delay', value: Number(row?.avgDelayDays ?? 0), unit: 'd' },
];
},
};

View File

@@ -0,0 +1,94 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ReportContext, ReportDefinition } from '../report.types';
// Only these two statuses have an operational "how long has it been stuck
// here" question — everything else (Available, Assigned, ...) turns over too
// fast for a days-in-status view to matter.
const TRACKED_STATUSES = [WagonStatus.Maintenance, WagonStatus.Detained];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Wagon, 'w')
.leftJoin(WagonType, 'wt', 'wt.id = w.wagon_type_id')
.leftJoin(Yard, 'y', 'y.id = w.current_yard_id')
// Latest time each wagon flipped INTO its current status, per (wagon, status)
// pair — a plain (non-correlated) derived table, joined on both columns, so
// it stays a normal JOIN rather than needing a LATERAL correlated subquery.
.leftJoin(
(sub) =>
sub
.select('l.wagon_id', 'wagon_id')
.addSelect('l.to_status', 'to_status')
.addSelect('MAX(l.created_at)', 'since')
.from('freight.wagon_status_logs', 'l')
.groupBy('l.wagon_id')
.addGroupBy('l.to_status'),
'log',
'log.wagon_id = w.id AND log.to_status = w.status',
)
.where('w.deleted_at IS NULL')
.andWhere('w.status IN (:...trackedStatuses)', { trackedStatuses: TRACKED_STATUSES });
const status = params.status as string | null;
if (status) qb.andWhere('w.status = :status', { status });
return qb;
}
export const wagonStatusDurationReport: ReportDefinition = {
key: 'wagon-status-duration',
title: 'Wagon Status Duration',
description: 'How long each wagon has sat in Maintenance or Detained',
group: 'Operations',
filters: [
{
key: 'status',
label: 'Status',
type: 'select',
options: TRACKED_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })),
},
],
columns: [
{ key: 'wagonNumber', label: 'Wagon', type: 'string', sortable: true, sortExpr: 'w.wagon_number' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'station', label: 'Station', type: 'string' },
{ key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'w.status' },
{ key: 'since', label: 'Since', type: 'date', sortable: true },
{ key: 'daysInStatus', label: 'Days in status', type: 'number', sortable: true },
],
defaultSort: { key: 'daysInStatus', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('w.wagon_number', 'wagonNumber')
.addSelect("COALESCE(wt.name, 'Unknown')", 'wagonType')
.addSelect("COALESCE(y.label, 'Unassigned')", 'station')
.addSelect('w.status', 'status')
.addSelect(`to_char(COALESCE(log.since, w.updated_at), 'YYYY-MM-DD')`, 'since')
.addSelect(
`FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400)::int`,
'daysInStatus',
);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('COUNT(*) FILTER (WHERE w.status = :maintenance)::int', 'maintenance')
.addSelect('COUNT(*) FILTER (WHERE w.status = :detained)::int', 'detained')
.addSelect(
`MAX(FLOOR(EXTRACT(EPOCH FROM (now() - COALESCE(log.since, w.updated_at))) / 86400))::int`,
'longest',
)
.setParameters({ maintenance: WagonStatus.Maintenance, detained: WagonStatus.Detained })
.getRawOne();
return [
{ label: 'Under maintenance', value: Number(row?.maintenance ?? 0) },
{ label: 'Detained', value: Number(row?.detained ?? 0) },
{ label: 'Longest days in status', value: Number(row?.longest ?? 0), unit: 'd' },
];
},
};

View File

@@ -3,6 +3,10 @@ import { bookingsListReport } from './definitions/bookings-list.report';
import { revenueByCustomerReport } from './definitions/revenue-by-customer.report';
import { agingReceivablesReport } from './definitions/aging-receivables.report';
import { contractUtilizationReport } from './definitions/contract-utilization.report';
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 { ReportDefinition } from './report.types';
/**
@@ -15,6 +19,10 @@ export const REPORTS: ReportDefinition[] = [
revenueByCustomerReport,
agingReceivablesReport,
contractUtilizationReport,
wagonFleetStatusReport,
wagonStatusDurationReport,
wagonRequestsReport,
locomotiveFleetStatusReport,
];
const BY_KEY = new Map<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));

View File

@@ -59,6 +59,10 @@ export const REPORT_KEYS = [
"revenue-by-customer",
"aging-receivables",
"contract-utilization",
"wagon-fleet-status",
"wagon-status-duration",
"wagon-requests",
"locomotive-fleet-status",
] as const;
export type ReportKey = (typeof REPORT_KEYS)[number];