mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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]));
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user