Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
2026-08-12 14:12:23 +00:00

9768 lines
405 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
AllocationLoadType,
Freight,
LoadingStatus,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
WagonMovementKind,
WagonStatus,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import {
DataSource,
EntityManager,
FindOptionsWhere,
ILike,
In,
IsNull,
Not,
QueryFailedError,
Raw,
} from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../../common/utils/pagination.util';
import { BookingsRepository } from '../../bookings/bookings.repository';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
import { Contract } from '../../contracts/entities/contract.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from '../dto/list-train-schedules-query.dto';
import { PinWagonsDto } from '../dto/pin-wagons.dto';
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
type ImportDjiboutiDocumentType,
} from '../entities/import-djibouti-operation.entity';
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from '../booking-window.config';
import { BookingWindowGateway } from '../booking-window.gateway';
import { BookingNotifierService } from '../booking-notifier.service';
import { BookingBatchService } from '../booking-batch.service';
import {
computeFleetAvailability,
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from '../utils/fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from '../wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
getContainerSlotSequenceNos,
roundTons,
sumWagonsRequired,
type TrainLimitConfig,
maxEdgeConsistUsage,
perEdgeConsistUsage,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
consistViolations,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
trainHardCaps,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
WagonTypeDimensions,
} from '../train-capacity.util';
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
shiftEatDay,
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
Object.entries(source).filter(([, v]) => v !== undefined),
) as Partial<T>;
}
/**
* The booking-window rule fields frozen onto a train schedule at creation (and
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
* its display cycles from this snapshot, so a later global-rules edit never redraws
* an already-open schedule's windows. The reopen gap is derived here — doc review +
* payment — because that is the real delay between a cycle closing and reopening.
*/
function windowRuleSnapshot(cfg: BookingWindowConfig) {
return {
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowCloseHour: cfg.windowCloseHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
* config, with the live config filling any snapshot field a legacy row lacks.
*
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
* the runtime cycle engine matches exactly what the board drew and the customer
* saw — a later global-rule edit must not retro-change an existing train. The
* doc-review / payment split is an internal process timing (not part of the
* window the customer sees) and is not stored split in the snapshot, so it always
* takes the live values; their sum is only used as a fallback reopen gap when the
* row predates `ruleReopenDelayMinutes`.
*/
export function effectiveWindowConfig(
schedule: {
ruleWindowOpenHour?: number | null;
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
rulePaymentWindowMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
ruleImportCloseOffsetMinutes?: number | null;
ruleExportCloseOffsetMinutes?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
return {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
// Pay windows read live values unless staff explicitly overrode this ONE
// schedule (rule_payment_window_minutes is only ever written by that
// override, never stamped at creation). The override wins for whichever
// direction the schedule runs.
paymentWindowMinutes:
schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
exportPaymentWindowMinutes:
schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes,
// The close offset is frozen per-schedule: a snapshot value of null means
// "created with no offset" and must NOT inherit a later live offset (that
// would retro-shrink an open train's window). Only a truly legacy row that
// predates the snapshot column (value undefined) falls back to live config.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
}
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BookingWagonAllocationIssue {
bookingId: string;
status: BookingWagonAllocationStatus;
issue: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: DeferredBookingRow[];
issues: BookingWagonAllocationIssue[];
violations: string[];
}
export interface CompositionUnassignedBookingRow {
id: string;
reference: string | null;
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
shortage: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
train_number: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
doc_review_ends_at: Date | null;
payment_phase_ends_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
route_stations: string[] | null;
}
@Injectable()
export class TrainSchedulingService {
private readonly logger = new Logger(TrainSchedulingService.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly locomotivesRepository: LocomotivesRepository,
// Kept in the DI signature for constructor-arity stability (specs mock it);
// wagon-type resolution now flows through the config lists on the bookings.
_wagonTypesRepository: WagonTypesRepository,
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
private readonly bookingJourneyService: BookingJourneyService,
private readonly bookingNotifier: BookingNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
// forwardRef: BookingBatchService injects this service back; @Optional so
// existing specs that construct the service without it keep working.
@Optional()
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService?: BookingBatchService,
) {}
/**
* Notify each booking's customer that their shipment was dispatched / arrived,
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
*/
private async notifyScheduleBookings(
schedule: TrainSchedule,
event: 'dispatched' | 'arrived',
): Promise<void> {
try {
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
if (!ids.length) return;
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
const destination =
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
const bookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(ids) },
relations: { company: true },
});
for (const b of bookings) {
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
else this.bookingNotifier.arrived(b, origin, destination);
}
} catch (err) {
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
}
}
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
* bookings without milestone rows (non-customs bookings), so this is safe to
* call for every direction and flow. Never blocks the operational action.
*/
private async completeMilestonesForScheduleBookings(
scheduleId: string,
codes: string[],
filter?: { originYardId?: string; destinationYardId?: string },
): Promise<void> {
if (!this.milestoneService || codes.length === 0) return;
try {
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
const params: unknown[] = [scheduleId];
if (filter?.originYardId) {
params.push(filter.originYardId);
conditions.push(`b.origin_yard_id = $${params.length}`);
}
if (filter?.destinationYardId) {
params.push(filter.destinationYardId);
conditions.push(`b.destination_yard_id = $${params.length}`);
}
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
`SELECT tsb.booking_id
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id
WHERE ${conditions.join(' AND ')}`,
params,
);
for (const { booking_id } of rows) {
for (const code of codes) {
await this.milestoneService.completeByDocTrigger(
{ bookingId: booking_id },
code,
);
}
}
} catch (err) {
this.logger.warn(
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* Push a schedule's current booking-window state over the socket so the
* portal home card and backoffice GL/batch views update in real time —
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
* group together, since the route entity keys only origin + destination). All
* schedules in a group must run ONE shared booking-window timeline so a
* customer booking on a later-created train is never expired by a sibling
* train's payment window closing on a different clock.
*
* Grouping keys off the columns the schedule already carries — no new schema.
* Callers pass a live `manager` so both the create (inside its transaction) and
* the update paths see uncommitted siblings.
*/
private async findGroupSiblings(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule[]> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
// A cancelled train is not a sibling: cancel retires its window as DONE,
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getMany();
}
/**
* A built train makes at most ONE departure per route per EAT day. Returns
* the non-cancelled schedule already holding this train on this route for
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
* legal — siblings must be different trains.
*/
private async findTrainRouteDayConflict(
trainId: string,
routeId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule | null> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = this.dataSource
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.innerJoin('s.trainSet', 'ts')
.where('ts.trainId = :trainId', { trainId })
.andWhere('s.routeId = :routeId', { routeId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getOne();
}
/**
* The window timeline a brand-new schedule must adopt to join its route+day
* group. Returns the canonical open/close times + rule snapshot copied from an
* existing sibling, or null when this is the first schedule in the group (the
* caller then computes its own times as before — nothing changes for the
* single-schedule case).
*
* The anchor is the sibling that best represents where the GROUP currently is
* on its shared clock, so a train created mid-cycle joins the group AT its
* current phase (with the group's exact doc-review / payment deadlines) instead
* of restarting the whole cycle on its own `now`. When the group has advanced
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
* the phase the joiner must adopt to see the same "N minutes left" the group
* already shows. When every sibling is still PRE_WINDOW we pick the
* earliest-opening one (the group's frozen open clock).
*/
private async findGroupWindowAnchor(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
): Promise<TrainSchedule | null> {
const siblings = await this.findGroupSiblings(
manager,
originStationId,
destinationStationId,
departure,
);
if (siblings.length === 0) return null;
// A DONE window is retired (the day's last cycle already ran) — anchoring
// to it would hand the newborn a dead window no tick ever advances. With no
// live or pending sibling left, fall back to fresh times (return null).
const withWindow = siblings.filter(
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
);
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
// not yet DONE) — the joiner must land in that same phase on the same clock.
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
const live = withWindow.filter(
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
s.windowPhase !== 'PRE_WINDOW',
);
if (live.length > 0) {
// Most-advanced phase leads; ties broken by earliest open for determinism.
return live.reduce((best, s) => {
const a = PHASE_ORDER.indexOf(s.windowPhase!);
const b = PHASE_ORDER.indexOf(best.windowPhase!);
if (a !== b) return a > b ? s : best;
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
});
}
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
// defines the group clock (the one a customer would have seen first).
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
const pool = pending.length > 0 ? pending : withWindow;
return pool.reduce((earliest, s) =>
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
);
}
/**
* The full window state an anchor sibling hands down to a schedule JOINING its
* group. Copies not just the open/close times + frozen rule snapshot but the
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
* the group: it enters directly at the group's current phase with the group's
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
* `now` and stamping its own later `paymentPhaseEndsAt`.
*
* `targetDeparture` is the JOINING schedule's own departure: every time is
* clamped to it so a group whose trains depart at different times of the same
* day never hands an earlier-departing train a deadline that outlives its
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
* preserves that invariant when the anchor departed later).
*/
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
const cap = targetDeparture.getTime();
const clamp = (d: Date | null | undefined): Date | null =>
d == null ? null : d.getTime() > cap ? targetDeparture : d;
return {
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
windowPhase: anchor.windowPhase,
bookingWindowStatus:
anchor.bookingWindowStatus === 'FULL'
? 'OPEN'
: anchor.bookingWindowStatus,
bookingCycleNo: anchor.bookingCycleNo,
windowOpensAt: clamp(anchor.windowOpensAt),
windowClosesAt: clamp(anchor.windowClosesAt),
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
// Frozen rule snapshot.
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
};
}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
// resolving the schedule's route + day and filtering on the day instead.
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
// Corridor mode: a schedule with intermediate stops pools every booking
// whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just
// exact endpoint matches — otherwise a mid-corridor booking unassigned from
// a wagon vanishes from the "Paid · unassigned" pool forever.
let corridorStops: string[] | undefined;
if (query.trainScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
if (schedule?.scheduledDepartureDate) {
day = eatDay(schedule.scheduledDepartureDate);
originStationId = originStationId ?? schedule.originStationId;
destinationStationId = destinationStationId ?? schedule.destinationStationId;
const stops = await this.stopYardsForSchedule(schedule);
if (stops.length > 2) corridorStops = stops;
}
}
let bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId,
destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
day,
corridorYardIds: corridorStops,
});
if (corridorStops) {
// The IN-filter admits both yards anywhere on the route; only origin
// strictly before destination is actually rideable on this train.
const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i]));
bookings = bookings.filter((b) => {
const from = stopIdx.get(b.originYardId);
const to = stopIdx.get(b.destinationYardId);
return from != null && to != null && from < to;
});
}
const tareDims = await this.loadWagonTareDims();
return {
count: bookings.length,
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
};
}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
}
async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'BULK' });
}
async getTrainSchedulingGlobalRules() {
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
const {
maxTrainLengthMeters: _len,
maxTrainWeightTons: _wt,
max20ftContainerWeightTons: _cw,
max20ftPairWeightDiffTons: _pd,
...pub
} = row;
return pub;
}
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
const row = await this.loadGlobalRulesRow();
if (!row) {
throw new NotFoundException('Train scheduling global rules not configured');
}
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.exportPaymentWindowMinutes != null)
row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes;
// Store 0 as null so "no offset" is a single canonical value.
if (dto.importCloseOffsetMinutes !== undefined)
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
if (dto.exportCloseOffsetMinutes !== undefined)
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
// overnight range that wraps past midnight (openHour > closeHour, e.g.
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowCloseHour != null ||
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null ||
dto.importCloseOffsetMinutes !== undefined ||
dto.exportCloseOffsetMinutes !== undefined;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.save(row);
// The cron reads config fresh every tick, so derived timings (doc review,
// payment, reopen) take effect on the next tick with no restart. But each
// schedule's initial open/close times were FROZEN at creation — re-stamp the
// ones whose window has not opened yet so a config edit applies to them too.
if (windowTimingChanged) {
await this.restampPendingWindows();
}
return this.toPublicGlobalRules(saved);
}
/**
* Override the booking-window rule for ONE schedule (staff action on the ops
* board). Only the fields provided are changed; the rest keep the schedule's
* existing snapshot (falling back to the live global config for legacy rows).
* The window must not have opened yet — an OPEN/past schedule stays frozen so
* customers keep the times they were shown. windowOpensAt/ClosesAt are
* re-derived from the merged rule, and the snapshot is updated so the board
* draws the new cycles.
*/
async updateScheduleWindowRule(
id: string,
dto: UpdateScheduleWindowRuleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'Booking window settings can only be changed before the window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
throw new BadRequestException(
'This schedule has already departed or has no departure date.',
);
}
// Merge the override onto the schedule's current effective rule (its snapshot,
// or the live config where a legacy row has no snapshot).
const liveCfg = await this.getWindowConfig();
const merged: BookingWindowConfig = {
importWindowLeadDays:
dto.importWindowLeadDays ??
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
dto.windowDurationHours ??
(schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours),
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes:
dto.paymentWindowMinutes ??
schedule.rulePaymentWindowMinutes ??
liveCfg.paymentWindowMinutes,
exportPaymentWindowMinutes:
dto.paymentWindowMinutes ??
schedule.rulePaymentWindowMinutes ??
liveCfg.exportPaymentWindowMinutes,
// A per-schedule override isn't a close-offset control, so inherit the
// offset already frozen on the schedule (null = none), or the live one for
// legacy rows — the override must not silently drop the global offset.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
// WHOLE group — every schedule sharing this origin + destination + EAT
// departure day. They all adopt the SAME rule snapshot and share the SAME
// window open/close timeline (the whole point of grouping). The shared times
// are clamped to each train's OWN departure so a group whose trains depart at
// different times of the same day never hands an earlier-departing sibling a
// window that outlives its departure. Only still-PRE_WINDOW siblings are
// touched — a sibling that has already opened, finalized, or dispatched stays
// frozen on the times its customers were shown and simply drops out of the
// group; the remaining pending trains stay in sync. EXPORT is excluded
// (departure-anchored FCFS window, no cross-expiry), so an export override
// only touches its own schedule.
const ruleFields = windowRuleSnapshot(merged);
const repo = this.dataSource.getRepository(TrainSchedule);
const cap = (d: Date, departure: Date): Date =>
d.getTime() > departure.getTime() ? departure : d;
const targets: Array<{ id: string; departure: Date }> = [
{ id, departure: schedule.scheduledDepartureDate },
];
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
}
}
}
// The pay-window override persists only when staff actually sent it (or the
// schedule already had one) — windowRuleSnapshot never stamps it, so NULL
// keeps meaning "follow the live global value for my direction".
const rulePaymentWindowMinutes =
dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null;
for (const t of targets) {
await repo.update(t.id, {
windowOpensAt: cap(times.windowOpensAt, t.departure),
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
rulePaymentWindowMinutes,
// Deliberately overridden — exempt from the global re-stamp, which would
// otherwise revert this schedule the next time global rules are saved.
windowRuleCustom: true,
});
}
this.logger.log(
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
*
* Editable only until the train leaves: once DISPATCHED (or beyond) the
* numbers are printed on paperwork and quoted downstream, so a late edit would
* desync records that already left with the train. The audit row is written by
* the global AuditInterceptor from the registered route.
*/
async updateScheduleTrainNumber(
id: string,
dto: UpdateScheduleTrainNumberDto,
): Promise<TrainSchedule> {
if (dto.trainNumber === undefined && dto.voyageNumber === undefined) {
throw new BadRequestException(
'Provide a train number or a voyage number to update.',
);
}
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
// Only a train that has not left can be renumbered. CANCELLED is excluded
// too — renumbering a dead schedule has no meaning.
const editable: string[] = [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
];
if (!editable.includes(schedule.status)) {
throw new BadRequestException(
`Cannot change the train or voyage number of a ${schedule.status} schedule — ` +
'the numbers are fixed once the train is dispatched.',
);
}
// An empty string clears the field; an omitted field is left untouched.
const patch: Partial<TrainSchedule> = {};
if (dto.trainNumber !== undefined) {
patch.trainNumber = dto.trainNumber.trim() || null;
}
if (dto.voyageNumber !== undefined) {
patch.voyageNumber = dto.voyageNumber.trim() || null;
}
await this.trainSchedulesRepository.update(id, patch);
this.logger.log(
`Schedule ${schedule.reference ?? id} renumbered` +
(patch.trainNumber !== undefined
? ` — train ${schedule.trainNumber ?? '—'}${patch.trainNumber ?? '—'}`
: '') +
(patch.voyageNumber !== undefined
? ` — voyage ${schedule.voyageNumber ?? '—'}${patch.voyageNumber ?? '—'}`
: '') +
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Reschedule ONE train's departure date (staff action on the ops board). Only
* allowed while the booking window has not opened yet — an OPEN/past schedule
* stays frozen so customers keep the times they were shown. The new date must
* still leave room for the booking lead window before departure (same floor as
* schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
* open/close times are re-derived from the schedule's existing rule snapshot.
*/
async updateScheduleDate(
id: string,
dto: UpdateScheduleDateDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'The departure date can only be changed before the booking window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
const departure = new Date(dto.scheduleDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days;
// EXPORT lead is in hours. Mirrors the create-schedule check.
const windowCfg = await this.getWindowConfig();
const earliest = earliestSchedulableDeparture(
schedule.direction,
windowCfg,
now,
);
if (departure.getTime() < earliest.getTime()) {
const detail =
schedule.direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()}).`,
);
}
// Moving onto a day where this same built train already runs this route
// would double-book the physical train — blocked for planning moves.
if (schedule.trainSetId && schedule.routeId) {
const trainSet = await this.dataSource
.getRepository(TrainSet)
.findOne({ where: { id: schedule.trainSetId } });
if (trainSet?.trainId) {
const conflict = await this.findTrainRouteDayConflict(
trainSet.trainId,
schedule.routeId,
departure,
id,
);
if (conflict) {
throw new ConflictException(
`This train is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
}
}
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, now);
// Moving the departure moves this train between route+day GROUPS. If the
// destination day already has a group (a sibling on the same origin +
// destination + new EAT day), adopt that group's shared timeline instead of
// the times just derived, so the rescheduled train lines up with the group
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
// own re-derived times and becomes the anchor for that day. EXPORT is
// excluded — its window is anchored to its own departure, not shared.
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
const windowFields = anchor
? this.groupWindowFieldsFrom(anchor, departure)
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...windowFields,
});
// Only customers whose bookings already HOLD wagons on this train are told
// about the move (SMS + email + portal inbox). Linked-but-unallocated
// bookings are skipped — nothing of theirs is riding this departure yet.
let notifiedCount = 0;
if (schedule.trainSetId) {
const allocations = await this.dataSource
.getRepository(WagonBookingAllocation)
.createQueryBuilder('a')
.innerJoin('a.trainSetWagon', 'slot')
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
.getMany();
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
if (allocatedBookingIds.length) {
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(allocatedBookingIds) },
relations: { company: true },
});
for (const booking of allocatedBookings) {
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
this.bookingNotifier.rescheduled(booking, departure);
notifiedCount += 1;
}
}
}
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
`${notifiedCount} allocated customer booking(s) notified`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, new Date());
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
if (anchor) {
// groupWindowFieldsFrom copies the anchor's live phase and
// deadlines, so a DONE train joining a live group re-enters the
// group's cycle directly — no extra reset needed.
return this.groupWindowFieldsFrom(anchor, departure);
}
if (!reopenFromDone) {
return {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
};
}
const now = new Date();
const windowOpensAt =
times.windowOpensAt < now ? now : times.windowOpensAt;
if (times.windowClosesAt.getTime() <= windowOpensAt.getTime()) {
return {}; // no window fits before the new departure — stay closed
}
return {
windowOpensAt,
windowClosesAt: times.windowClosesAt,
windowPhase: 'PRE_WINDOW',
bookingWindowStatus: 'CLOSED',
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
};
})()
: {};
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
...windowFields,
});
// Everything aboard or targeted rides along: bookings linked on the train
// (schedule_bookings) plus reservations still pointing at it via
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
const targeted = await this.dataSource.getRepository(Booking).find({
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
relations: { company: true },
});
const aboard = targeted.filter(
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
);
if (aboard.length) {
await this.dataSource
.getRepository(Booking)
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
for (const booking of aboard) {
this.bookingNotifier.maintenanceMoved(booking, departure);
}
}
this.logger.log(
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
`${aboard.length} booking(s) moved with the train.`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped.
*/
async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig();
const now = new Date();
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
],
});
const repo = this.dataSource.getRepository(TrainSchedule);
let restamped = 0;
for (const s of schedules) {
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
// Hand-configured windows are not "pending the global rule" — staff picked
// these times deliberately, so a global-rules edit must leave them alone.
if (s.windowRuleCustom) continue;
const times =
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
// snapshot alongside the re-stamped times — the board then draws the new
// window from this same rule.
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(cfg),
});
restamped += 1;
// New times take effect immediately on every card (the tick then opens
// the window within seconds if the re-derived open is already due).
void this.emitWindowState(s.id);
}
if (restamped > 0) {
this.logger.log(
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
);
}
return restamped;
}
/**
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
* Numeric columns come back from pg as strings — normalize every field.
*/
async getWindowConfig(): Promise<BookingWindowConfig> {
const row = await this.loadGlobalRulesRow();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
// Offsets are optional: a missing/unset value means "no offset", not a
// numeric default — keep it null so bookingCloseCutoff falls back to
// departure. Zero and negatives are treated as "no offset" too.
const offset = (v: unknown): number | null => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
};
return {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
windowOpenHour: num(row?.windowOpenHour, 8),
windowCloseHour: num(row?.windowCloseHour, 17),
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60),
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
};
}
/**
* Limits for a preview aimed at an EXISTING schedule must be the schedule's
* own: its locomotive set and its built-consist wagon cap. Resolving from
* the dto alone re-derived the global wagon cap (53) and rejected a
* physically-coupled 54-wagon train the assign path would accept.
*/
private async resolvePreviewLimitConfig(dto: {
targetScheduleId?: string;
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
const target = dto.targetScheduleId
? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId)
: null;
if (!target) return this.resolveTrainLimitConfig(dto);
return this.resolveTrainLimitConfig(
dto,
combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)),
target.maxWagons ?? undefined,
);
}
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
null,
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
'CONTAINER',
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
const limits = await this.resolvePreviewLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
'BULK',
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
private buildPreviewResponse(validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>) {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
return {
valid: validation.valid,
violations: validation.violations,
warnings: validation.warnings,
summary: validation.summary,
fleetAvailability: validation.fleetAvailability,
deferredBookings: validation.deferredBookings,
bookingIds: validation.bookings.map((b) => b.id),
wagonPlan: validation.wagonPlan,
containerUnits: containerBookings.length
? expandBookingContainerUnits(containerBookings)
: [],
containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan),
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getSchedulableRoute(dto.routeId);
const scheduleWarnings: string[] = [];
// The pulling set comes either from a built train (Train Builder) or from
// hand-picked locomotive ids (legacy path). A built train also links the
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
// and yard follow the schedule.
let builtTrain: Train | null = null;
let locomotiveIds: string[];
if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId },
relations: { locomotives: true },
order: { locomotives: { sequenceNo: 'ASC' } },
});
if (!builtTrain) {
throw new NotFoundException(`Train ${dto.trainId} not found`);
}
if (
builtTrain.status === Freight.TrainStatus.OutOfService ||
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
builtTrain.status === Freight.TrainStatus.Deactivated
) {
throw new ConflictException(
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
);
}
locomotiveIds = (builtTrain.locomotives ?? [])
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 1) {
throw new BadRequestException(
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
scheduleWarnings.push(
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
new Date(dto.scheduleDate),
);
if (conflict) {
throw new ConflictException(
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
// multiple future schedules and does not need to be at the origin yard yet — staff
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
// everything else surfaces as a warning.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status === 'OUT_OF_SERVICE') {
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
}
if (locked.status !== 'AVAILABLE') {
scheduleWarnings.push(
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
);
}
if (locked.currentYardId !== route.originYardId) {
scheduleWarnings.push(
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
);
}
lockedLocomotives.push(locked);
}
// Frozen on the route at create/update from the yard-country enum;
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
// Direction-matched fixed number from the built train's typed pair.
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
// (assignTrainNumber is idempotent, so both paths compose).
const pairTrainNumber = builtTrain
? (direction === 'IMPORT'
? builtTrain.importTrainNumber
: builtTrain.exportTrainNumber) ?? null
: null;
if (builtTrain && !pairTrainNumber) {
scheduleWarnings.push(
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
);
}
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotives,
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
// 24h before departure (FCFS). No schedule is ever always-open now.
const globalCfg = await this.getWindowConfig();
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
// on this origin + destination + EAT departure day, this new train JOINS
// its group and adopts the group's shared window timeline (open/close +
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
// That keeps every train on the day advancing through the same open/
// doc-review/payment/close instants, so a booking on one train is never
// expired by a sibling train's payment window closing on a different clock.
// First train in the group falls through to the normal computation.
//
// EXPORT is excluded: an export window is a single FCFS window anchored to
// each train's OWN departure (windowClosesAt = departure) with no
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
const groupAnchor =
direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
manager,
route.originYardId,
route.destinationYardId,
departure,
);
// Per-schedule window rule chosen at creation. Refused for a train that
// JOINS an existing route+day group: the group shares ONE window timeline,
// so a joining train adopts the anchor's times verbatim and its own
// settings would be silently discarded. Staff edit the group's window
// instead (Booking window settings, which fans out to every sibling).
if (dto.windowRule && groupAnchor) {
throw new BadRequestException(
'This train joins an existing booking group (same route and departure day), ' +
'which shares one booking window across all its trains. Create it with the ' +
'group settings, then use Booking window settings to change the window for ' +
'the whole group.',
);
}
// The rule this schedule is born under: staff overrides on top of the live
// global config, so an omitted field still follows the global value.
const windowCfg: BookingWindowConfig = dto.windowRule
? {
...globalCfg,
...pickDefined({
windowOpenHour: dto.windowRule.windowOpenHour,
windowCloseHour: dto.windowRule.windowCloseHour,
windowDurationHours: dto.windowRule.windowDurationHours,
docReviewMinutes: dto.windowRule.docReviewMinutes,
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
}),
// One pay-window override drives both directions (only the one
// matching this schedule's direction is ever read).
...(dto.windowRule.paymentWindowMinutes !== undefined
? {
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
}
: {}),
// Close offsets are nullable-by-intent: null/0 means "close at
// departure", which must override a non-null global, so these are
// merged on presence rather than on definedness.
...(dto.windowRule.importCloseOffsetMinutes !== undefined
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
: {}),
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
: {}),
}
: globalCfg;
// Short-notice trains are allowed: a departure inside the booking lead
// window is NOT rejected — the window just opens immediately (opensAt is
// clamped to `now` below) instead of waiting out a lead that has already
// passed. Only `updateScheduleDate` still enforces the lead floor.
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to
// the configured limit.
const builtTrainWagonCount = builtTrain
? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } })
: 0;
const maxWagons =
builtTrainWagonCount > 0
? builtTrainWagonCount
: (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain;
// Retry past a concurrent insert that grabbed the same S-<year> sequence
// (the unique index rejects the loser; it re-reads the max and tries again).
const saved = await this.insertScheduleWithReference(manager, (reference) =>
manager.getRepository(TrainSchedule).create({
reference,
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: departure,
// Born SCHEDULED: there is no draft/finalize phase — a created train
// is immediately visible and bookable to customers.
status: TrainScheduleStatusEnum.Scheduled,
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
...windowFields,
}),
);
// Locomotives stay in their current status until dispatch — advance scheduling
// must not block the locomotive from serving earlier trains.
if (builtTrain) {
await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id);
}
return saved.id;
});
const created = await this.getTrainScheduleById(createdScheduleId);
// New window announced — portal home / GL cards pick it up immediately.
void this.emitWindowState(createdScheduleId);
return { ...created, warnings: scheduleWarnings };
}
async assignBookingsToSchedule(
scheduleId: string,
dto: AssignBookingsDto,
freightType?: 'CONTAINER' | 'BULK',
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
if (!schedule.trainSet) {
throw new BadRequestException('Schedule has no train set');
}
// Batch parity: a schedule may only allocate bookings from its route-day POOL.
// Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
// a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
// pinned to whichever train in the day's group first held it. Every train
// sharing this origin + destination + EAT departure day draws from ONE shared
// pool (one shared booking window), so a booking is allocatable here when it is
// either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
// pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
// day/capacity fit is enforced downstream by validateBookingsForScheduling.
// EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
if (dto.bookingIds.length) {
const groupScheduleIds = new Set<string>([scheduleId]);
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
scheduleId,
);
for (const sib of siblings) groupScheduleIds.add(sib.id);
}
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
const stray = targeted.filter(
(b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
);
if (stray.length) {
throw new BadRequestException(
`These bookings are pinned to a train on a different route or day: ${stray
.map((b) => b.reference ?? b.id)
.join(', ')}`,
);
}
}
// The rebuild below deletes EVERY schedule↔booking link row and recreates
// only what makes the new plan. Ride-along (intercity) bookings are linked
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
// workspace's picked ids, so planning from dto.bookingIds alone silently
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
// every list. Every (re)assignment therefore re-plans the WHOLE train:
// the requested ids plus everything currently linked.
const linkedRows =
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
const allBookingIds = [
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
// created — every (re)assignment rebuilds the plan under the same flag so the
// stored train order stays consistent no matter how bookings are added.
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(
previewDto,
limitLoco,
schedule.maxWagons ?? undefined,
);
// Callers that add bookings without hand-picking container slots (the
// workspace "Add from pool" button, re-adding a removed booking) send no
// containerPlacements. Auto-fill them the same way the batch engine does:
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
// Callers hand-pick placements only for the bookings they know about; the
// union above may have folded in linked ride-alongs those placements never
// covered. Auto-fill whatever units are missing (all of them when no
// placements were sent at all) so the placement validator doesn't reject
// container bookings the caller couldn't have placed.
let containerPlacements = dto.containerPlacements;
{
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
[],
false,
limits,
scheduleId,
);
const containerBookings = preview.bookings.filter(
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const providedKeys = new Set(
(containerPlacements ?? []).map(
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
),
);
const unplacedUnits = units.filter(
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = [...(containerPlacements ?? []), ...generated];
}
}
}
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
containerPlacements,
true,
limits,
scheduleId,
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
// Every REQUESTED booking must have made the plan. Silently dropping a
// deferred one let the workspace "Add from pool" report success while the
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
// A stock shortage is a physical impossibility, so forceAssign cannot
// override it either.
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const details = droppedRequested.map(
(id) =>
reasonById.get(id) ??
`${id}: does not fit the train's wagon stock or capacity`,
);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case. Each
// locomotive's overageToleranceTons/Meters extends the hard cap before that
// override is even needed (e.g. the fertilizer example's +90T deviation).
const weightCapWithOverage =
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
// A built train hauls EVERY coupled wagon's tare — empty ones included —
// so train-bound schedules count the full consist, not just planned slots.
const consistWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
const planTareTons = roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const consistTareTons = consistWagons
? roundTons(
consistWagons.reduce(
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
0,
),
)
: planTareTons;
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
// so their tare rides on top of the binding edge.
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
const scheduleStops = await this.stopYardsForSchedule(schedule);
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
const stopLabels = await this.yardLabelMap(scheduleStops);
// Each edge is its own consist — name EVERY leg that breaks the limit,
// not just the heaviest figure, so staff see where along A→…→E it fails.
const legName = (edge: number) =>
scheduleStops.length > 2
? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]}${
stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1]
}`
: 'the route';
const overweightLegs = perEdge
.map((e) => ({
edge: e.edge,
grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons),
}))
.filter((e) => e.grossWeightTons > weightCapWithOverage);
if (!dto.forceAssign && overweightLegs.length) {
throw new BadRequestException(
`Train set locomotives cannot pull the gross weight on ${overweightLegs
.map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`)
.join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`,
);
}
const overlongLegs = perEdge
.map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) }))
.filter((e) => e.lengthMeters > lengthCapWithOverage);
if (!dto.forceAssign && overlongLegs.length) {
throw new BadRequestException(
`Train set locomotives cannot support the train length on ${overlongLegs
.map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`)
.join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`,
);
}
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
if (deletedAllocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
}
await manager.getRepository(TrainSetWagon).delete({ trainSetId });
await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });
await manager.getRepository(TrainSet).update(trainSetId, {
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: 'ASSIGNED',
});
const savedWagons = await this.persistTrainSetWagons(
manager,
trainSetId,
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
manager,
savedWagons,
wagonPlan,
bookings,
containerPlacements ?? [],
);
// The link above puts these bookings on the train: they are SCHEDULED, not
// ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next
// batch fill, which unlinks it and frees its wagons on the next window cycle.
const scheduledAt = new Date();
for (const booking of bookings) {
await this.bookingsRepository.updateSchedulingFields(
booking.id,
{
schedulingStatus: SchedulingStatus.Scheduled,
scheduledAt,
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
},
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Draft,
{},
manager,
);
}
await this.autoPinWagonsForSchedule(
manager,
scheduleId,
schedule.originStationId,
savedWagons,
schedule.reverseWagonOrder ?? false,
);
});
const detail = await this.getTrainScheduleById(scheduleId);
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
if (!link) {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
}
const booking = await this.bookingsRepository.findById(bookingId);
if (booking?.isGovernment) {
throw new BadRequestException(
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
);
}
const bookingReference = booking?.reference ?? null;
await this.dataSource.transaction(async (manager) => {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.filter((a) => a.bookingId === bookingId)
.map((a) => a.id);
if (allocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
allocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
}
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
bookingId,
manager,
);
const booking = await this.bookingsRepository.findById(bookingId);
const schedulingStatus = this.resolvePostUnassignStatus(booking);
// Clear the schedule pointer too: unassign fully detaches the booking from
// this train. Leaving trainScheduleId set glued the booking to a schedule
// that may then be dispatched/cancelled/deleted, orphaning it — the
// assign-bookings parity guard would reject it from every OTHER schedule.
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus, wagonsRequired: null, trainScheduleId: null },
manager,
);
// Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale
// (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as
// is, the booking becomes invisible: the eligible pool only admits PAID,
// so it can never be re-added to any train. Revert it to PAID.
if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) {
await manager
.getRepository(Booking)
.update(bookingId, { status: 'PAID', loadedAt: null } as never);
}
// Recompute the train-set composition from whatever survives this removal.
// The removed booking's allocations were already deleted above, so any slot
// left with zero allocations was ridden only by this booking — release it
// (frees its reserved wagon slot). Shared slots keep their surviving
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
// fully-empty train was reset).
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
relations: { allocations: true },
});
let recomputedWeightTons = 0;
let recomputedLengthMeters = 0;
let recomputedWagonCount = 0;
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
const slotWeight = slotAllocations.reduce(
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
0,
);
if (Number(slot.assignedWeightTons) !== slotWeight) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
}
recomputedWeightTons += slotWeight;
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
recomputedWagonCount += 1;
}
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: roundTons(recomputedWeightTons),
totalLengthMeters: roundTons(recomputedLengthMeters),
wagonCount: recomputedWagonCount,
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
// the current status (an object literal lets TypeORM's contextual typing
// accept the partial without pulling in relation fields).
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
});
});
// Freed wagons may un-full the train — re-derive the window status (this
// also revives a DONE window pre-departure so the freed space is bookable
// again for import/export).
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
await this.trainCompositionRemovalLogRepository.create({
scheduleId,
bookingId,
bookingReference,
removedByUserId: userId ?? null,
removedAt: new Date(),
});
// Ops decision, so the customer hears about it: SMS/email + inbox telling
// them to rebook or pick a new schedule (the removal log above is the record).
const removedBooking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
);
return this.getTrainScheduleById(scheduleId);
}
private async runWarehouseArrivalAutomation(scheduleId: string) {
// Runs after the arrival transaction has committed — must never throw, or a
// successfully arrived train reports a 500 and looks stuck to the operator.
let direction: string | undefined;
try {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.label AS "destinationName"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (direction === 'IMPORT') {
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the destination yard.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED',
result,
};
}
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the Djibouti port.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return {
direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED',
result,
};
}
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
} catch (error) {
return {
direction,
status: 'FAILED',
reason: error instanceof Error ? error.message : String(error),
};
}
}
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
const normalized = (value ?? '').toUpperCase();
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
normalized.includes(token),
);
}
async getImportLoadingBookings(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
if (!scheduleBookings.length) {
return { count: 0, items: [] };
}
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
const statusByBookingId = new Map(
scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
);
const candidateIds = scheduleBookings
.map((sb) => sb.bookingId)
.filter((id) => allocatedBookingIds.has(id));
if (!candidateIds.length) {
return { count: 0, items: [] };
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
const tareDims = await this.loadWagonTareDims();
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(b, tareDims),
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
}
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations, bookings] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const invalid: string[] = [];
for (const id of dto.bookingIds) {
const booking = bookingById.get(id);
if (
!scheduledIds.has(id) ||
!allocatedIds.has(id) ||
!booking ||
booking.tradeDirection !== 'IMPORT' ||
booking.paymentStatus !== 'PAID'
) {
invalid.push(id);
}
}
if (invalid.length) {
throw new BadRequestException(
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getImportLoadingBookings(scheduleId);
}
/**
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
* direction (import/export/domestic). Distinct from unassign: the booking stays
* on its wagon; this only records whether cargo is physically loaded. Allowed
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
*/
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
// Only bookings that are on this train AND pinned to a wagon can be loaded —
// no direction/payment filter, staff load whatever is physically on the set.
const invalid = dto.bookingIds.filter(
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
);
if (invalid.length) {
throw new BadRequestException(
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
const slots = schedule.trainSet?.wagons ?? [];
const slotIds = new Set(slots.map((w) => w.id));
const slotById = new Map(slots.map((w) => [w.id, w]));
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
// Occupancy is judged against THIS schedule's own slots only — a wagon
// pinned on another schedule (e.g. the same train's July 17 run) stays
// pinnable here.
const slotIdByPhysicalId = new Map(
slots
.filter((w) => w.physicalWagonId)
.map((w) => [w.physicalWagonId as string, w.id]),
);
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
if (!slotIds.has(assignment.trainSetWagonId)) {
throw new BadRequestException(
`Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
);
}
const physicalWagon = await manager.getRepository(Wagon).findOne({
where: { id: assignment.physicalWagonId },
});
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
if (builtTrainId) {
// Train-bound schedule: only the built train's own consist may be
// pinned — wherever the wagons currently sit, they travel with the
// train, so no yard/status gate applies.
if (physicalWagon.trainId !== builtTrainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
);
}
} else {
if (physicalWagon.trainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
);
}
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
);
}
if (
physicalWagon.currentTrainScheduleId &&
physicalWagon.currentTrainScheduleId !== scheduleId
) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
);
}
if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
);
}
}
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
// its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
for (const [physicalId, slotId] of slotIdByPhysicalId) {
if (slotId === assignment.trainSetWagonId) {
slotIdByPhysicalId.delete(physicalId);
break;
}
}
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
return this.getTrainScheduleById(scheduleId);
}
async finalizeSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Schedules are born SCHEDULED now — finalize is a no-op for them so the
// allocate wizard and the window auto-finalize keep working. The DRAFT
// branch below only still runs for legacy rows.
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
return this.getTrainScheduleById(scheduleId);
}
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
throw new BadRequestException('Only DRAFT schedules can be finalized');
}
if (!schedule.scheduleBookings?.length) {
throw new BadRequestException('Cannot finalize a schedule with no bookings');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Scheduled,
{},
manager,
);
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now },
manager,
);
}
});
// Finalized — push so portal/GL cards reflect the new state instantly.
void this.emitWindowState(scheduleId);
return this.getTrainScheduleById(scheduleId);
}
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide.
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
// Same rule for wagons: many schedules may pin the same wagon, but it can
// only be OUT on one dispatched train at a time.
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (pinnedPhysicalIds.length) {
const rolling = await this.dataSource.getRepository(Wagon).find({
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
});
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
if (busy.length) {
throw new ConflictException(
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
);
}
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
}
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
}
// A built train follows its schedule out: IN_SERVICE until arrival.
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
}
// The train is out — every pinned wagon is ASSIGNED to this schedule and
// stays pinned so no other schedule can pick it while it's rolling.
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (dispatchedPhysicalIds.length) {
await manager
.getRepository(Wagon)
.update(
{ id: In(dispatchedPhysicalIds) },
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: SchedulingStatus.Dispatched },
manager,
);
}
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
// that the operator didn't load individually are auto-loaded now — the
// train is leaving with them. Mid-corridor boarders stay PAID until the
// operator loads them at their own yard.
await manager.query(
`UPDATE freight.bookings b
SET status = 'IN_TRANSIT',
loaded_at = COALESCE(b.loaded_at, $3)
FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = b.id
AND tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
[scheduleId, schedule.originStationId, now],
);
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
await manager
.getRepository(Booking)
.createQueryBuilder()
.update()
.set({
status: 'EXPIRED',
schedulingStatus: SchedulingStatus.Eligible,
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(
`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
.execute();
});
if (this.isImportDjiboutiSchedule(schedule)) {
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
});
console.log(
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
);
}
// Dispatch closed the window — drop it from portal/GL cards right away.
void this.emitWindowState(scheduleId);
// Customer tracking: cargo is on the departing train — loading milestones
// plus the direction's "departed" handoff milestone. Restricted to bookings
// that BOARD at the train's origin; mid-corridor boarders get their loading
// milestones from their own operator load at their own yard.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(
scheduleId,
[
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
// doc-trigger path no-ops it for import bookings.
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
schedule.direction === 'IMPORT'
? 'DEPARTED_FROM_DJIBOUTI'
: 'DEPARTED_TO_DJIBOUTI',
],
{ originYardId: schedule.originStationId },
);
}
void this.notifyScheduleBookings(schedule, 'dispatched');
const detail = await this.getTrainScheduleById(scheduleId);
// Surface a compact dispatch confirmation so the caller can toast the "train
// is out" info (train number, departure, wagons committed) without re-deriving it.
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
(slot) => slot.physicalWagonId,
).length;
return Object.assign(detail, {
dispatchInfo: {
// The real train number was assigned inside the txn — read it back off
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
departedAt: now.toISOString(),
wagonsDispatched: dispatchedWagonCount,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
},
});
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
return this.mapImportDjiboutiOperation(schedule, operation);
}
async uploadImportDjiboutiDocument(
scheduleId: string,
dto: UploadImportDjiboutiDocumentDto,
) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const documents = {
...(operation.documents ?? {}),
[dto.documentType]: {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
},
};
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
documents,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
const documents = { ...(operation.documents ?? {}) };
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
documents.GATE_PASS = {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
};
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
documents,
gatepassGrantedAt: securedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
console.log(
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
* milestone for every customs booking on this schedule, so contract/booking
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*
* A booking only earns its gate pass once the customer has settled the freight
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
* train and its paid bookings proceed. Re-securing the gate pass after payment
* settles picks the booking up; so does any later call to this bridge.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
securedAt: Date,
): Promise<void> {
const bookings = await this.dataSource.getRepository(Booking).find({
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
});
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const bookingIds = bookings.map((b) => b.id);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookingIds),
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
},
});
const paidBookingIds = new Set(
rows
.filter(
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
)
.map((r) => r.bookingId),
);
// A booking whose payment settled through a path that never wrote the
// milestone still counts as paid — the clearance views self-heal the row on
// read, and the gate pass must not lag behind that.
for (const booking of bookings) {
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
paidBookingIds.add(booking.id);
}
}
const skipped: string[] = [];
for (const row of rows) {
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
if (row.status === 'COMPLETED') continue;
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
skipped.push(row.bookingId ?? '(unknown)');
continue;
}
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
if (skipped.length > 0) {
this.logger.warn(
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Confirm cargo is loaded on the train from the workspace, for any direction.
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
* For every other schedule there is no departure loading gate, so this is a
* success no-op and simply returns the current detail.
*/
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
if (this.isImportDjiboutiSchedule(schedule)) {
await this.confirmImportLoadedOnTrain(scheduleId, dto);
}
// Confirming loading also marks every wagon-assigned booking LOADED, so the
// per-booking loading flag and the dispatch gate agree (otherwise the
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
// is confirmed — allocation time already enforces it, but a wagon swap or
// an edited allocation since then could have broken it unnoticed.
await this.assertWagonContainerCapacity(scheduleId);
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
await this.assertExportBookingsReceived([...wagonAssignedIds]);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
LoadingStatus.Loaded,
);
}
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
// is the export-side "cargo reached origin yard" step that precedes it).
void this.completeMilestonesForScheduleBookings(scheduleId, [
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
]);
return this.getTrainScheduleById(scheduleId);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const generatedAt = operation.loadListGeneratedAt ?? new Date();
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
loadListGeneratedAt: generatedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return {
generatedAt: generatedAt.toISOString(),
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
route: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0,
// Every wagon on the train set, loaded or not, in consist order. An empty
// wagon has an empty `allocations` array — it is still part of the train
// and still belongs on the marshalling document.
wagons: [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
booking: allocation.booking,
loadType: allocation.loadType ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean),
containerItems: allocation.containerItems ?? [],
})),
})),
operation: await this.getImportDjiboutiOperation(schedule.id),
};
}
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const loadList = await this.generateImportLoadList(scheduleId, {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isExportSchedule(schedule)) {
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
}
const html = this.buildExportLoadListHtml(schedule);
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* The train's composition as it stands right now — the source for the
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
* station work. A wagon slot is on the train iff it has not DEPARTED and
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
* unloaded, PLANNED/RESERVED = not on board yet).
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
* path is comparing the board yard against the latest checkpoint sequence.
*/
private intercityOnBoardView(schedule: TrainSchedule): {
wagons: TrainSetWagon[];
unassignedBookings: Booking[];
} {
const wagons = (schedule.trainSet?.wagons ?? [])
.filter((wagon) => {
if (wagon.status === 'DEPARTED') return false;
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
return wagon.boardYardId == null || hasLoaded;
})
.map((wagon) => ({
...wagon,
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
})) as TrainSetWagon[];
const onBoardBookingIds = new Set(
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
);
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
// after dispatch (never wagon-pinned) and loads whose allocation was never
// confirmed LOADED. They are physically on the train, so they get a row.
const unassignedBookings = (schedule.scheduleBookings ?? [])
.map((link) => link.booking)
.filter((booking): booking is Booking => Boolean(booking))
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
return { wagons, unassignedBookings };
}
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
throw new BadRequestException(
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
wagons,
unassignedBookings,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* A container item's size in feet, for the marshalling document's 40ft/20ft
* tally. Two independent sources, since only one is populated depending on
* how the item was created:
* - `item.containerType` — the item's own container_type_id FK, set for
* manually-entered items (no booking-container line behind them).
* - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the
* booking-line fallback for items generated from an allocation.
* (`findByIdWithFullGraph` must load both relations or every item here
* silently resolves to null and the tally stays zero.)
*/
private resolveContainerItemSize(item: {
containerType?: { sizeFt?: number | null } | null;
bookingContainer?: {
containerSize?: string | null;
containerType?: { sizeFt?: number | null } | null;
} | null;
}): number | null {
const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt;
if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt;
const label = item.bookingContainer?.containerSize;
if (label?.includes('40')) return 40;
if (label?.includes('20')) return 20;
return null;
}
private buildExportLoadListHtml(
schedule: TrainSchedule,
opts?: {
title?: string;
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
},
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
const rows = wagons
.flatMap((wagon) => {
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
// reads as a wagon that is not there, and the count stops matching.
if (allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
</tr>`;
});
})
.join('');
// Intercity riders accepted after dispatch have no wagon slot recorded —
// they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
unassigned
.map((booking) => {
const containerNumbers = (booking.bookingContainers ?? [])
.map((container) => container.containerNumber)
.filter(Boolean)
.join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `<tr>
<td colspan="6">${esc(booking.reference)}${esc(leg)}</td>
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
<td>${esc(booking.company?.name)}</td>
<td>${esc(containerNumbers)}</td>
<td>-</td>
<td>-</td>
</tr>`;
})
.join('')
: '';
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
});
});
});
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
</div>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon No</th>
<th>Wagon Type</th>
<th class="num">Equated Length</th>
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Cargo Type</th>
<th>Company</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
${unassignedRows}
</tbody>
</table>
<div class="notice">
Loading and dispatch staff must verify wagon identity, seal number, container number,
cargo type, and customer booking against the physical consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared person / date</div>
<div class="line">Check person / date</div>
<div class="line">Operations authorization / date</div>
</div>
</body>
</html>`;
}
private isExportSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return direction === 'EXPORT';
}
/**
* Every export booking being confirmed loaded must already be received at the
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
* is the check that the cargo is physically in the yard before we call it loaded.
*
* Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded —
* that cargo is manually loaded from the customer's truck straight onto the
* wagon, never sees the warehouse, and is never GRN'd. Its custody is attested
* by the carriage acceptance sheet instead (same carve-out as the shared
* assertExportReceivedWithGrn gate — see common/export-received-gate.ts).
*/
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
if (!bookingIds.length) return;
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT b.reference
FROM freight.bookings b
WHERE b.id = ANY($1)
AND b.deleted_at IS NULL
AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN'
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
)`,
[bookingIds],
);
if (rows.length) {
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
throw new BadRequestException(
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
);
}
}
/**
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
* time. `validateContainerPlacements` already enforces this the moment a
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
* wagon swap, an edited allocation, or a container item added out-of-band
* between allocation and loading could still leave a wagon over its 2-TEU
* capacity undetected until the train is already loaded. This closes that gap
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
* same size resolution as the marshalling document) right before loading is
* confirmed.
*/
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
pw.wagon_number AS "wagonNumber",
SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) AS "teuUsed"
FROM freight.train_set_wagons tsw
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
JOIN freight.wagon_allocation_container_items wci
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
WHERE tsw.train_set_id = (
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
)
AND tsw.deleted_at IS NULL
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
HAVING SUM(
CASE COALESCE(ct.size_ft, bct.size_ft)
WHEN 40 THEN 2
WHEN 20 THEN 1
ELSE
CASE
WHEN bc.container_size ILIKE '%40%' THEN 2
WHEN bc.container_size ILIKE '%20%' THEN 1
ELSE 0
END
END
) > $2`,
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
);
if (rows.length) {
const labels = rows
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
.join(', ');
throw new BadRequestException(
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
);
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = loadList.wagons.reduce(
(sum, wagon) =>
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
});
});
});
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => {
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
return `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(companyName)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
);
})
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Load List / Marshalling Document</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.doc { min-height: 100vh; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.tile strong { font-size: 13px; }
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
.pending { background: #f8fafc; color: #64748b; }
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Load List /<br />Marshalling Document</h1>
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
Generated: ${esc(date(loadList.generatedAt))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
<div class="status">
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
</div>
<h2>Wagon Marshalling Allocation</h2>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Company</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
</tbody>
</table>
<div class="notice">
Gate and loading staff must verify this document against the granted gatepass,
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared by Djibouti operations</div>
<div class="line">Train loading supervisor</div>
<div class="line">EDR operations authorization</div>
</div>
<div class="footer">
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
</div>
</div>
</body>
</html>`;
}
private safeDocumentName(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
if (!this.isImportDjiboutiSchedule(schedule)) return;
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
// Loading confirmation does NOT gate dispatch. Per-booking loading is
// tracking only and the loaded-on-train step is optional — a scheduled train
// dispatches without waiting on loading.
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
}
return schedule;
}
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isDjiboutiGatepassSchedule(schedule)) {
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
}
return schedule;
}
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
}
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'IMPORT' &&
this.isDjiboutiPortDestination(
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
)
);
}
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'EXPORT' &&
this.isDjiboutiPortDestination(
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
)
);
}
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
if (existing) return existing;
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
}
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
void operation;
return [];
}
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
if (!operation?.gatepassGrantedAt) {
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
}
}
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
return {
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
status: {
documentsComplete: missingDocuments.length === 0,
missingDocuments,
gatepassStatus,
gatepassGranted: Boolean(operation.gatepassGrantedAt),
readyForLoading: Boolean(operation.readyForLoadingAt),
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
loadListGenerated: Boolean(operation.loadListGeneratedAt),
},
documents: operation.documents ?? {},
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
gatepassStatus,
readyForLoadingAt: operation.readyForLoadingAt ?? null,
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
performedBy: operation.performedBy ?? null,
notes: operation.notes ?? null,
};
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) {
// Creation-assigned pair number: two live runs may never share a number,
// so block dispatch while another DISPATCHED schedule still carries it.
const clash = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
.andWhere('s.id != :id', { id: schedule.id })
.getOne();
if (clash) {
throw new ConflictException(
`Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
);
}
return schedule.trainNumber;
}
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
// creation and must be invisible to pool picks.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status IN (:...statuses)', {
statuses: [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
TrainScheduleStatusEnum.Dispatched,
],
})
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
// Every typed train pair is reserved for its train — the pool may never
// hand one out, even when that train has no active schedule right now.
const pairRows: { n: string }[] = await manager.query(
`SELECT import_train_number AS n FROM freight.trains
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
UNION
SELECT export_train_number FROM freight.trains
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
);
const usedNumbers = [
...activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n)),
...pairRows.map((row) => row.n),
];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
if (status === 'OPEN') {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule?.bookingWindowStatus === 'FULL') {
throw new ConflictException('Train is full — the booking window cannot be reopened');
}
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
void this.emitWindowState(scheduleId);
}
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
private async buildScheduleStations(schedule: TrainSchedule) {
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
const stations: Station[] = [];
const route = schedule.routeId
? await this.dataSource.getRepository(Route).findOne({
where: { id: schedule.routeId },
relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
})
: null;
if (route) {
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
});
return stations;
}
// Fallback: no route milestones — just origin → destination from the schedule stations.
stations.push({
sequenceNo: 0,
yardId: schedule.originStationId,
label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
code: schedule.originStation?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
code: schedule.destinationStation?.code ?? '',
});
return stations;
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map(resolvedSeq))
: -1;
return {
scheduleId,
status: schedule.status,
direction: schedule.direction ?? null,
trainNumber: schedule.trainNumber ?? null,
actualDepartureAt: schedule.actualDepartureAt
? schedule.actualDepartureAt.toISOString()
: null,
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
note: e.note ?? null,
})),
};
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can be tracked');
}
const stations = await this.buildScheduleStations(schedule);
const finalSeq = stations[stations.length - 1].sequenceNo;
const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
}
const kind =
dto.kind ??
(dto.sequenceNo === 0
? TrainCheckpointKind.Departed
: dto.sequenceNo === finalSeq
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
});
} else {
await this.trainCheckpointEventsRepository.create({
trainScheduleId: scheduleId,
yardId: station.yardId,
sequenceNo: dto.sequenceNo,
kind,
occurredAt,
note: dto.note ?? null,
});
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
// unload each one by hand. The final station is covered by
// arriveSchedule's bulk fallback above.
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
// A pass is also a position fix: the locomotives, every wagon still
// aboard, and the built train are physically AT this yard now — not at
// the origin they departed from. Wagons released at earlier stops no
// longer carry this schedule id and stay where they alighted; the final
// arrival settle still writes the wagon-movement ledger rows.
await this.dataSource.transaction(async (manager) => {
const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (locoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
}
await manager
.getRepository(Wagon)
.update(
{ currentTrainScheduleId: scheduleId },
{ currentYardId: station.yardId },
);
if (schedule.trainSet?.trainId) {
await manager
.getRepository(Train)
.update(schedule.trainSet.trainId, { currentYardId: station.yardId });
}
});
}
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: 'COMPLETED',
});
}
// A built train arrives with its schedule: settle it at the destination
// yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait).
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(
manager,
schedule.trainSet.trainId,
schedule.destinationStationId,
);
}
// Per-booking journey: bookings destined for the FINAL yard that the
// operator didn't unload individually get their arrival stamped now as a
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
// their own unload (possibly already done while the train kept rolling).
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
// Release every locomotive of the set (not just the legacy primary) and move it
// to the destination yard where it physically arrived.
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (arrivedLocoIds.length) {
await manager.getRepository(Locomotive).update(
{ id: In(arrivedLocoIds) },
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
);
}
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (!wagon) continue;
// A wagon that already alighted mid-route (unload released it, possibly
// re-pinned elsewhere since) is no longer this schedule's to move.
if (wagon.currentTrainScheduleId !== scheduleId) continue;
// Dynamic consist: the wagon settles at its slot's alight yard, not
// blanket at the train's destination.
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool.
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
currentYardId: settleYardId,
});
// Ledger: the wagon rode this schedule to its settle yard.
const slotAllocations = slot.allocations ?? [];
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: slot.boardYardId ?? schedule.originStationId,
toYardId: settleYardId,
trainScheduleId: scheduleId,
bookingId: slotAllocations[0]?.bookingId ?? null,
kind: slotAllocations.length
? WagonMovementKind.Loaded
: WagonMovementKind.EmptyReposition,
occurredAt: now,
}),
);
}
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
const stations = await this.buildScheduleStations(schedule);
const finalStation = stations[stations.length - 1];
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
});
if (!existingFinal) {
await manager.getRepository(TrainCheckpointEvent).save(
manager.getRepository(TrainCheckpointEvent).create({
trainScheduleId: scheduleId,
yardId: finalStation.yardId,
sequenceNo: finalStation.sequenceNo,
kind: TrainCheckpointKind.Arrived,
occurredAt: now,
}),
);
}
});
// Customer tracking: the train reached the corridor's far end. Restricted
// to bookings destined for the FINAL yard — mid-corridor bookings get their
// arrival milestone from their own operator unload at their own yard.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(
scheduleId,
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
{ destinationYardId: schedule.destinationStationId },
);
}
void this.notifyScheduleBookings(schedule, 'arrived');
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
}
async getContainerTrainSchedules(
query: ListTrainSchedulesQueryDto = {},
allowedDirections?: string[],
) {
const { page, pageSize, skip, take } = normalizePagination(query);
// Per-user trade-direction scope: schedules carry a `direction` column.
if (allowedDirections && allowedDirections.length === 0) {
return {
items: [],
meta: buildPaginationMeta(0, page, pageSize),
};
}
// Exact-match filters (enum/id semantics). Freight type is derived from
// the bookings aboard — no column to match — so it rides on `id` as an
// EXISTS fragment instead.
const base: FindOptionsWhere<TrainSchedule> = {};
if (allowedDirections) base.direction = In(allowedDirections) as never;
if (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId;
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
// Search fans out across every human-recognizable label; each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, reference: like as never },
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
// Newest-created first (the client can re-sort; this is the default order).
const sortBy = query.sortBy ?? 'createdAt';
const sortOrder = query.sortOrder ?? 'DESC';
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
train: true,
// Slot allocations back the list's "used wagons" figure — without
// them the row can only report the coupled consist size, which is
// what made the list disagree with the detail page's wagon plan.
wagons: { allocations: true },
},
// Yards carry the route's display name used by mapScheduleListItem;
// milestones (with yards) let it show the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { [sortBy]: sortOrder } as never,
skip,
take,
});
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
async getContainerTrainScheduleById(id: string) {
return this.getTrainScheduleById(id);
}
async cancelTrainSchedule(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new BadRequestException(
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
{
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
// Cancelled run: the built train never left — re-derive its status
// (back to AVAILABLE unless other runs still reference it).
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
}
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
// but never stomp a locomotive that is currently pulling another dispatched train.
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
cancelledLocoIds,
id,
manager,
);
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
if (releasable.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
}
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// Built-train wagons stay coupled to their train (ASSIGNED); loose
// wagons return to the open AVAILABLE pool.
status: wagon.physicalWagon?.trainId
? WagonStatus.Assigned
: WagonStatus.Available,
// A cancelled train never left — its wagons stay/return at the origin
// yard, free to be re-pinned onto another schedule from there.
currentYardId: schedule.originStationId,
});
}
}
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository.findById(sb.bookingId);
// Detach from the cancelled schedule — clear the pointer so the freed
// booking can be assigned to another train. Leaving it set orphans the
// booking against a schedule that is about to be gone.
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{
schedulingStatus: this.resolvePostUnassignStatus(booking),
trainScheduleId: null,
},
manager,
);
}
});
// Best-effort customer notice (SMS + email + in-app) — the cancel itself has
// already committed, so a notification failure must never fail the cancel.
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository
.findByIdWithFiles(sb.bookingId)
.catch(() => null);
if (booking) this.bookingNotifier.scheduleCancelled(booking);
}
// Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id);
return this.getTrainScheduleById(id);
}
private async getTrainScheduleById(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return this.mapScheduleDetail(schedule);
}
private async validateBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
freightType: 'CONTAINER' | 'BULK' | null,
forceAssign = false,
containerPlacements: ContainerPlacementInput[] = [],
requireContainerPlacements = false,
trainLimits: Required<TrainLimitConfig>,
targetScheduleId?: string,
) {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException('At least one booking is required');
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const violations: string[] = [];
const warnings: string[] = [];
if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((b) => b.id));
violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`);
}
const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds);
const conflictingLinks = targetScheduleId
? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId)
: scheduledLinks;
if (conflictingLinks.length > 0) {
violations.push('One or more selected bookings are already assigned to a train schedule');
}
const bookingTypes = new Set(bookings.map((b) => b.freightType));
const isMixed = bookingTypes.size > 1;
const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' =
freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK'));
if (freightType === 'CONTAINER' || freightType === 'BULK') {
const wrongType = bookings.filter((b) => b.freightType !== freightType);
if (wrongType.length) {
violations.push(`Only ${freightType} bookings are supported`);
}
}
const invalidStatus = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
!b.isGovernment,
);
if (invalidStatus.length) {
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
violations.push(
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`,
);
}
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
// stop list is also what makes the wagon plan leg-aware below.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
}
if (
bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
})
) {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
// Day-match: a booking scheduled for a specific EAT day must board a train
// departing that same day. forceAssign downgrades a mismatch to a warning
// so staff can knowingly move a booking onto an adjacent-day train.
const scheduleDay = eatDay(new Date(dto.scheduleDate));
const dayMismatched = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
b.scheduledDate != null &&
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
);
if (dayMismatched.length) {
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
.map((b) => b.reference ?? b.id)
.join(', ')}`;
if (forceAssign) {
warnings.push(message);
} else {
violations.push(message);
}
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {
warnings.push(
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
);
}
// Overweight is the soft threshold (maxVgmTons): the customer already
// paid the overweight surcharge at booking. The hard ceiling
// (maxCapacityTons) blocks booking creation, so anything reaching
// scheduling is shippable — warn the planner, never block allocation.
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
if (overweightLines.length) {
warnings.push(
`Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`,
);
}
}
}
// Wagon-type resolution is list-based: each container/cargo type carries
// the wagon types that can haul it, and the plan mixes wagon types within
// one consist. A schedule created from a built train (Train Builder) plans
// against ONLY that train's own wagons — full when every consist wagon is
// allocated; legacy schedules plan against the boarding yards' pool.
const allowed = await this.loadAllowedWagonTypes(bookings);
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
const stock: WagonStock = await this.wagonStockForSchedule(
targetScheduleId,
originYardId,
bookings.map((b) => b.originYardId),
builtTrainId,
);
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
const legByBookingId = new Map(
bookings.flatMap((b) => {
const from = stops.indexOf(b.originYardId);
const to = stops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
const planned = planWagonsWithStock({
bookings,
allowed,
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
// the stored train order, allocations and snapshot all carry the reversed
// order together. No-op unless the schedule set the flag.
const wagonPlan = applyWagonOrderReversal(
planned.plan,
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
);
// Availability rows come from the BOUNDED plan — the one that actually
// mixes wagon types against real stock. The old unbounded "pure demand"
// plan had infinite stock of every allowed type, so its tie-break parked a
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
// the deferred bookings' own shortage rows.
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
planned.plan,
stock.remainingByTypeId,
stock.codesByTypeId,
);
warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
this.stampSlotLegs(
wagonPlan,
fittingBookings,
dto.originStationId,
dto.destinationStationId,
stops,
);
violations.push(
...(await this.validatePhysicalFleetForPlan(
wagonPlan,
originYardId,
targetScheduleId,
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
// The plan can mix wagon types, so limit math always runs against the
// distinct types actually planned (shortest length drives the wagon-count
// fallback — validateMixedTrainLimits generalizes the single-type check).
const plannedWagonTypes = [
...new Map(
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
).values(),
];
const stopLabelMap =
stops.length > 2 ? await this.yardLabelMap(stops) : new Map<string, string>();
const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId);
pushLimit(
validateMixedTrainLimitsPerEdge(
wagonPlan,
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
trainLimits,
stops,
stopLabels,
),
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
violations.push(
...validateContainerPlacements(
containerBookings,
wagonPlan,
containerPlacements,
placementRules,
legByBookingId,
Math.max(1, stops.length - 1),
),
);
violations.push(
...(await this.validateFleetContainers(containerPlacements, containerBookings)),
);
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
// above — the whole-route totals here are informational (summary) only. The
// locomotive checks below also compare per edge: a train is never heavier
// than its heaviest leg, so disjoint legs must not be summed.
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
const maxEdgeGrossTons = roundTons(
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
);
const maxEdgeLengthMeters = roundTons(
Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)),
);
const legName = (edge: number) =>
stops.length > 2 ? `${stopLabels[edge]}${stopLabels[edge + 1]}` : 'the route';
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
}
if (assignedLocomotives.length) {
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
if (offYard) {
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
);
}
if (setLimits) {
// Name every leg the set cannot pull — staff must see WHERE along the
// corridor the train is too heavy/long, not just that it is somewhere.
const weightCap =
setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0);
const lengthCap =
setLimits.maxTrainLengthMeters +
(Number(setLimits.overageToleranceMeters) || 0);
const legIssues = perEdgeUsage.flatMap((e) => {
const issues: string[] = [];
if (roundTons(e.grossWeightTons) > weightCap) {
issues.push(
`Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`,
);
}
if (roundTons(e.lengthMeters) > lengthCap) {
issues.push(
`Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`,
);
}
return issues;
});
if (legIssues.length) pushLimit(legIssues);
}
} else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
});
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
warnings.push(
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
);
}
if (
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
maxEdgeGrossTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
maxEdgeLengthMeters,
)
) {
pushLimit(['No locomotive can support the total train weight and length']);
}
}
const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))];
return {
valid: violations.length === 0,
violations,
warnings,
bookings: fittingBookings,
wagonPlan,
fleetAvailability,
deferredBookings,
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons,
totalTareTons,
// Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
freightMode: resolvedMode,
},
};
}
private async loadGlobalRulesRow(): Promise<TrainSchedulingGlobalRules | null> {
try {
const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({
order: { createdAt: 'ASC' },
take: 1,
});
return rows[0] ?? null;
} catch (err) {
// A read failure here silently downgrades every booking window to the
// hardcoded defaults (desk 817, duration 3h, lead 3) while the settings
// UI keeps showing the saved row — a maddening mismatch. The usual cause
// is a missing column (migrations not run on this database). Scream.
this.logger.error(
`Failed to read train-scheduling global rules — booking windows are ` +
`running on HARDCODED DEFAULTS (817). Run pending migrations. ` +
`Cause: ${(err as Error).message}`,
);
return null;
}
}
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: LocomotiveLimits | null,
builtWagonCount?: number,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
// truth — global-rules / env caps do not floor them (a mis-set global
// row once capped every train at 14m). Only an explicit per-request dto
// override still applies.
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
// A built train's own consist is the real capacity — the length-derived
// slot count is only an estimate for trains with no wagons coupled yet.
// Without this override, validation re-derives a DIFFERENT wagon cap
// than the one the train was actually built with (e.g. a 54-wagon
// consist rejected against a re-derived 53-slot cap that never matched
// what staff physically coupled).
maxWagonsPerTrain:
dto?.maxWagonsPerTrain != null
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
: builtWagonCount && builtWagonCount > 0
? builtWagonCount
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
wagonTypes,
);
return {
maxWeightTons,
maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
};
}
/**
* Every active wagon type: the slot count derives from the shortest wagon the
* fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
* 12.228m) and under-report how many wagons the train length allows.
*/
private async loadSchedulingWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
const types = await this.dataSource
.getRepository(WagonType)
.find({ where: { isActive: true } });
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
capacityTons: 70,
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
{
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
capacityTons: 60,
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
},
];
}
/**
* Built train (Train Builder) behind a schedule's train set, if the schedule
* was created by picking a train instead of loose locomotives.
*/
private async builtTrainIdOfSchedule(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<string | null> {
if (!scheduleId) return null;
const runner = manager ?? this.dataSource;
const rows: { train_id: string | null }[] = await runner.query(
`SELECT tset.train_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.id = $1`,
[scheduleId],
);
return rows[0]?.train_id ?? null;
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
for (const wagon of wagons) {
// Train-bound schedule: the built train's own consist IS the fleet — only
// its wagons count (wherever they currently sit; they travel with the
// train), and loose yard wagons never do.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
// and the July 20 run. A wagon is unusable only when it is coupled to a
// built train's consist, physically blocked, or out on a dispatched
// train right now.
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
if (wagon.trainId) continue;
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== targetScheduleId
) {
continue;
}
if (wagon.currentYardId !== originYardId) continue;
}
const typeId = wagon.wagonTypeId;
const code = typeCodeById.get(typeId) ?? typeId;
const existing = counts.get(typeId) ?? { code, available: 0 };
existing.available += 1;
counts.set(typeId, existing);
}
return [...counts.entries()].map(([wagonTypeId, value]) => ({
wagonTypeId,
wagonTypeCode: value.code,
available: value.available,
}));
}
/**
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
trainSetWagonId: wagon.id,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
slotStatus: wagon.status ?? null,
boardYardId: wagon.boardYardId ?? null,
alightYardId: wagon.alightYardId ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
loadType: allocation.loadType ?? null,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
})),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
};
}
/**
* A wagon in a blocked physical state can never be planned or pinned.
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
* train or stamped by a live run — schedule-level occupancy is tracked on
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
*/
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
return (
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
);
}
/**
* Physical wagons already pinned to THIS schedule's slots. Availability is
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
* pins held by other schedules of the same train are irrelevant.
*/
private async pinnedPhysicalWagonIdsForSchedule(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<Set<string>> {
if (!scheduleId) return new Set();
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ts.id = $1
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
[scheduleId],
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
/**
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
* schedule. Used to guard consist trims — the Wagon entity itself carries no
* schedule-occupancy state anymore.
*/
/**
* Physical wagons pinned to any live run's slot. `excludeTrainId` drops the
* pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g.
* the paired return leg): a consist edit is an edit of the TRAIN, sibling
* runs ride whatever it is composed of and their pins are re-pointed by the
* edit itself. Only pins held by live schedules of OTHER trains block it.
*/
private async wagonIdsPinnedToLiveSchedules(
manager?: EntityManager,
excludeTrainId?: string,
): Promise<Set<string>> {
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT DISTINCT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
JOIN freight.train_sets tset ON tset.id = tsw.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL
AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`,
[excludeTrainId ?? null],
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
manager: EntityManager,
scheduleId: string,
originYardId: string,
slots: TrainSetWagon[],
reverseWagonOrder = false,
) {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
scheduleId,
manager,
);
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
trainSetWagonId: slot.id,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
}));
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
wagons,
scheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
stops,
);
if (unpinnable.length) {
throw new BadRequestException({
message: 'Insufficient physical wagons to pin all train slots',
violations: unpinnable,
});
}
const occupiedSpans = new Map<string, Array<[number, number]>>();
for (const slot of planSlots) {
const span = this.slotSpanOf(slot, stops);
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
occupiedSpans,
span,
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
);
if (!physical) continue;
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
// touched here, so the same physical wagon stays free for every other
// schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
pinnedSpans.push(span);
occupiedSpans.set(physical.id, pinnedSpans);
}
}
/** Pre-assign check: every planned slot must have a matching physical wagon. */
private async validatePhysicalFleetForPlan(
wagonPlan: WagonPlanSlot[],
originYardId: string,
targetScheduleId?: string,
): Promise<string[]> {
if (!wagonPlan.length) return [];
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const targetSchedule = targetScheduleId
? await this.trainSchedulesRepository.findById(targetScheduleId)
: null;
const stops = targetSchedule
? await this.stopYardsForSchedule(targetSchedule)
: [];
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: slot.wagonTypeCode,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
})),
wagons,
targetScheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
stops,
);
}
/**
* Stop-index span [board, alight) a slot occupies along the route. Slots with
* unknown/missing yards conservatively span the whole route (never share).
*/
private slotSpanOf(
slot: { boardYardId?: string | null; alightYardId?: string | null },
stops: string[],
): [number, number] {
const last = Math.max(1, stops.length - 1);
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last;
if (from < 0 || to < 0 || from >= to) return [0, last];
return [from, to];
}
private findUnpinnableWagonSlots(
slots: Array<{
sequenceNo: number;
wagonTypeId: string;
wagonTypeCode: string;
boardYardId?: string | null;
alightYardId?: string | null;
}>,
wagons: Wagon[],
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
): string[] {
const violations: string[] = [];
// One physical wagon may serve several slots whose leg spans don't overlap
// (freed at its alight yard, reloaded downstream) — track occupied spans
// per wagon, not a flat taken-set.
const occupiedSpans = new Map<string, Array<[number, number]>>();
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const span = this.slotSpanOf(slot, stops);
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
occupiedSpans,
span,
builtTrainId,
pinnedToScheduleIds,
);
if (!physical) {
violations.push(
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
);
continue;
}
const spans = occupiedSpans.get(physical.id) ?? [];
spans.push(span);
occupiedSpans.set(physical.id, spans);
}
return violations;
}
/**
* Dynamic consist: a slot's wagon may either ride from the train's origin
* yard (attaching there, possibly empty until the slot's board yard) or
* already sit AT the slot's board yard and hook on when the train arrives.
*/
private pickPhysicalWagonForSlot(
slot: { wagonTypeId: string; boardYardId?: string | null },
wagons: Wagon[],
scheduleId: string | undefined,
originYardId: string,
occupiedSpans: Map<string, Array<[number, number]>>,
span: [number, number],
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined {
// Free for this slot = no already-assigned span on this wagon overlaps the
// slot's own leg. Disjoint legs (alight before board) share the wagon.
const spanFree = (wagonId: string): boolean =>
(occupiedSpans.get(wagonId) ?? []).every(
([from, to]) => to <= span[0] || span[1] <= from,
);
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (!spanFree(wagon.id)) return false;
// Loose pool never lends a wagon coupled to a built train's consist.
if (wagon.trainId) return false;
// Out on a dispatched train right now — physically gone.
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== scheduleId
) {
return false;
}
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
// yard wagon.
if (builtTrainId) {
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
spanFree(w.id),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
}
return reverseWagonOrder
? b.sequenceNumber - a.sequenceNumber
: a.sequenceNumber - b.sequenceNumber;
});
return candidates[0];
}
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
// fall back to one riding from the train's origin.
if (slot.boardYardId) {
const atBoardYard = wagons.find(
(w) => usable(w) && w.currentYardId === slot.boardYardId,
);
if (atBoardYard) return atBoardYard;
}
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
}
private positiveNumber(value: number | undefined, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
private async validateFleetContainers(
placements: ContainerPlacementInput[],
containerBookings: Booking[],
): Promise<string[]> {
const violations: string[] = [];
const inventoryIds = [
...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))),
];
if (!inventoryIds.length) return violations;
const lineById = new Map(
containerBookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, line] as const),
),
);
const containers = await this.dataSource.getRepository(Container).find({
where: { id: In(inventoryIds) },
});
const containerById = new Map(containers.map((c) => [c.id, c]));
for (const placement of placements) {
if (!placement.containerId) continue;
const fleet = containerById.get(placement.containerId);
if (!fleet) {
violations.push(`Fleet container ${placement.containerId} not found`);
continue;
}
if (fleet.status !== 'AVAILABLE') {
violations.push(`Container ${fleet.containerNumber} is not available`);
}
const line = lineById.get(placement.bookingContainerId);
if (line && fleet.containerTypeId !== line.containerTypeId) {
violations.push(
`Container ${fleet.containerNumber} type does not match booking line`,
);
}
if (
placement.containerNumber &&
fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase()
) {
violations.push(
`Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`,
);
}
}
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
/**
* Wagon types allowed to carry each container/cargo type on these bookings.
* The many-to-many configuration lists (active wagon types only), keyed by
* type id — the flexible planner mixes wagon types within one consist.
* Bookings must arrive from findByIdsForScheduling so the `wagonTypes`
* relations are loaded.
*/
private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap {
const byContainerTypeId = new Map<string, WagonType[]>();
const byCargoTypeId = new Map<string, WagonType[]>();
const active = (list?: WagonType[] | null) =>
(list ?? []).filter((wt) => wt.isActive !== false);
for (const booking of bookings) {
for (const line of booking.bookingContainers ?? []) {
const containerType = line.containerType;
if (containerType && !byContainerTypeId.has(containerType.id)) {
byContainerTypeId.set(containerType.id, active(containerType.wagonTypes));
}
}
const cargoType = booking.cargoType;
if (cargoType && !byCargoTypeId.has(cargoType.id)) {
byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes));
}
}
return { byContainerTypeId, byCargoTypeId };
}
/**
* TRAIN-mode wagon stock: the built train's own consist, grouped by wagon
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
/**
* The physical wagons a schedule can actually plan against, by wagon type.
*
* A schedule built from a Train Builder train plans against ONLY that train's
* own consist. A legacy/dynamic-consist schedule plans against the boarding
* yards' loose pool: a slot's wagon may ride from the train's origin OR
* already sit at the booking's own boarding yard and attach there, so the
* usable fleet is the union across the origin and every boarding yard.
*
* Public because batch fill needs the SAME stock the allocator will later
* validate against — selecting a booking the allocator cannot place is how
* customers ended up paying for wagons that were never there.
*/
async wagonStockForSchedule(
scheduleId: string | undefined,
originYardId: string,
boardingYardIds: Array<string | null | undefined> = [],
preloadedBuiltTrainId?: string | null,
): Promise<WagonStock> {
const builtTrainId =
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
});
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const wagon of wagons) {
remainingByTypeId.set(
wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
}
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
}
/**
* Stamp each plan slot with the leg it occupies (dynamic consist): the
* boarding/alighting yards of the bookings it carries. Null means the
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
* carrying bookings with mixed corridors stays whole-route (conservative).
*/
private stampSlotLegs(
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
scheduleOriginYardId: string,
scheduleDestinationYardId: string,
stops: string[],
): void {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
for (const slot of wagonPlan) {
const slotBookings = [
...new Set(slot.allocations.map((a) => a.bookingId)),
]
.map((id) => bookingById.get(id))
.filter((b): b is Booking => Boolean(b));
if (!slotBookings.length) continue;
const [first] = slotBookings;
const sameCorridor = slotBookings.every(
(b) =>
b.originYardId === first.originYardId &&
b.destinationYardId === first.destinationYardId,
);
if (sameCorridor) {
slot.boardYardId =
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
slot.alightYardId =
first.destinationYardId === scheduleDestinationYardId
? null
: first.destinationYardId;
continue;
}
// Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides
// the UNION of its cargo legs. A yard missing from the stop list keeps
// the slot on the whole route so capacity is never under-occupied.
let from = Number.POSITIVE_INFINITY;
let to = Number.NEGATIVE_INFINITY;
for (const b of slotBookings) {
const f = stops.indexOf(b.originYardId);
const t = stops.indexOf(b.destinationYardId);
if (f < 0 || t <= f) {
from = Number.POSITIVE_INFINITY;
break;
}
from = Math.min(from, f);
to = Math.max(to, t);
}
if (!Number.isFinite(from) || to <= from) continue;
slot.boardYardId = from === 0 ? null : stops[from];
slot.alightYardId = to === stops.length - 1 ? null : stops[to];
}
}
private async persistTrainSetWagons(
manager: EntityManager,
trainSetId: string,
wagonPlan: WagonPlanSlot[],
) {
const wagons = wagonPlan.map((slot) =>
manager.getRepository(TrainSetWagon).create({
trainSetId,
wagonTypeId: slot.wagonTypeId,
sequenceNo: slot.sequenceNo,
capacityTons: slot.capacityTons,
lengthMeters: slot.lengthMeters,
assignedWeightTons: slot.assignedWeightTons,
status: 'PLANNED',
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
}),
);
return manager.getRepository(TrainSetWagon).save(wagons);
}
private async persistAllocationsAndLoads(
manager: EntityManager,
savedWagons: TrainSetWagon[],
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
containerPlacements: ContainerPlacementInput[] = [],
) {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const lineById = new Map(
bookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
),
);
const allocationBySlotBooking = new Map<string, string>();
const containerItems: Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string | null;
grossWeightTons: number;
positionOnWagon: number | null;
containerId?: string | null;
containerNumber?: string | null;
sealNumber?: string | null;
}> = [];
const bulkLoads: Array<{
wagonBookingAllocationId: string;
bookingId: string;
cargoTypeId: string | null;
cargoDescription: string | null;
weightTons: number;
quantity: number;
}> = [];
for (let i = 0; i < savedWagons.length; i += 1) {
const slot = wagonPlan[i];
const trainSetWagon = savedWagons[i];
if (!slot || !trainSetWagon) continue;
for (const alloc of slot.allocations) {
const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: trainSetWagon.id,
bookingId: alloc.bookingId,
allocatedWeightTons: alloc.allocatedWeightTons,
loadType: alloc.loadType,
status: 'PLANNED',
}),
);
allocationBySlotBooking.set(
`${slot.sequenceNo}:${alloc.bookingId}`,
savedAllocation.id,
);
const booking = bookingById.get(alloc.bookingId);
if (!booking) continue;
if (alloc.loadType === AllocationLoadType.Bulk) {
bulkLoads.push({
wagonBookingAllocationId: savedAllocation.id,
bookingId: booking.id,
cargoTypeId: booking.cargoTypeId ?? null,
cargoDescription: booking.cargoFreeText ?? null,
weightTons: alloc.allocatedWeightTons,
quantity: 1,
});
}
}
}
for (const placement of containerPlacements) {
const lineEntry = lineById.get(placement.bookingContainerId);
if (!lineEntry) continue;
// Durably persist the container number on the booking container line first, so it
// survives a refresh regardless of whether a wagon allocation slot can be matched
// below. booking_container is the source of truth re-read into the preview units.
if (placement.containerNumber && placement.containerNumber.trim()) {
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
containerNumber: placement.containerNumber.trim(),
});
}
const allocationId = allocationBySlotBooking.get(
`${placement.sequenceNo}:${lineEntry.bookingId}`,
);
if (!allocationId) continue;
const { line } = lineEntry;
containerItems.push({
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
containerTypeId: line.containerTypeId ?? null,
grossWeightTons: Number(line.vgmPerUnitTons),
positionOnWagon: placement.unitIndex + 1,
containerId: placement.containerId ?? null,
containerNumber: placement.containerNumber?.trim() ?? null,
sealNumber: placement.sealNumber ?? null,
});
if (placement.containerId) {
await manager.getRepository(Container).update(placement.containerId, {
status: 'LOADED',
bookingId: lineEntry.bookingId,
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
});
}
}
if (containerItems.length) {
await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager);
}
if (bulkLoads.length) {
await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager);
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
/**
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
* legacy single-locomotive column on the train set.
*/
private async findLocomotiveIdsDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
manager?: EntityManager,
): Promise<Set<string>> {
if (!locomotiveIds.length) return new Set();
const runner = manager ?? this.dataSource;
const rows: { locomotive_id: string }[] = await runner.query(
`SELECT DISTINCT loco.locomotive_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND ts.id <> $1
AND loco.locomotive_id = ANY($2)`,
[excludeScheduleId, locomotiveIds],
);
return new Set(rows.map((r) => r.locomotive_id));
}
private async assertLocomotivesNotDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
): Promise<void> {
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
locomotiveIds,
excludeScheduleId,
);
if (!busy.size) return;
const locos = await this.dataSource
.getRepository(Locomotive)
.find({ where: { id: In([...busy]) } });
const codes = locos.map((l) => l.code).join(', ');
throw new ConflictException(
`Locomotive(s) ${codes} are currently out on another dispatched train`,
);
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
totalLengthMeters: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locomotive.status !== 'AVAILABLE') {
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
}
if (
Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
totalWeightTons
) {
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
}
if (
Number(locomotive.maxTrainLengthMeters) +
(Number(locomotive.overageToleranceMeters) || 0) <
totalLengthMeters
) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive;
}
private async buildEmptyTrainSet(
manager: EntityManager,
locomotives: Locomotive[],
builtTrainId: string | null = null,
) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
// Built fleet train this set was formed from (Train Builder), if any.
trainId: builtTrainId,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
relations: { originYard: true, destinationYard: true },
});
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
if (route.status !== 'AVAILABLE') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
);
}
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route;
}
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(
booking: Booking,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
) {
return {
id: booking.id,
reference: booking.reference,
freightType: booking.freightType,
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
priorityScore: booking.priorityScore,
schedulingStatus: booking.schedulingStatus,
containerType:
booking.bookingContainers
?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
quantity:
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(booking, tareDims),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
status: booking.status,
isGovernment: Boolean(booking.isGovernment),
};
}
/**
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
* Schedules with no bookings (type null) match nothing. Applied to `id` so
* the list query stays on findAndCount instead of a query-builder rewrite.
*/
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
`AND b.freight_type ${cmp} :${param})`;
if (freightType === 'MIXED') {
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
);
}
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
{ ftIs: freightType, ftNot: freightType },
);
}
private resolveScheduleFreightType(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
const types = new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.freightType)
.filter((t): t is string => Boolean(t)),
);
if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
if (types.size > 1) return 'MIXED';
return null;
}
/**
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
* past a concurrent insert that grabbed the same sequence (the unique index
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
* runs inside the caller's transaction manager so the row joins the same commit.
*/
private async insertScheduleWithReference(
manager: EntityManager,
build: (reference: string) => TrainSchedule,
): Promise<TrainSchedule> {
const year = new Date().getFullYear();
const repo = manager.getRepository(TrainSchedule);
for (let attempt = 0; attempt < 5; attempt += 1) {
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
try {
return await repo.save(build(reference));
} catch (err) {
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
continue;
}
throw err;
}
}
// Unreachable — the loop either returns or throws — but satisfies the compiler.
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
computeScheduleWagonUsage({
wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings,
maxWagons: schedule.maxWagons,
});
return {
id: schedule.id,
reference: schedule.reference ?? null,
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,
direction: schedule.direction ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
id: schedule.trainSet.train.id,
code: schedule.trainSet.train.code,
trainName: schedule.trainSet.train.trainName ?? null,
}
: null,
locomotive: schedule.trainSet?.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
/** Coupled slots carrying a booking allocation — matches the wagon plan. */
wagonsUsed,
/** Coupled consist size; the denominator of "used". */
wagonsTotal,
/** Claimed by bookings (incl. unpaid) — not bookable. */
wagonsReserved,
/** Consist minus what bookings have claimed; what is still bookable. */
wagonsRemaining,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
maxWagons: schedule.maxWagons ?? 0,
remainingWagons: Math.max(
0,
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
),
};
}
/** AVAILABLE locomotives at the route's origin yard. */
/**
* All in-service locomotives, annotated for the schedule-creation picker.
* Advance scheduling means nothing is filtered out — staff see status, whether the
* locomotive is at the origin yard yet, and how many future schedules it already has.
*/
async getAvailableLocomotivesForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
const locomotives = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
order: { code: 'ASC' },
});
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
? await this.dataSource.query(
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND loco.locomotive_id = ANY($1)
GROUP BY loco.locomotive_id`,
[locomotives.map((l) => l.id)],
)
: [];
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
return locomotives.map((loco) => ({
...loco,
atOriginYard: loco.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
}));
}
/**
* All schedulable built trains (Train Builder), annotated for the
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with no locomotive at all are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
const trains = await this.dataSource.getRepository(Train).find({
where: {
status: Not(
In([
Freight.TrainStatus.OutOfService,
Freight.TrainStatus.UnderMaintenance,
Freight.TrainStatus.Deactivated,
]),
),
},
relations: {
currentYard: true,
locomotives: { locomotive: true },
wagons: { wagonType: true },
},
order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } },
});
const counts: { train_id: string; future_count: string }[] = trains.length
? await this.dataSource.query(
`SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND tset.train_id = ANY($1)
GROUP BY tset.train_id`,
[trains.map((t) => t.id)],
)
: [];
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 1)
.map((train) => {
const wagons = train.wagons ?? [];
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
id: train.currentYard.id,
code: train.currentYard.code,
label: train.currentYard.label,
}
: null,
locomotives: (train.locomotives ?? [])
.filter((link) => link.locomotive)
.map((link) => ({
id: link.locomotive!.id,
code: link.locomotive!.code,
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
totalTareTons: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
),
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
// Live from the coupled set — `capacity_tons` still holds the old
// single-locomotive figure on trains built before pull weight summed.
maxPullWeightTons: roundTons(
combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
)?.maxPullWeightTons ?? Number(train.capacityTons),
),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
});
}
/**
* Where consist work can physically happen right now. Before departure it is
* the built train's own yard. After dispatch it is the route stop the train
* is STANDING AT per its latest checkpoint — null while rolling between
* stops or when the last checkpoint is off-route, and consist work is closed
* there. Arrived/cancelled schedules always return null (history only).
*/
private async currentConsistYardId(
schedule: TrainSchedule,
): Promise<string | null> {
if (
schedule.status === TrainScheduleStatusEnum.Draft ||
schedule.status === TrainScheduleStatusEnum.Scheduled
) {
return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId;
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null;
const rows: Array<{ yard_id: string | null }> = await this.dataSource.query(
`SELECT yard_id
FROM freight.train_checkpoint_events
WHERE train_schedule_id = $1
ORDER BY occurred_at DESC, created_at DESC
LIMIT 1`,
[schedule.id],
);
const yardId = rows[0]?.yard_id ?? null;
if (!yardId) return null;
return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId)
? yardId
: null;
}
/**
* Physical wagons whose cargo still RIDES beyond the given stop: any
* allocation whose booking alights strictly after it. Cargo whose
* destination is this stop (or an earlier one) has been offloaded here and
* no longer blocks its wagon — that wagon may be trimmed or switched away.
* Before departure the stop is the origin, so every allocated wagon counts
* as aboard — one rule covers both phases. Unknown destinations and
* off-route stops stay conservative (aboard).
*/
// ponytail: trusts booking.destinationYardId, not a physical unload
// confirmation — if staff trim before actually unloading, the cargo strands.
// Wire the journey unload flag in if that ever bites.
private wagonIdsWithCargoBeyond(
schedule: TrainSchedule,
atYardId: string | null,
): Set<string> {
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
const atIdx = atYardId ? stops.indexOf(atYardId) : -1;
const aboard = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue;
const ridesOn = (slot.allocations ?? []).some((allocation) => {
const destination = allocation.booking?.destinationYardId;
const destIdx = destination ? stops.indexOf(destination) : -1;
if (destIdx < 0 || atIdx < 0) return true;
return destIdx > atIdx;
});
if (ridesOn) aboard.add(slot.physicalWagonId);
}
return aboard;
}
/**
* Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
* against the locomotive limits incl. overage tolerance, addable yard wagons,
* and the adjustment history.
*/
async getScheduleConsist(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
// Where the train stands right now — the origin yard before departure, the
// checkpoint stop after it. Null = rolling; the consist is view-only then.
const currentYardId = await this.currentConsistYardId(schedule);
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const addableWagons = currentYardId
? await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
})
: [];
const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots whose cargo still rides beyond the current stop — those wagons
// cannot be trimmed, only switched. Cargo offloaded at this stop (or
// earlier) has released its wagon.
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
// Only OTHER trains' pins block edits here — this train's own schedules
// (incl. the paired return run) have their pins managed by the edit itself
// (removal clears, switch re-points).
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
undefined,
builtTrain.id,
);
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const consistTareTons = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
);
const consistLengthMeters = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
);
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
// capacity, so trimming/coupling wagons moves the FULL line live.
const wagonUsage =
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
const mapWagon = (wagon: Wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
}
: null,
});
return {
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
train: {
id: builtTrain.id,
code: builtTrain.code,
trainName: builtTrain.trainName ?? null,
currentYardId: builtTrain.currentYardId ?? null,
},
limits: {
maxPullWeightTons,
overageToleranceTons,
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
maxTrainLengthMeters,
overageToleranceMeters,
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
},
totals: {
wagonCount: wagons.length,
cargoTons,
consistTareTons,
grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters,
},
scheduleCapacity: wagonUsage
? {
...wagonUsage,
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
}
: null,
wagons: wagons.map((wagon) => {
const loaded = loadedWagonIds.has(wagon.id);
const pinnedElsewhere = pinnedToLiveIds.has(wagon.id);
return {
...mapWagon(wagon),
loaded,
removable: !pinnedElsewhere && !loaded,
// A loaded wagon can't leave, but its SLOT can change wagon: switch
// moves the cargo allocations onto a same-type replacement.
switchable: !pinnedElsewhere,
blockReason: pinnedElsewhere
? 'Pinned by another live schedule'
: loaded
? 'Cargo aboard rides beyond this stop — switch it instead'
: null,
};
}),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
id: log.id,
action: log.action,
wagonId: log.wagonId,
wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId,
yardId: log.yardId ?? null,
occurredAt: log.occurredAt,
})),
// Editable before departure, and after it whenever the train is standing
// at a route stop (mid-route wagon work at station B); frozen while
// rolling and once arrived/cancelled.
editable:
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
(schedule.status === TrainScheduleStatusEnum.Dispatched &&
currentYardId != null),
currentStop: currentYardId
? {
yardId: currentYardId,
label:
this.mapScheduleStops(schedule).find(
(s) => s.yardId === currentYardId,
)?.label ?? currentYardId,
isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched,
}
: null,
};
}
/**
* Permanently adjust the built train's consist from a schedule: trim free
* wagons (their tare no longer rides — the usual fix when gross weight beats
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
* length headroom remain (limits incl. overage tolerance). The built train
* updates in place, the schedule's wagon cap follows, and every change is
* logged for the schedule's history.
*/
async adjustScheduleConsist(
scheduleId: string,
dto: AdjustScheduleConsistDto,
userId?: string | null,
) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
const switches = dto.switches ?? [];
if (!addWagonIds.length && !removeWagonIds.length && !switches.length) {
throw new BadRequestException(
'Nothing to adjust — pass wagons to add, remove and/or switch',
);
}
const switchFromIds = switches.map((s) => s.fromWagonId);
const switchToIds = switches.map((s) => s.toWagonId);
const touched = new Map<string, number>();
for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) {
touched.set(id, (touched.get(id) ?? 0) + 1);
}
if ([...touched.values()].some((count) => count > 1)) {
throw new BadRequestException(
'Each wagon may appear once per adjustment — not in two lists or two switches',
);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
// Consist edits are open before departure, and after it whenever the train
// is STANDING AT a route stop (checkpointed): that is exactly the "switch
// wagons at station B" window. Rolling between stops → frozen.
const currentYardId = await this.currentConsistYardId(schedule);
const editableStatus =
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
schedule.status === TrainScheduleStatusEnum.Dispatched;
if (!editableStatus || !currentYardId) {
throw new BadRequestException(
schedule.status === TrainScheduleStatusEnum.Dispatched
? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)'
: 'The consist can no longer be adjusted — the run is over',
);
}
const builtTrainRef = schedule.trainSet?.train;
if (!builtTrainRef) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
// Wagons whose cargo still rides beyond the current stop: never removable,
// but switchable — the replacement inherits the slot, cargo included.
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);
const lengthCapMeters = roundTons(
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({
where: { id: builtTrainRef.id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
const consist = await manager.getRepository(Wagon).find({
where: { trainId: train.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: coupled, cargo offloaded, no foreign pin ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
manager,
train.id,
);
// Every live train set of THIS built train (this run + siblings, e.g.
// the paired return leg) — their pins follow the consist edit.
const ownSetIds = (
await manager.getRepository(TrainSet).find({
where: { trainId: train.id },
select: { id: true },
})
).map((set) => set.id);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`,
);
}
if (pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`,
);
}
removed.push(wagon);
}
// Shared gate for every incoming wagon (couple or switch replacement):
// AVAILABLE, loose, and standing where the train stands right now.
const lockIncomingWagon = async (wagonId: string): Promise<Wagon> => {
// No `relations` on this query: Postgres refuses FOR UPDATE through the
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
// applied to the nullable side of an outer join"). Lock the row alone,
// then attach its type with a separate unlocked lookup.
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
);
}
if (wagon.currentYardId !== currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`,
);
}
wagon.wagonType =
(await manager
.getRepository(WagonType)
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
return wagon;
};
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
added.push(await lockIncomingWagon(wagonId));
}
// --- validate switches: outgoing coupled + not foreign-pinned; the
// replacement passes the incoming gate AND matches the wagon type, so
// the slot's cargo (weight, TEU geometry) rides it unchanged ---
const switchPairs: Array<{ from: Wagon; to: Wagon }> = [];
for (const { fromWagonId, toWagonId } of switches) {
const from = consistById.get(fromWagonId);
if (!from) {
throw new NotFoundException(
`Wagon ${fromWagonId} is not coupled to train ${train.code}`,
);
}
if (pinnedToLiveIds.has(from.id)) {
throw new ConflictException(
`Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`,
);
}
const to = await lockIncomingWagon(toWagonId);
if (to.wagonTypeId !== from.wagonTypeId) {
throw new BadRequestException(
`Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`,
);
}
switchPairs.push({ from, to });
}
// --- headroom check (only additions can push the train over a cap;
// switches are same-type and cancel out, but are computed honestly) ---
const removedIds = new Set(removed.map((w) => w.id));
const switchedFromIds = new Set(switchPairs.map((p) => p.from.id));
const finalConsist = [
...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)),
...added,
...switchPairs.map((p) => p.to),
];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const finalGrossTons = roundTons(cargoTons + finalTareTons);
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
throw new BadRequestException(
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
);
}
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
throw new BadRequestException(
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
);
}
// --- apply: detach trims, couple additions, swap switches, compact ---
// A wagon leaving the train stands wherever the train stands — stamping
// the stop yard is what makes it findable (and re-couplable) at B.
const detachPatch = {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
trainSetWagonId: null,
currentTrainScheduleId: null,
currentYardId,
};
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
}
if (removed.length && ownSetIds.length) {
// This train's own pins (all its runs) on trimmed wagons are stale —
// clear them so the freed wagon isn't still claimed by slots it left.
await manager
.getRepository(TrainSetWagon)
.update(
{ trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) },
{ physicalWagonId: null },
);
}
// Switches: the replacement takes the outgoing wagon's position AND its
// slot pins, so every cargo allocation now rides the new wagon. The
// outgoing wagon is left standing at the stop.
for (const { from, to } of switchPairs) {
const slots = ownSetIds.length
? await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: In(ownSetIds), physicalWagonId: from.id },
})
: [];
for (const slot of slots) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { physicalWagonId: to.id });
}
const ownSlot =
slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0];
await manager.getRepository(Wagon).update(to.id, {
trainId: train.id,
sequenceNumber: from.sequenceNumber,
status: WagonStatus.Assigned,
trainSetWagonId: ownSlot?.id ?? null,
currentTrainScheduleId: from.currentTrainScheduleId ?? null,
});
// Mirror on the in-memory row — the compaction below sorts by it.
to.sequenceNumber = from.sequenceNumber;
await manager.getRepository(Wagon).update(from.id, detachPatch);
}
const remaining = consist.filter(
(w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id),
);
const switchedIn = switchPairs.map((p) => p.to);
const compacted = [...remaining, ...switchedIn].sort(
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
);
for (let i = 0; i < compacted.length; i++) {
if (compacted[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 });
}
}
let sequence = compacted.length;
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
// The schedule is full when every consist wagon is allocated.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { maxWagons: finalConsist.length });
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
const now = new Date();
await logRepo.save(
[
...removed.map((wagon) => ({
action: 'REMOVE' as const,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...added.map((wagon) => ({
action: 'ADD' as const,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
})),
...switchPairs.map(({ from, to }) => ({
action: 'SWITCH' as const,
wagonId: to.id,
// varchar(50) — two long wagon numbers could overflow the column.
wagonNumber: `${from.wagonNumber}${to.wagonNumber}`.slice(0, 50),
})),
].map((entry) =>
logRepo.create({
trainScheduleId: scheduleId,
trainId: train.id,
action: entry.action,
wagonId: entry.wagonId,
wagonNumber: entry.wagonNumber,
adjustedByUserId: userId ?? null,
yardId: currentYardId,
occurredAt: now,
}),
),
);
});
// The consist IS the schedule's booking capacity, so an edit moves the
// FULL line: freeing slots on a FULL schedule reopens its window, taking
// the last slot closes it. Staff may shrink below what is already
// committed — allowed, but reported back as a warning (never silently).
const warnings: string[] = [];
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
if (usage) {
const nowFull = usage.remainingSlots <= 0;
if (usage.overAllocatedBy > 0) {
warnings.push(
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
`${usage.allocatedWagons}${usage.overAllocatedBy} wagon(s) over capacity. ` +
'Couple more wagons or free bookings before departure.',
);
}
if (wasFull && !nowFull) {
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
warnings.push(
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
'so it is no longer FULL and can take bookings again.',
);
} else if (!wasFull && nowFull) {
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
warnings.push(
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
);
}
}
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
}
/**
* Unified change history for the schedule detail "History" tab: wagon
* consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened
* at) merged with booking composition removals, newest first. Actor resolves
* through iam.users; rows survive wagon/train deletion (log tables carry
* plain columns, no FKs).
*/
async getScheduleHistory(scheduleId: string) {
type HistoryRow = {
id: string;
kind: 'WAGON' | 'BOOKING';
action: string;
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: Date;
};
const wagonRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT l.id,
l.action,
l.wagon_number AS "subject",
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
WHERE l.train_schedule_id = $1
AND l.deleted_at IS NULL
ORDER BY l.occurred_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
...r,
kind: 'WAGON' as const,
note: null,
}));
const bookingRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT r.id,
r.booking_reference AS "subject",
r.notes AS "note",
COALESCE(u.username, u.email) AS "actor",
r.removed_at AS "occurredAt"
FROM freight.train_composition_removal_logs r
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
WHERE r.schedule_id = $1
AND r.deleted_at IS NULL
ORDER BY r.removed_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
...r,
kind: 'BOOKING' as const,
action: 'BOOKING_REMOVED',
yardLabel: null,
}));
// Per-booking journey events (load at boarding yard / unload at alighting
// yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a
// multi-stop train's disjoint legs (a→b loads then unloads at b while a→c
// rides through) each show as their own row. Append-only: these columns are
// only ever set once per booking, never cleared, so rows never disappear.
const journeyRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT b.id,
b.reference AS "subject",
COALESCE(oy.label, oy.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
b.loaded_at AS "occurredAt"
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
WHERE b.loaded_at IS NOT NULL
AND b.deleted_at IS NULL
ORDER BY b.loaded_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
...r,
kind: 'BOOKING' as const,
action: 'BOOKING_LOADED',
note: null,
}));
const unloadRows: HistoryRow[] = (
await this.dataSource.query(
`SELECT b.id,
b.reference AS "subject",
COALESCE(dy.label, dy.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
b.arrived_at AS "occurredAt"
FROM freight.bookings b
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
WHERE b.arrived_at IS NOT NULL
AND b.deleted_at IS NULL
ORDER BY b.arrived_at DESC
LIMIT 200`,
[scheduleId],
)
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
...r,
kind: 'BOOKING' as const,
action: 'BOOKING_UNLOADED',
note: null,
}));
return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort(
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
}
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
*/
private async syncBuiltTrainAfterScheduleChange(
manager: EntityManager,
trainId: string,
moveToYardId?: string | null,
): Promise<void> {
const train = await manager.getRepository(Train).findOne({ where: { id: trainId } });
if (!train) return;
const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {};
const managed = [
Freight.TrainStatus.Available,
Freight.TrainStatus.Scheduled,
Freight.TrainStatus.InService,
];
if (!managed.includes(train.status)) {
if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch);
return;
}
const rows: { status: string }[] = await manager.query(
`SELECT DISTINCT ts.status
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[trainId],
);
const statuses = new Set(rows.map((r) => r.status));
const next = statuses.has('DISPATCHED')
? Freight.TrainStatus.InService
: statuses.size
? Freight.TrainStatus.Scheduled
: Freight.TrainStatus.Available;
await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch });
}
/**
* A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a
* FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s
* milestones with the destination stop AFTER the origin stop — OR (routes with
* no milestones recorded) the pair equals the schedule's own endpoints. This
* mirrors the sub-leg matching the create-booking path already does
* (`getBookableScheduleEntities`), so a through train (Djibouti → Kality →
* Dire) is announced and bookable for every leg it actually serves
* (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two
* endpoints. Static SQL fragment (no user input) interpolated into the window
* queries below; `ts` and `cr` must be the schedule and contract_route aliases.
*/
private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `(
EXISTS (
SELECT 1
FROM freight.route_milestones mo
JOIN freight.route_milestones md
ON md.route_id = mo.route_id
AND md.sequence_no > mo.sequence_no
WHERE mo.route_id = ts.route_id
AND mo.yard_id = cr.origin_yard_id
AND md.yard_id = cr.destination_yard_id
)
OR (cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id)
)`;
/**
* Upcoming/open booking windows announced on the portal home "booking
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
* are listed so every customer sees what is opening — not just those on their
* contract lanes; DOMESTIC trains are always open and need no announcement.
*
* When `companyId` is given, a matching active contract on the lane is
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
* "Book now"); customers with no covering contract still see the window with a
* null contract, and the portal routes them to the contract list to get one.
* A contract covering any FORWARD sub-leg of the corridor counts as covering
* the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`).
*/
async getBookingWindowsForCompany(companyId: string | null) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.contract_routes cr
ON cr.deleted_at IS NULL
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
LEFT JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
[companyId],
);
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
// per-row ordering, so re-sort the mapped rows by departure for the client.
return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
return ta - tb;
});
}
/**
* Upcoming/open booking windows on a single contract's routes. Used to gate the
* booking form for the customer AND Ethiopian GL (who books on the customer's
* behalf): no window row with isOpenNow=true → booking entry is hidden.
*/
async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.contract_id = $1
AND cr.deleted_at IS NULL
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[contractId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
}
/**
* All announced booking windows across every lane — import window cycles AND
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
* phase filter as the customer-facing lists, no contract scoping.
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) =>
this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
);
}
private mapBookingWindowRow(r: BookingWindowRow) {
const origin = r.origin_label ?? r.origin_code ?? null;
const destination = r.destination_label ?? r.destination_code ?? null;
// Full corridor from the route's milestones (origin → stops → destination).
// Falls back to the schedule's origin/destination when no milestones exist.
const milestoneStops = (r.route_stations ?? []).filter(
(s): s is string => Boolean(s),
);
const routeStations =
milestoneStops.length >= 2
? milestoneStops
: [origin, destination].filter((s): s is string => Boolean(s));
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
trainNumber: r.train_number ?? null,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
windowPhase: r.window_phase,
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
windowOpensAt: r.window_opens_at,
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
origin,
destination,
routeStations,
};
}
/** OPEN schedules a new booking may target (with rough remaining capacity).
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
* returns schedules whose route passes through both yards in the correct order.
*/
/**
* Raw OPEN same-route schedule entities a new booking may target (with the
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
* originStationId / scheduledDepartureDate / trainSet).
*/
private async getBookableScheduleEntities(
originYardId?: string,
destinationYardId?: string,
): Promise<
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
> {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
route: { milestones: true },
originStation: true,
destinationStation: true,
// Cargo relations feed effectiveWagonsRequired for legacy links whose
// stored wagonsRequired is NULL — without them such a booking counts
// as 1 wagon and per-leg occupancy under-reports.
scheduleBookings: {
booking: {
bookingContainers: { containerType: true },
cargoType: { wagonTypes: true },
},
},
},
order: { scheduledDepartureDate: 'ASC' },
});
// A train that has already departed can never be booked, even if the window
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
// past-departure schedule never leaks into the portal day pool, the schedule
// calendar, or the ET GL create-booking gate.
const now = new Date();
return schedules
.filter(
(s) =>
s.scheduledDepartureDate != null &&
s.scheduledDepartureDate > now,
)
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.filter((s) => {
// Build the full stop list: origin -> milestones (ordered) -> destination
const milestones = s.route?.milestones ?? [];
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
const uniqueStopYardIds: string[] = [];
for (const yardId of stopYardIds) {
if (!uniqueStopYardIds.includes(yardId)) {
uniqueStopYardIds.push(yardId);
}
}
// Check origin yard filter
if (originYardId) {
if (!uniqueStopYardIds.includes(originYardId)) {
return false;
}
}
// Check destination yard filter
if (destinationYardId) {
if (!uniqueStopYardIds.includes(destinationYardId)) {
return false;
}
// Ensure destination comes after origin (if both are specified)
if (originYardId) {
const originIndex = uniqueStopYardIds.indexOf(originYardId);
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
if (destIndex <= originIndex) {
return false;
}
}
}
return true;
});
}
/**
* Wagon slots still free for a leg of the schedule's corridor, per edge:
* capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a
* booking alighting at a mid-stop frees its slots for the edges past it, so a
* train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length
* (a→b→…→h). No leg given → the most open edge (can anything board at all?).
*/
private remainingWagonsForLeg(
schedule: TrainSchedule,
originYardId?: string,
destinationYardId?: string,
): number {
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
const budget = new CorridorBudget(stops, {
wagons: Number(schedule.maxWagons ?? 0),
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
});
for (const sb of schedule.scheduleBookings ?? []) {
if (!sb.booking) continue;
budget.subtract(
{
wagons: this.effectiveWagonsRequired(sb.booking),
weightTons: 0,
lengthMeters: 0,
},
budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId),
);
}
const leg =
originYardId && destinationYardId
? budget.legOf(originYardId, destinationYardId)
: null;
const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining();
return Math.max(0, remaining.wagons);
}
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.getBookableScheduleEntities(
originYardId,
destinationYardId,
);
return schedules.map((s) => ({
...this.mapScheduleListItem(s),
// Leg-aware: the list item's own remainingWagons is consist-based
// (maxWagons coupled wagons) and reads 0 on any fully-consisted train.
remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId),
}));
}
/**
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
* departure on the route. Customers pick a DAY (not a train) — so this returns
* only the day strings, no capacity, counts or train info.
*/
async getAvailableDays(
originYardId?: string,
destinationYardId?: string,
): Promise<{ days: string[] }> {
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
const days = new Set<string>();
for (const s of schedules) {
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
}
return { days: [...days].sort() };
}
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
* train capacity (not fully allocated) AND its wagon stock can physically carry
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
* NOT gated — a booking bigger than the free capacity is accepted and the batch
* engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
async getAvailableDaysForCargo(input: {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
input.destinationYardId,
);
if (schedules.length === 0) return { days: [] };
// Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the
// REQUESTED leg's free slots, not on how many wagons are coupled to the
// consist (a fully-consisted train read 0 remaining and hid its days).
const withCapacity = schedules.filter(
(s) =>
this.remainingWagonsForLeg(
s,
input.originYardId,
input.destinationYardId,
) > 0,
);
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
const days = new Set<string>();
for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
/**
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
* whose wagon stock can physically carry the selected cargo — every container
* line (or the bulk cargo type) must map to at least one wagon type the
* schedule's stock actually has. Stock = the built train's own consist, or the
* origin yard's loose pool for schedules assembled from loose locomotives.
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
* a partial split offer from the batch engine later.
*/
private async filterCargoCompatibleSchedules(
schedules: TrainSchedule[],
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
},
): Promise<TrainSchedule[]> {
if (!schedules.length) return schedules;
const required = await this.requiredWagonTypeSets(cargo);
// No cargo identity supplied — nothing to gate on (legacy callers).
if (required === null) return schedules;
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
return schedules.filter((s) => {
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
return required.every((set) => {
for (const typeId of set) if (stock.has(typeId)) return true;
return false;
});
});
}
/**
* One Set of allowed wagon-type ids per required cargo dimension: per
* container line's type (or per container size when only sizes are known),
* or a single set for the bulk cargo type. `null` = no cargo identity given,
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
* configured) — the gate then blocks every schedule, mirroring the hard
* config violation scheduling raises for the same state.
*/
private async requiredWagonTypeSets(cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<Set<string>[] | null> {
if (cargo.freightType === 'CONTAINER') {
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
if (typeIds.length) {
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
[typeIds],
);
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
}
return [...byType.values()];
}
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
// ANY active container type of that size has a matching wagon type.
const sizes = [
...new Set(
(cargo.containers ?? [])
.map((line) => parseInt(String(line.containerSize), 10))
.filter((n) => Number.isFinite(n) && n > 0),
),
];
if (!sizes.length) return null;
const rows: { size_ft: number; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.size_ft, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
AND (ct.is_active IS DISTINCT FROM false)`,
[sizes],
);
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
}
return [...bySize.values()];
}
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
`SELECT wt.id AS wagon_type_id
FROM freight.cargo_types c
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE c.deleted_at IS NULL
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
);
const set = new Set<string>();
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
return [set];
}
/**
* Wagon-type ids each schedule's stock can offer: the built train's own
* consist for train-bound schedules, the origin yard's loose usable pool
* otherwise. Batched — two queries for the whole schedule list.
*/
private async scheduleWagonTypeStock(
schedules: TrainSchedule[],
): Promise<Map<string, Set<string>>> {
const builtTrainIds = [
...new Set(
schedules
.map((s) => s.trainSet?.trainId)
.filter((id): id is string => Boolean(id)),
),
];
const looseOriginYardIds = [
...new Set(
schedules
.filter((s) => !s.trainSet?.trainId)
.map((s) => s.originStationId)
.filter(Boolean),
),
];
const [trainRows, yardRows] = await Promise.all([
builtTrainIds.length
? (this.dataSource.query(
`SELECT train_id, wagon_type_id
FROM freight.wagons
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
GROUP BY train_id, wagon_type_id`,
[builtTrainIds],
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
looseOriginYardIds.length
? (this.dataSource.query(
`SELECT current_yard_id, wagon_type_id
FROM freight.wagons
WHERE train_id IS NULL AND deleted_at IS NULL
AND status IN ('AVAILABLE', 'ASSIGNED')
AND current_yard_id = ANY($1::uuid[])
GROUP BY current_yard_id, wagon_type_id`,
[looseOriginYardIds],
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
]);
const byTrain = new Map<string, Set<string>>();
for (const row of trainRows) {
const set = byTrain.get(row.train_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byTrain.set(row.train_id, set);
}
const byYard = new Map<string, Set<string>>();
for (const row of yardRows) {
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byYard.set(row.current_yard_id, set);
}
const result = new Map<string, Set<string>>();
for (const s of schedules) {
const trainId = s.trainSet?.trainId;
result.set(
s.id,
trainId
? byTrain.get(trainId) ?? new Set()
: byYard.get(s.originStationId) ?? new Set(),
);
}
return result;
}
/**
* Booking-time gate for a chosen day: does the route have an OPEN departure
* that day at all, and can any of that day's departures physically carry the
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
* partial split offer instead).
*/
async checkDayCargoCompatibility(
originYardId: string,
destinationYardId: string,
day: string,
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
containerTypeIds?: string[];
},
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
const schedules = await this.getBookableScheduleEntities(
originYardId,
destinationYardId,
);
const onDay = schedules.filter(
(s) =>
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
);
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
}
/**
* Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
*/
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
let milestoneYards: string[] = [];
if (schedule.route?.milestones?.length) {
milestoneYards = [...schedule.route.milestones]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yardId);
} else if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
milestoneYards = milestones.map((m) => m.yardId);
}
const raw = milestoneYards.length >= 2
? milestoneYards
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
const unique: string[] = [];
for (const yardId of raw) {
if (yardId && !unique.includes(yardId)) unique.push(yardId);
}
return unique;
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
/**
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per detail/board request without letting edits go stale long. */
private wagonTareDimsCache: {
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
expiresAt: number;
} | null = null;
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
return this.wagonTareDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
t.id,
{
tareWeightTons: Number(t.tareWeightTons) || 0,
capacityTons: Number(t.capacityTons) || 0,
},
]),
);
const value = {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
},
container: {
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
* every wagon the booking occupies — the same gross axis the batch engine
* spends against the locomotive's pull limit. Wagon count mirrors the batch
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
* tons ÷ payload for bulk — whichever is largest).
*/
private grossBookingWeightTons(
booking: Pick<
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = bookingCargoTons(booking);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
const wagonTypeId =
booking.freightType === 'BULK'
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
const dims = {
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
capacityTons: typed?.capacityTons || fallback.capacityTons,
};
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon) — more wagons for the same cargo, so more tare to pull.
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
const byItems = bulkItemWagonsRequired(
booking,
dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
);
const wagons = Math.max(1, stored, byLength, byWeight, byItems);
return roundTons(cargo + wagons * dims.tareWeightTons);
}
private async mapScheduleDetail(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
// Snapshot state decides below whether the live consist may be drawn at
// all, so it is derived before the consist wagons are fetched.
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer
// describe THIS train. If a frozen snapshot was captured at the transition,
// the per-slot wagon number + booking allocations are read from it instead.
const snapshot = schedule.wagonAllocationSnapshot ?? null;
const isWagonAllocationFrozen = Boolean(
snapshot &&
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled,
);
const snapshotSlotByTrainSetWagonId = new Map(
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// Booking has no ORM relation to Contract (FK only) — fetched separately
// by id so the "on this train" cards can show the contract reference.
const contractIds = [
...new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.contractId)
.filter((id): id is string => Boolean(id)),
),
];
// All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] =
await Promise.all([
this.loadWagonTareDims(),
requiresLoadingConfirmation
? this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } })
: null,
this.getWindowConfig(),
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
// Mirror the pinning direction: a reverse-order schedule draws the
// whole consist back-to-front, empties included.
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
})
: [],
contractIds.length
? this.dataSource
.getRepository(Contract)
.find({ where: { id: In(contractIds) }, select: { id: true, reference: true } })
: [],
]);
const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference]));
const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt)
: true;
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
// train's remaining wagons as consist-only entries and the composition views
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
// really is: loaded slots first, then the empty consist. Skipped for frozen
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
// later trains, so the live consist no longer describes THIS departure.
const coveredPhysicalIds = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(slot.id)
: undefined;
const physicalId = frozenSlot
? frozenSlot.physicalWagonId
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
// Fallback only — real empty rows below carry the wagon's OWN physical
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
// The wagon's REAL coupling position, so an empty wagon in the middle
// of the train draws in the middle — not appended after every loaded
// slot. Falls back to a tail position only if the wagon somehow has
// no sequence number of its own.
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
// Coupled wagons ride the whole corridor — they count on every leg.
boardYardId: null,
alightYardId: null,
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}));
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
// the slot's own stored value.
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
list: T[],
) =>
orderConsistWagons(list, {
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
reverseWagonOrder: schedule.reverseWagonOrder,
});
// Heaviest-edge consist usage. Cross-leg slot sharing means plain sums
// over-report a multi-stop train — a wagon reused Gelan→Adama and
// Adama→Doraleh is two slots but ONE physical wagon, and the train is never
// heavier/longer than its heaviest single leg. Same math as the pull-limit
// enforcement; coupled-but-empty consist wagons ride every edge.
const heaviestLeg = schedule.trainSet
? (() => {
const usage = maxEdgeConsistUsage(
[
...(schedule.trainSet.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters),
tareWeightTons: w.wagonType
? Number(w.wagonType.tareWeightTons)
: 0,
assignedWeightTons: Number(w.assignedWeightTons),
boardYardId: w.boardYardId ?? null,
alightYardId: w.alightYardId ?? null,
allocations: w.allocations ?? [],
})),
...emptyConsistWagons.map((w) => ({
lengthMeters: w.lengthMeters,
tareWeightTons: Number(w.tareWeightTons ?? 0),
assignedWeightTons: 0,
allocations: [],
})),
],
this.mapScheduleStops(schedule).map((s) => s.yardId),
);
return {
grossWeightTons: roundTons(usage.grossWeightTons),
lengthMeters: roundTons(usage.lengthMeters),
loadedWagonCount: usage.loadedWagonCount,
};
})()
: null;
return {
id: schedule.id,
reference: schedule.reference ?? null,
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null,
windowOpensAt: schedule.windowOpensAt
? schedule.windowOpensAt.toISOString()
: null,
windowClosesAt: schedule.windowClosesAt
? schedule.windowClosesAt.toISOString()
: null,
docReviewEndsAt: schedule.docReviewEndsAt
? schedule.docReviewEndsAt.toISOString()
: null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export
// pay windows are tuned separately).
paymentWindowMinutes:
schedule.rulePaymentWindowMinutes ??
(schedule.direction === 'EXPORT'
? windowCfg.exportPaymentWindowMinutes
: windowCfg.paymentWindowMinutes),
},
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
: null,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
actualDepartureAt: schedule.actualDepartureAt ?? null,
originStation: schedule.originStation,
destinationStation: schedule.destinationStation,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
id: schedule.trainSet.train.id,
code: schedule.trainSet.train.code,
trainName: schedule.trainSet.train.trainName ?? null,
}
: null,
trainSet: schedule.trainSet
? {
id: schedule.trainSet.id,
status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
// What the locomotives actually haul: usage on the corridor's
// heaviest edge, not the sum of every leg's slots.
heaviestLeg,
locomotive: schedule.trainSet.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
maxPullWeightTons: roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
maxTrainLengthMeters: roundTons(
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
),
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
status: loco.status,
currentYardId: loco.currentYardId ?? null,
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: drawConsist(
(schedule.trainSet.wagons ?? [])
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
// comes live. Falls back to live if a slot is missing from the snap.
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
// Draw the slot at its physical wagon's REAL coupling position,
// not the planning-time slot index — the two diverge once a
// load has been dragged onto a different wagon (moveWagonLoad
// repoints physicalWagonId but a slot keeps its own sequenceNo),
// or once wagon types were interleaved at pinning time. Frozen
// and not-yet-pinned slots have no live physical wagon to trust,
// so they keep their own slot sequence.
const sequenceNo =
frozenSlot || !wagon.physicalWagon
? wagon.sequenceNo
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
return {
id: wagon.id,
sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
// Corridor span this slot rides (null = schedule endpoint) —
// lets the UI compute per-leg utilization from real slots.
boardYardId: wagon.boardYardId ?? null,
alightYardId: wagon.alightYardId ?? null,
physicalWagonId: frozenSlot
? frozenSlot.physicalWagonId
: wagon.physicalWagonId ?? null,
physicalWagonNumber: frozenSlot
? frozenSlot.physicalWagonNumber
: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations: frozenSlot
? frozenSlot.allocations.map((allocation) => ({
id: null,
bookingId: allocation.bookingId,
bookingReference: allocation.bookingReference,
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
loadType: allocation.loadType,
status: null,
// Frozen: container detail collapses to the captured numbers;
// per-container geometry isn't re-derivable post-release.
containerItems: allocation.containerNumbers.map((containerNumber) => ({
id: null,
containerNumber,
containerTypeId: null,
grossWeightTons: null,
containerId: null,
positionOnWagon: null,
bookingContainerId: null,
})),
bulkLoad: null,
}))
: wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
};
})
.concat(emptyConsistWagons),
),
}
: null,
bookings:
schedule.scheduleBookings?.map((sb) => ({
id: sb.booking?.id ?? sb.bookingId,
reference: sb.booking?.reference ?? null,
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
weightTons: sb.booking
? this.grossBookingWeightTons(sb.booking, tareDims)
: 0,
// Cargo only (VGM / bulk tons) — what the customer actually booked,
// without the wagons' tare. The legs tab shows this per booking.
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Which leg of the corridor this booking rides — the workspace can't
// tell a ride-along (intercity) or sub-corridor booking from through
// cargo without it.
tradeDirection: sb.booking?.tradeDirection ?? null,
originYardId: sb.booking?.originYardId ?? null,
destinationYardId: sb.booking?.destinationYardId ?? null,
origin:
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
destination:
sb.booking?.destinationYard?.label ??
sb.booking?.destinationYard?.code ??
null,
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
contractReference:
(sb.booking?.contractId
? contractReferenceById.get(sb.booking.contractId)
: null) ?? null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
isGovernment: Boolean(sb.booking?.isGovernment),
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// Gross ceiling the validator holds each leg to: the set's weakest
// locomotive pull limit plus its overage tolerance. Booking weightTons
// above are gross too, so the strip can sum them per leg against this.
maxGrossWeightTons: (() => {
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
return setLimits
? roundTons(
Number(setLimits.maxPullWeightTons) +
(Number(setLimits.overageToleranceTons) || 0),
)
: null;
})(),
// Length ceiling per leg, same shape as maxGrossWeightTons: the set's
// most restrictive locomotive length plus its overage tolerance.
maxLengthMeters: (() => {
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
const cap =
Number(setLimits?.maxTrainLengthMeters) +
(Number(setLimits?.overageToleranceMeters) || 0);
return setLimits && Number.isFinite(cap) ? roundTons(cap) : null;
})(),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
isWagonAllocationFrozen,
wagonAllocationSnapshot: snapshot,
};
}
/**
* A booking's wagon footprint with a computed fallback: rows linked by paths
* that never stamped `wagonsRequired` (legacy allocate) read NULL, and every
* occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed
* a near-empty leg. Falls back to the TEU/weight-derived count when the cargo
* relations are loaded; a bare booking still degrades to 1.
*/
private effectiveWagonsRequired(booking: Booking): number {
const stored = Number(booking.wagonsRequired);
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
const bulkCapacity =
booking.freightType === 'BULK' && bulkCapacities.length
? Math.max(...bulkCapacities)
: undefined;
// BULK with no cargo relations loaded: recomputing would size against a
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
return storedCeil;
}
// Stored is a candidate, never an early return (batch parity): rows
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
}
/**
* yardId → display label for error messages that name corridor legs. One
* query; unknown ids fall back to the raw id so a message never goes blank.
*/
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
if (!yardIds.length) return new Map();
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(yardIds) } });
return new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
}
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
private mapScheduleStops(
schedule: TrainSchedule,
): Array<{ yardId: string; label: string }> {
const milestones = [...(schedule.route?.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const raw = milestones.length >= 2
? milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}))
: [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
const seen = new Set<string>();
return raw.filter((stop) => {
if (!stop.yardId || seen.has(stop.yardId)) return false;
seen.add(stop.yardId);
return true;
});
}
private isHoldActive(booking: Booking): boolean {
if (!booking.holdExpiresAt) return false;
return booking.holdExpiresAt.getTime() > Date.now();
}
private resolvePostUnassignStatus(booking: Booking | null): string {
if (!booking) return SchedulingStatus.NotScheduled;
if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
return SchedulingStatus.Holding;
}
return SchedulingStatus.Eligible;
}
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!schedule.trainSet?.locomotive) {
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not linked to this schedule');
}
if (!this.isReadyToLoadBooking(booking)) {
throw new BadRequestException('Booking is not paid and ready to load');
}
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.has(bookingId)) {
throw new BadRequestException('Booking is already assigned to a wagon');
}
const allBookingIds = [...wagonAssignedIds, bookingId];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
schedule.maxWagons ?? undefined,
);
const validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
scheduleId,
);
if (!validation.valid) {
throw new BadRequestException({
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.some((b) => b.id === bookingId)) {
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
throw new BadRequestException({
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
violations: validation.violations,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === bookingId,
);
if (missingForBooking) {
throw new BadRequestException({
message: missingForBooking.issue,
violations: [missingForBooking.issue],
});
}
const assignableSet = new Set(validation.bookings.map((b) => b.id));
const assignPlacements = placementsForBookings(placements, assignableSet, units);
const needsPlacements = containerBookings.length > 0;
return this.assignBookingsToSchedule(
scheduleId,
{
bookingIds: validation.bookings.map((b) => b.id),
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
}
/**
* Government-priority switch: free wagons by unassigning the selected
* commercial bookings, then allocate the government booking in their place.
* The gov booking must need no more wagons than the switched-out bookings
* free (ops selects more bookings otherwise), and the post-switch
* composition is fully validated BEFORE anything is unassigned so a failing
* switch never leaves the train half-emptied.
*/
async switchGovernmentBooking(
scheduleId: string,
governmentBookingId: string,
removeBookingIds: string[],
userId?: string,
) {
if (removeBookingIds.includes(governmentBookingId)) {
throw new BadRequestException('Government booking cannot be switched out by itself');
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot switch bookings on a schedule in status ${schedule.status}`,
);
}
const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([
governmentBookingId,
]);
if (!govBooking) {
throw new NotFoundException(`Booking ${governmentBookingId} not found`);
}
if (!govBooking.isGovernment) {
throw new BadRequestException('Only government bookings can be switched onto a train');
}
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.has(governmentBookingId)) {
throw new BadRequestException('Government booking is already allocated on this train');
}
const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds);
if (removed.length !== removeBookingIds.length) {
throw new NotFoundException('One or more bookings to switch out were not found');
}
const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id));
if (notOnTrain.length) {
throw new BadRequestException(
`Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`,
);
}
const govRemoved = removed.filter((b) => b.isGovernment);
if (govRemoved.length) {
throw new BadRequestException(
`Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`,
);
}
// Dry-run the post-switch composition: survivors + the gov booking.
const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id));
const previewDto = {
bookingIds: [...survivorIds, governmentBookingId],
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
schedule.maxWagons ?? undefined,
);
const validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
scheduleId,
);
const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0);
if (!validation.valid) {
throw new BadRequestException({
message: `Switch validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.some((b) => b.id === governmentBookingId)) {
throw new BadRequestException(
`Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`,
);
}
const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan);
if (govWagons > freedWagons) {
throw new BadRequestException(
`Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`,
);
}
// Same container-number gate as single-booking assignment, applied to the
// incoming gov booking only.
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === governmentBookingId,
);
if (missingForGov) {
throw new BadRequestException({
message: missingForGov.issue,
violations: [missingForGov.issue],
});
}
// ponytail: unassign + assign run as sequential own-transaction steps, not
// one atomic unit — the dry-run above means the assign step can only fail
// on a concurrent edit; staff re-add from the eligible pool if it does.
for (const booking of removed) {
await this.unassignBooking(scheduleId, booking.id, userId);
}
const assignableSet = new Set(validation.bookings.map((b) => b.id));
const assignPlacements = placementsForBookings(placements, assignableSet, units);
return this.assignBookingsToSchedule(
scheduleId,
{
bookingIds: validation.bookings.map((b) => b.id),
containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined,
},
undefined,
);
}
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
// Callers that already hold the full schedule graph (batch board detail)
// pass it in so the preview doesn't re-load the same heavy graph.
preloadedSchedule?: TrainSchedule,
): Promise<WagonAllocationAttemptResult> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, false);
}
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
async tryAutoWagonAllocation(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, true);
}
private async buildAllocationAttempt(
schedule: TrainSchedule,
performAssign: boolean,
): Promise<WagonAllocationAttemptResult> {
const empty: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
if (!schedule.trainSet?.locomotive) {
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
return {
...empty,
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
};
}
const linkedBookings = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
schedule.id,
schedule,
);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
schedule.maxWagons ?? undefined,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Validation failed';
return {
...empty,
violations: [message],
issues: eligible.map((b) => ({
bookingId: b.id,
status: 'FAILED' as const,
issue: message,
})),
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
const deferredMap = new Map(
validation.deferredBookings.map((d) => [d.id, d.reason]),
);
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingNumbers = findMissingContainerNumberIssues(units, placements);
const missingByBooking = new Map<string, string>();
for (const m of missingNumbers) {
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
}
const placeholderWarnings = new Map<string, string>();
for (const p of placements) {
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
const unit = units.find(
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
);
if (unit && !placeholderWarnings.has(unit.bookingId)) {
placeholderWarnings.set(
unit.bookingId,
'Container number auto-assigned — verify before dispatch.',
);
}
}
const assignableIds = validation.bookings
.filter((b) => !missingByBooking.has(b.id))
.map((b) => b.id);
const assignableSet = new Set(assignableIds);
const assignPlacements = placementsForBookings(
placements,
assignableSet,
units,
);
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
}
if (missingByBooking.has(b.id)) {
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
}
if (deferredMap.has(b.id)) {
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
}
if (!fittingIds.has(b.id)) {
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
return {
bookingId: b.id,
status: 'FAILED',
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
};
}
if (wagonAssignedIds.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
}
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
});
const result: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: validation.deferredBookings,
issues,
violations: validation.violations,
};
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
};
}
try {
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
result.assignedBookingIds = assignableIds;
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId)) {
issue.status = 'ASSIGNED';
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
}
}
} catch (err) {
const message =
err instanceof BadRequestException
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
'; ',
) ??
(err.getResponse() as { message?: string }).message ??
err.message)
: err instanceof Error
? err.message
: 'Allocation failed';
result.violations = [...result.violations, message];
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
issue.status = 'FAILED';
issue.issue = message;
}
}
}
return result;
}
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
}
const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
if (!wagon) {
throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
}
if ((wagon.allocations ?? []).length > 0) {
throw new BadRequestException(
'Cannot remove a wagon slot that has active allocations; remove the booking first',
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
// Recount from the slot rows rather than decrementing the cached counter.
// A blind `wagonCount - 1` desyncs the moment two removals race or the
// in-memory schedule graph is stale, and the counter is what the schedule
// capacity math reads.
const remaining = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
select: { id: true, lengthMeters: true },
});
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
wagonCount: remaining.length,
totalLengthMeters: roundTons(
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
),
});
});
return this.getTrainScheduleById(scheduleId);
}
async updateContainerItem(
scheduleId: string,
itemId: string,
dto: UpdateContainerItemDto,
): Promise<{ id: string; containerNumber: string | null }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status === 'DISPATCHED') {
throw new BadRequestException('Cannot edit a dispatched schedule');
}
const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
where: { id: itemId },
relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
});
if (!item) {
throw new NotFoundException(`Container item ${itemId} not found`);
}
const wagonId = item.wagonBookingAllocationId;
const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
where: { id: wagonId },
relations: ['trainSetWagon'],
});
if (!wagonAllocation?.trainSetWagon) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
const trainSetWagonId = wagonAllocation.trainSetWagon.id;
const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.includes(trainSetWagonId)) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
containerNumber: dto.containerNumber ?? null,
});
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
/**
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
* Whole-load moves keep every packing rule intact by construction (a valid
* load stays valid on any wagon whose type supports it), which is what lets
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
* swap with containers.
*
* Three shapes, picked from the target:
* - target is an empty consist-only wagon (coupled on the built train, no
* slot row): REPIN — the source slot simply points at that physical wagon
* (type/capacity/length follow), and the wagon it left shows as empty.
* - target is an empty slot: allocations repoint to it and the load-coupled
* slot fields (assigned weight, status, board/alight leg) move across.
* - target is a loaded slot: the two loads swap wagons the same way.
*
* Validated per direction: the receiving wagon's type must support the
* incoming load type, and the incoming cargo must fit its rated payload.
*/
async moveWagonLoad(
scheduleId: string,
sourceWagonId: string,
dto: MoveWagonLoadDto,
): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
}
if (sourceWagonId === dto.targetWagonId) {
return this.getTrainScheduleById(scheduleId);
}
const slots = schedule.trainSet?.wagons ?? [];
const source = slots.find((w) => w.id === sourceWagonId);
if (!source) {
throw new NotFoundException('Source wagon is not part of this schedule');
}
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({ where: { trainSetWagonId } });
const sourceAllocs = await loadAllocations(source.id);
if (!sourceAllocs.length) {
throw new BadRequestException('Source wagon has no load to move');
}
// Target: a slot of this train set, or an empty consist-only wagon of the
// built train (physical wagon with no slot row yet).
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const wagonForTarget = slotById
? null
: schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
if (!slotById && !wagonForTarget) {
throw new NotFoundException('Target wagon is not part of this schedule');
}
// A physical wagon holds at most one slot. When the caller addressed the
// wagon directly but a slot is already pinned to it, move into that slot
// rather than minting a second one on the same wagon.
const targetSlot =
slotById ??
(wagonForTarget
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
: null);
const consistWagon = targetSlot ? null : wagonForTarget;
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
if (targetSlot && targetSlot.id === source.id) {
return this.getTrainScheduleById(scheduleId);
}
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
];
const cargoOf = (allocs: WagonBookingAllocation[]) =>
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
// Name wagons by their physical number — the consist is drawn in the train's
// coupling order, so a slot's sequenceNo is not the position staff can see.
const slotLabel = (slot: TrainSetWagon) =>
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
capacityTons: number,
) => {
const incoming = loadTypesOf(allocs);
// Unknown type or no declared support list → staff decides; don't block.
if (wagonType) {
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
for (const loadType of incoming) {
const ok =
supported.includes(loadType) ||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
supported.length === 0;
if (!ok) {
throw new BadRequestException(
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
);
}
}
}
const cargo = cargoOf(allocs);
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
throw new BadRequestException(
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
);
}
};
// What the target must be able to receive…
checkReceives(
sourceAllocs,
wagonLabel(targetSlot, consistWagon),
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
);
// …and, on a swap, what comes back to the source.
if (targetAllocs.length) {
checkReceives(
targetAllocs,
slotLabel(source),
source.wagonType,
Number(source.capacityTons),
);
}
await this.dataSource.transaction(async (manager) => {
const slotRepo = manager.getRepository(TrainSetWagon);
const allocs = manager.getRepository(WagonBookingAllocation);
// Load-coupled slot fields travel with the load; wagon identity stays.
const loadFieldsOf = (slot: TrainSetWagon) => ({
assignedWeightTons: slot.assignedWeightTons,
status: slot.status,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
});
const emptyLoadFields = {
assignedWeightTons: 0,
status: 'PLANNED',
boardYardId: null,
alightYardId: null,
};
const sourceLoadFields = loadFieldsOf(source);
// Empty consist wagon with no slot row yet: give it one, then move the
// load into it. Repinning the SOURCE slot onto that wagon would have been
// fewer writes, but it renames the wagons instead of moving the load —
// the loaded slot becomes wagon B and B's identity pops out as an empty
// wagon where A used to be. Staff read that as the train re-ordering
// itself. A wagon must never change place because a container moved.
if (consistWagon) {
const { maxSequenceNo } = (await slotRepo
.createQueryBuilder('slot')
.select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo')
.where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId })
.getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 };
const created = await slotRepo.save(
slotRepo.create({
trainSetId: source.trainSetId,
wagonTypeId: consistWagon.wagonTypeId,
physicalWagonId: consistWagon.id,
// Plan-order key only — the consist is drawn in the train's coupling
// order (wagons.sequence_number), so appending here moves nothing.
// It just has to clear the (train_set_id, sequence_no) unique index.
sequenceNo: Number(maxSequenceNo) + 1,
capacityTons: roundTons(
Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons),
),
lengthMeters: roundTons(
Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters),
),
...sourceLoadFields,
}),
);
for (const alloc of sourceAllocs) {
await allocs.update(alloc.id, { trainSetWagonId: created.id });
}
await slotRepo.update(source.id, emptyLoadFields);
return;
}
const target = targetSlot as TrainSetWagon;
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
for (const alloc of sourceAllocs) {
await allocs.update(alloc.id, { trainSetWagonId: target.id });
}
for (const alloc of targetAllocs) {
await allocs.update(alloc.id, { trainSetWagonId: source.id });
}
await slotRepo.update(target.id, sourceLoadFields);
await slotRepo.update(source.id, targetLoadFields);
});
return this.getTrainScheduleById(scheduleId);
}
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const allBookings = await this.bookingsRepository.findAll({
where: { trainScheduleId: scheduleId },
select: [
'id',
'reference',
'freightType',
'priorityScore',
'cargoTotalWeightVgm',
'status',
'schedulingStatus',
'paymentStatus',
'isGovernment',
],
});
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const unassigned = allBookings
.filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
.sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
wagonTypeId: row.wagonTypeId,
wagonTypeCode: row.wagonTypeCode,
needed: 0,
available: row.available,
shortfall: 0,
}));
// Gross weight needs the scheduling graph (containers, cargo type, wagon
// types) that the trimmed select above deliberately skips.
const tareDims = await this.loadWagonTareDims();
const fullById = new Map(
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
(b) => [b.id, b],
),
);
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
b as Booking,
fleetByTypeId,
);
return {
id: b.id,
reference: b.reference ?? null,
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
// GROSS: cargo + tare of the wagons the booking occupies.
grossWeightTons: this.grossBookingWeightTons(
(fullById.get(b.id) ?? b) as Booking,
tareDims,
),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,
};
}),
);
return { fleetAtOrigin, bookings };
}
private async previewUnassignedBookingAssignability(
schedule: TrainSchedule,
wagonAssignedIds: Set<string>,
booking: Booking,
fleetByTypeId: Map<string, { code: string; available: number }>,
): Promise<{
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
shortage: BookingWagonShortage | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
shortage: null,
};
}
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
const resolvedBooking = fullBooking ?? booking;
// List-based resolution: every wagon type allowed for the booking's
// container/cargo type counts toward its availability.
const allowed = this.loadAllowedWagonTypes([resolvedBooking]);
const candidates =
freightType === 'BULK'
? [...allowed.byCargoTypeId.values()].flat()
: [...allowed.byContainerTypeId.values()].flat();
if (!candidates.length) {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
shortage: null,
};
}
const bulkCapacity =
freightType === 'BULK'
? Math.max(...candidates.map((wt) => Number(wt.capacityTons)))
: undefined;
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/');
const yardWagonsAvailable = candidates.reduce(
(sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0),
0,
);
const allBookingIds = [...wagonAssignedIds, booking.id];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
schedule.maxWagons ?? undefined,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
shortage: null,
};
}
if (!validation.valid) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
shortage: null,
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
if (!fittingIds.has(booking.id)) {
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
const yardShortfall =
yardWagonsAvailable < wagonsRequired
? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
: null;
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason:
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
shortage:
deferred?.shortage ??
(yardShortfall
? {
wagonTypeCodes: requiredWagonTypeCode,
wagonsNeeded: wagonsRequired,
wagonsAvailable: yardWagonsAvailable,
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
}
: null),
};
}
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
if (containerBookings.some((b) => b.id === booking.id)) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === booking.id,
);
if (missing) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
shortage: null,
};
}
}
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: true,
blockReason: null,
shortage: null,
};
}
/**
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
* structured per-type shortage this booking would hit if placed on top of the
* schedule's current wagon assignments, or null when it fits (or is blocked
* by something other than missing wagons — those keep the legacy link-then-
* fix-manually path).
*/
async previewPaidBookingWagonShortage(
scheduleId: string,
bookingId: string,
): Promise<BookingWagonShortage | null> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule?.trainSet?.locomotive) return null;
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
booking,
fleetByTypeId,
);
return assignability.shortage;
}
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: {
status: string;
paymentStatus?: string | null;
isGovernment?: boolean;
}): boolean {
if (booking.status === 'EXPIRED') return false;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return false;
}
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
if (booking.isGovernment) return true;
return false;
}
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(
scheduleId: string,
// Pass when the caller already holds the schedule with trainSet.wagons —
// only wagon ids are read here, the old full-graph reload was pure waste.
preloadedSchedule?: TrainSchedule,
): Promise<Set<string>> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { trainSetWagonId: In(wagonIds) },
select: ['bookingId'],
});
return new Set(allocations.map((a) => a.bookingId));
}
// ── Train merge ────────────────────────────────────────────────────────────
// Combine two trains into one departure. The schedule the action is taken
// from ALWAYS survives: its train set is repointed at the target train, the
// target's wagons join this consist, and the source train is emptied and
// deactivated. When the target also runs a schedule on the SAME DAY, that
// schedule's bookings move here and it is soft-deleted; the target's
// other-day schedules contribute wagons only.
/** Statuses whose schedules may take part in a merge. */
private static readonly MERGEABLE_STATUSES: string[] = [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
];
/**
* Everything a merge needs to decide, gathered once. Both `previewMerge` and
* `mergeScheduleTrain` run this so the modal shows exactly what will happen
* and the commit cannot diverge from it.
*/
private async planMerge(scheduleId: string, targetTrainId: string) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) {
throw new BadRequestException(
`Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`,
);
}
const sourceTrainId = schedule.trainSet?.trainId ?? null;
if (sourceTrainId && sourceTrainId === targetTrainId) {
throw new BadRequestException(
'That is already this schedule\'s train — pick a different one to merge in.',
);
}
const targetTrain = await this.dataSource
.getRepository(Train)
.findOne({ where: { id: targetTrainId } });
if (!targetTrain) {
throw new NotFoundException(`Train ${targetTrainId} not found`);
}
// Every schedule the target train is committed to, via its train sets.
// Locomotives come along: the merged train is pulled by the union of this
// schedule's locos and the target train's, so capacity checks need both.
const targetSets = await this.dataSource
.getRepository(TrainSet)
.find({
where: { trainId: targetTrainId },
relations: { locomotives: { locomotive: true }, locomotive: true },
});
const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
where: { trainSetId: In(targetSetIds) },
})
: [];
// The same-day schedule is the one whose bookings move here. Only a
// draft/scheduled one qualifies — a dispatched departure keeps its cargo.
const sameDay = (a: Date | string, b: Date | string) =>
new Date(a).toISOString().slice(0, 10) ===
new Date(b).toISOString().slice(0, 10);
const absorbed =
targetSchedules.find(
(s) =>
s.id !== schedule.id &&
sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) &&
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
) ?? null;
// Wagons ride with the train, so every OTHER draft/scheduled schedule on it
// is affected too — it gains the merged consist but never the bookings.
const affectedOthers = targetSchedules.filter(
(s) =>
s.id !== schedule.id &&
s.id !== absorbed?.id &&
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
);
const untouched = targetSchedules.filter(
(s) =>
s.id !== schedule.id &&
s.id !== absorbed?.id &&
!TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
);
// The wagons joining this consist: whatever physically sits on the target
// train today.
const incomingWagons = await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
// EVERY wagon physically on the source train moves with the merge — not
// just the ones coupled into this schedule's set. A wagon left behind
// would strand on the deactivated train. "Loose" = on the train but not
// backing a set slot; it joins the counts and the capacity math.
const sourceWagons = sourceTrainId
? await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
: [];
const coupledPhysicalIds = new Set(
(schedule.trainSet?.wagons ?? [])
.map((w) => w.physicalWagonId)
.filter(Boolean),
);
const looseSourceWagons = sourceWagons.filter(
(w) => !coupledPhysicalIds.has(w.id),
);
const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id },
relations: { booking: true },
})
: [];
return {
schedule,
sourceTrainId,
targetTrain,
targetSets,
absorbed,
affectedOthers,
untouched,
incomingWagons,
looseSourceWagons,
movingBookings,
};
}
/**
* Blocking checks, run against the plan. Returns human-readable reasons; an
* empty array means the merge may proceed. Kept separate from `planMerge` so
* the preview can SHOW the reasons rather than throwing on them.
*/
private async mergeBlockers(
plan: Awaited<ReturnType<TrainSchedulingService['planMerge']>>,
): Promise<string[]> {
const blockers: string[] = [];
const { schedule, incomingWagons, movingBookings, absorbed } = plan;
if (incomingWagons.length === 0) {
blockers.push(
`${plan.targetTrain.code} has no wagons to merge — nothing would move.`,
);
}
// ── Capacity: the merged consist must fit the merged train's locomotives ─
// Existing side = coupled set slots PLUS loose wagons riding the source
// train without a slot — they all move, so they all count.
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0,
}));
const wagonTypeIds = [
...new Set(
[...incomingWagons, ...plan.looseSourceWagons]
.map((w) => w.wagonTypeId)
.filter(Boolean),
),
];
const wagonTypes = wagonTypeIds.length
? await this.dataSource
.getRepository(WagonType)
.find({ where: { id: In(wagonTypeIds) } })
: [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
const slotFromWagon = (w: Wagon) => {
const t = typeById.get(w.wagonTypeId);
return {
lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 0,
};
};
const incomingSlots = incomingWagons.map(slotFromWagon);
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
// The merged train is pulled by the union of this schedule's locomotives
// and whatever already pulls the target train (its sets keep their locos).
// Pull weight adds up across the pool; length stays the tightest cap.
const locoPool = [
...this.locomotivesOfTrainSet(schedule.trainSet),
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
];
const limits = combinedLocomotiveLimits([
...new Map(locoPool.map((l) => [l.id, l])).values(),
]);
if (limits) {
const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ take: 1 });
const caps = trainHardCaps(limits, {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
});
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
// Merge is a physical consist move, so only the physical axes gate it:
// can this schedule's locomotives pull the merged weight and length.
// `schedule.maxWagons` is the booking-window planning ceiling — using it
// as a slot cap here blocked every merge into a bigger train (e.g. a
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
// ceiling to the merged size instead.
const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: merged.length,
});
blockers.push(...violations);
}
// ── Legs: an absorbed booking must be servable by THIS schedule's route ──
if (absorbed && movingBookings.length) {
const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null);
if (routeYardIds.length) {
const position = new Map(routeYardIds.map((id, i) => [id, i]));
const slotIds = movingBookings.map((mb) => mb.bookingId);
const allocations = slotIds.length
? await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { bookingId: In(slotIds) },
relations: { trainSetWagon: true },
})
: [];
const offRoute = new Set<string>();
for (const alloc of allocations) {
const board = alloc.trainSetWagon?.boardYardId ?? null;
const alight = alloc.trainSetWagon?.alightYardId ?? null;
// Null on both = rides the whole route; always compatible.
if (!board && !alight) continue;
const from = board ? position.get(board) : 0;
const to = alight ? position.get(alight) : routeYardIds.length - 1;
if (from === undefined || to === undefined || from >= to) {
offRoute.add(alloc.bookingId);
}
}
if (offRoute.size) {
blockers.push(
`${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` +
'travel legs this schedule\'s route does not serve in the same order.',
);
}
}
}
return blockers;
}
/** Ordered yard ids along a route, origin first. Empty when unknown. */
private async routeYardSequence(routeId: string | null): Promise<string[]> {
if (!routeId) return [];
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId }, order: { sequenceNo: 'ASC' } });
return milestones
.map((m) => m.yardId)
.filter((id): id is string => Boolean(id));
}
/**
* What a merge WOULD do, without doing it. Drives the confirmation modal:
* which schedules gain wagons, which one is absorbed, and why it is blocked.
*/
async previewMerge(scheduleId: string, targetTrainId: string) {
const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan);
// Coupled slots plus loose wagons on the source train — everything moves.
const existingCount =
(plan.schedule.trainSet?.wagons?.length ?? 0) +
plan.looseSourceWagons.length;
return {
canMerge: blockers.length === 0,
blockers,
targetTrain: {
id: plan.targetTrain.id,
code: plan.targetTrain.code,
trainNumber: plan.targetTrain.trainNumber ?? null,
},
wagons: {
current: existingCount,
incoming: plan.incomingWagons.length,
merged: existingCount + plan.incomingWagons.length,
},
/** The same-day schedule whose bookings move here and is then removed. */
absorbedSchedule: plan.absorbed
? {
id: plan.absorbed.id,
reference: plan.absorbed.reference ?? null,
scheduledDepartureDate: plan.absorbed.scheduledDepartureDate,
status: plan.absorbed.status,
bookingsMoving: plan.movingBookings.length,
}
: null,
/** Other draft/scheduled schedules on the target — wagons only. */
affectedSchedules: plan.affectedOthers.map((s) => ({
id: s.id,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
status: s.status,
})),
/** On the target train but left alone (dispatched, cancelled, …). */
untouchedSchedules: plan.untouched.map((s) => ({
id: s.id,
reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
status: s.status,
})),
sourceTrainWillDeactivate: Boolean(plan.sourceTrainId),
};
}
/**
* Execute the merge. One transaction: repoint the train set, move the wagons
* (appended last so the builder can reorder them later), carry the absorbed
* schedule's bookings across, soft-delete that schedule, and deactivate the
* emptied source train.
*/
async mergeScheduleTrain(
scheduleId: string,
dto: MergeScheduleTrainDto,
): Promise<TrainSchedule> {
const plan = await this.planMerge(scheduleId, dto.targetTrainId);
const blockers = await this.mergeBlockers(plan);
if (blockers.length) {
throw new BadRequestException(blockers.join(' '));
}
const {
schedule,
sourceTrainId,
targetTrain,
absorbed,
incomingWagons,
movingBookings,
} = plan;
const trainSetId = schedule.trainSetId;
await this.dataSource.transaction(async (manager) => {
// 1. This schedule's set now runs on the target train.
await manager.getRepository(TrainSet).update(trainSetId, {
trainId: targetTrain.id,
});
// 2. The physical wagons follow the train — the target's stay put, and
// EVERY wagon on the source train (coupled or loose) moves across so
// nothing strands on the deactivated train.
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.id },
);
}
if (sourceTrainId) {
await manager
.getRepository(Wagon)
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
}
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder
// in the train builder afterwards.
const existing = schedule.trainSet?.wagons ?? [];
let nextSequence =
existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1;
const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({
where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) },
});
for (const row of incomingSetWagons) {
if (row.trainSetId === trainSetId) continue;
await manager.getRepository(TrainSetWagon).update(row.id, {
trainSetId,
sequenceNo: nextSequence,
});
nextSequence += 1;
}
// 4. The absorbed schedule's bookings move here. `bookingId` is uniquely
// indexed, so these rows are UPDATED across rather than re-inserted.
if (absorbed && movingBookings.length) {
await manager
.getRepository(TrainScheduleBooking)
.update(
{ trainScheduleId: absorbed.id },
{ trainScheduleId: schedule.id },
);
}
// 5. The absorbed schedule is soft-deleted — its bookings still exist and
// still depart that day, so nobody is notified and nothing is lost.
if (absorbed) {
await manager.getRepository(TrainSchedule).softDelete(absorbed.id);
}
// 6. The source train is now empty; park it.
if (sourceTrainId) {
await manager.getRepository(Train).update(sourceTrainId, {
status: Freight.TrainStatus.Deactivated,
});
}
// 7. Keep the set's cached totals honest.
const mergedCount =
(schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length;
await manager
.getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount });
// 8. Booking capacity follows the consist: raise (never lower) the
// planning ceiling so the merged wagons are actually sellable.
if (mergedCount > (schedule.maxWagons ?? 0)) {
await manager
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: mergedCount });
}
});
this.logger.log(
`Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` +
`${incomingWagons.length} wagon(s) moved` +
(absorbed
? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))`
: '') +
(sourceTrainId ? ', source train deactivated' : '') +
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
);
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
}
}