diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts new file mode 100644 index 000000000..a01f714ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts new file mode 100644 index 000000000..e23ce5469 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/global-logistics-wagons.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts new file mode 100644 index 000000000..8827b77b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/loaded-capacity.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts new file mode 100644 index 000000000..88a25d890 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-schedule-status.report.ts @@ -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 { + 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) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts new file mode 100644 index 000000000..7dc7b8c3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/train-turnaround.report.ts @@ -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 { + 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' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts new file mode 100644 index 000000000..7dabec37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/wagon-teu-utilization.report.ts @@ -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 { + 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) }, + ]; + }, +}; 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 dfc6ff002..0d9b1d3a2 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -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(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 f9a0f959f..9cddfde87 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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];