diff --git a/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts new file mode 100644 index 000000000..f2b9377f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/locomotive-fleet-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts new file mode 100644 index 000000000..1371936f5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-fleet-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts new file mode 100644 index 000000000..df5ac364c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-requests.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts new file mode 100644 index 000000000..348f681d1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-status-duration.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 26474d025..dfc6ff002 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -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(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7470ed2b5..f9a0f959f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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];