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