Files
edr-platform/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts
Nathnael 0e9cfbce66 fix(reports): count container tonnage in exports and reports
Every SQL tonnage in the export datasets and report definitions used
`COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)`. COALESCE
falls through on NULL, never on 0 — and the portal booking wizard stores
`cargo_total_weight_vgm = 0` for container freight on purpose, because VGM
is captured per container line, not as a booking-level figure. So every
portal-created container booking reported as weighing nothing. The
backoffice wizard does store a booking-level total, so the same table holds
both shapes and the numbers looked erratic rather than uniformly zero.

Extract the resolver the TypeScript side already has three copies of
(bookingCargoTons, cargoTonsAndItems, totalVgmTons) into one SQL helper:
NULLIF both booking-level columns, then fall back to
SUM(booking_container.total_vgm_tons). Applied to the bookings and
train-schedules export datasets, the cargo-summary, contract-utilization and
booking-status-breakdown reports, and the intercity booking list.

On dev data this recovers 116 of 154 zero-weight container bookings and
raises live booking tonnage from 42,973 t to 61,424 t.
2026-08-28 09:20:46 +00:00

140 lines
9.1 KiB
TypeScript

import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Route } from '../../routes/entities/route.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
/**
* `freightType` is NOT a column on train_schedules — it is derived from the
* bookings aboard (the list service does this with an EXISTS subquery). Exposed
* here as an aggregate over those bookings, never as `sch.freight_type`.
*/
export const trainSchedulesDataset: ExportDataset = {
key: 'train-schedules',
title: 'Train schedules',
description: 'Scheduled trains with route, timings, booking window and load',
group: 'Operations',
permission: FREIGHT_PERMS.trainScheduling.view,
base: { entity: TrainSchedule, alias: 'sch' },
joins: [
{ alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' },
{ alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' },
{ alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' },
{ alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' },
],
groups: [
{ id: 'schedule', label: 'Schedule' },
{ id: 'route', label: 'Route' },
{ id: 'timings', label: 'Timings' },
{ id: 'window', label: 'Booking window' },
{ id: 'load', label: 'Load' },
],
fields: [
{ key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' },
{ key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' },
{ key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' },
{ key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' },
{ key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' },
{ key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' },
{ key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' },
{ key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' },
{ key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' },
{ key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' },
{ key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' },
{ key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' },
{ key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' },
{ key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' },
{ key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' },
{ key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` },
{ key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` },
{
key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings',
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`,
},
{
key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings',
select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`,
},
{ key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' },
{ key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' },
{ key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` },
{ key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' },
{ key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' },
{
key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true,
select: `(SELECT COUNT(*)::int FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
// Derived, not a column — see the file header.
key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true,
select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8
FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},
{
key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load',
select: `(SELECT COUNT(*)::int FROM freight.wagons w
WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`,
},
],
filters: [
{ key: 'departure', label: 'Departure', type: 'daterange' },
{ key: 'status', label: 'Status', type: 'text' },
{ key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) },
// freightType is derived from the bookings aboard, so it filters via
// EXISTS — the same shape the list service's scheduleFreightTypeFilter uses.
{ key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) },
{ key: 'originStationId', label: 'Origin', type: 'text' },
{ key: 'destinationStationId', label: 'Destination', type: 'text' },
{ key: 'search', label: 'Search reference or train number', type: 'text' },
],
defaultSort: { key: 'scheduledDeparture', dir: 'DESC' },
scope(ctx, qb) {
const { params, directions } = ctx;
qb.andWhere('sch.deleted_at IS NULL');
if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom });
if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo });
if (params.status) qb.andWhere('sch.status = :status', { status: params.status });
if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction });
if (params.freightType) {
qb.andWhere(
`EXISTS (SELECT 1 FROM freight.bookings fb
WHERE fb.train_schedule_id = sch.id AND fb.deleted_at IS NULL
AND fb.freight_type = :freightType)`,
{ freightType: params.freightType },
);
}
if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId });
if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId });
if (params.search) {
qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` });
}
// Schedules carry their own `direction` column, so scope on that directly
// rather than through the bookings aboard.
applyDirectionScope(qb, 'sch.direction', directions);
},
};