import { AllocationLoadType, LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonMovementKind, WagonStatus, } from '@edr/types'; import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; 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 { 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 { 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 { 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 { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; 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 { PinWagonsDto } from './dto/pin-wagons.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 { ImportDjiboutiOperation, type ImportDjiboutiDocumentType, } from './entities/import-djibouti-operation.entity'; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, selectBookingsWithinFleetCap, summarizeFleetWarnings, totalAssignedWeight, wagonsRequiredForBooking, type DeferredBookingRow, type FleetAvailabilityRow, } from './fleet-plan.util'; import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, expandBookingContainerUnits, getContainerSlotSequenceNos, roundTons, sumWagonsRequired, type TrainLimitConfig, validateContainerPlacements, validateMixedTrainLimits, validateTrainLimits, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { deriveTrainCapacityFromLocomotive, minLocomotiveLimits, wagonTypeDimensionsFromEntity, WagonTypeDimensions, } from './train-capacity.util'; import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; import { computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, eatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { BookingJourneyService } from './booking-journey.service'; import { TrainCheckpointEventsRepository } from './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; /** * 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, }; } /** * 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; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: 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, paymentWindowMinutes: liveCfg.paymentWindowMinutes, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes, }; } 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; status: string | null; schedulingStatus: string | null; wagonsRequired: number; requiredWagonTypeCode: string; yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; } export interface UnassignedBookingsResponse { fleetAtOrigin: FleetAvailabilityRow[]; bookings: CompositionUnassignedBookingRow[]; } const DEFAULT_TRAIN_LIMITS: Required = { 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; 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; } @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, private readonly 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, ) {} /** * 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 { 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 { 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 { 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}`, ); } } 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; 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 bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, originStationId, destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, day, }); return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' }); } async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) { return this.getEligibleBookings({ ...query, freightType: 'BULK' }); } async getTrainSchedulingGlobalRules() { return this.loadGlobalRulesRow(); } async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { const row = await this.loadGlobalRulesRow(); if (!row) { throw new NotFoundException('Train scheduling global rules not configured'); } if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; if (dto.max20ftContainerWeightTons != null) { row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; } if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } 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.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; // 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; 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 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 { 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 ?? liveCfg.paymentWindowMinutes, reopenDelayMinutes: liveCfg.reopenDelayMinutes, }; // 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.', ); } await this.dataSource.getRepository(TrainSchedule).update(id, { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt, ...windowRuleSnapshot(merged), }); this.logger.log( `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, ); void this.emitWindowState(id); 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 { 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()}).`, ); } // 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); await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt, }); this.logger.log( `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + `(window reopens ${times.windowOpensAt.toISOString()})`, ); 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 { 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; 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 { const row = await this.loadGlobalRulesRow(); const num = (v: unknown, fallback: number) => { const n = v == null ? NaN : Number(v); return Number.isFinite(n) ? n : fallback; }; 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), reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), }; } async previewTrainSchedule(dto: PreviewTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, null, false, [], false, limits, dto.targetScheduleId, ), ); } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, 'CONTAINER', false, [], false, limits, dto.targetScheduleId, ), ); } async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, 'BULK', false, [], false, limits, dto.targetScheduleId, ), ); } private buildPreviewResponse(validation: Awaited>) { 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 locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 2) { throw new BadRequestException('A train must be pulled by at least two locomotives'); } const scheduleWarnings: string[] = []; 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); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(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 windowCfg = await this.getWindowConfig(); // 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 (lead 3, today 11th → first allowed departure is the 14th); EXPORT // lead is in hours (24h = 1 day ahead). const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); if (departure.getTime() < earliest.getTime()) { const detail = 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; ` + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + `(earliest ${earliest.toISOString()})`, ); } // 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 windowFields = direction === 'EXPORT' ? { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg), } : { // IMPORT and DOMESTIC share the import booking-day window cycle. bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) .maxWagonsPerTrain; // Retry past a concurrent insert that grabbed the same S- 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, status: TrainScheduleStatusEnum.Draft, direction, maxWagons, ...windowFields, }), ); // Locomotives stay in their current status until dispatch — advance scheduling // must not block the locomotive from serving earlier trains. 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 that targeted it. This mirrors // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. if (dto.bookingIds.length) { const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); if (stray.length) { throw new BadRequestException( `These bookings are not assigned to this schedule: ${stray .map((b) => b.reference ?? b.id) .join(', ')}`, ); } } const previewDto = { bookingIds: dto.bookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, maxTrainWeightTons: dto.maxTrainWeightTons, maxTrainLengthMeters: dto.maxTrainLengthMeters, maxWagonsPerTrain: dto.maxWagonsPerTrain, }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); // 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"). let containerPlacements = dto.containerPlacements; if (!containerPlacements?.length) { 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 slots = getContainerSlotSequenceNos(preview.wagonPlan); const generated = autoFillPlacements(units, slots); const missing = findMissingContainerNumberIssues(units, generated); if (missing.length) { throw new BadRequestException({ message: `Booking validation failed: ${missing .map((m) => m.issue) .join('; ')}`, violations: missing.map((m) => m.issue), }); } 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, }); } const { bookings, wagonType, 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); if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); } await this.dataSource.transaction(async (manager) => { const trainSetId = schedule.trainSetId; await this.releasePinnedWagonsForTrainSet(manager, 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, wagonType, 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 ?? [], ); for (const booking of bookings) { await this.bookingsRepository.updateSchedulingFields( booking.id, { schedulingStatus: SchedulingStatus.Eligible, wagonsRequired: sumWagonsRequired(booking), }, 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, ); }); 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); 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); await this.bookingsRepository.updateSchedulingFields( bookingId, { schedulingStatus, wagonsRequired: null }, manager, ); const remainingBookings = (schedule.scheduleBookings ?? []).filter( (sb) => sb.bookingId !== bookingId, ); if (remainingBookings.length === 0) { await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId); await this.wagonBookingAllocationsRepository.deleteByTrainSetId( schedule.trainSetId, manager, ); await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId }); await manager.getRepository(TrainSet).update(schedule.trainSetId, { totalWeightTons: 0, totalLengthMeters: 0, wagonCount: 0, status: 'DRAFT', }); } }); await this.trainCompositionRemovalLogRepository.create({ scheduleId, bookingId, bookingReference, removedByUserId: userId ?? null, removedAt: new Date(), }); console.log( `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be 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 items = bookings .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') .map((b) => ({ id: b.id, reference: b.reference ?? null, customer: b.company?.name ?? null, weightTons: b.cargoTotalWeightVgm, 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 slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => 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`); } if ( physicalWagon.status !== WagonStatus.Available && physicalWagon.currentTrainScheduleId !== scheduleId ) { throw new ConflictException( `Wagon ${physicalWagon.wagonNumber} is not available`, ); } if (physicalWagon.currentYardId !== schedule.originStationId) { throw new ConflictException( `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, ); } await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { physicalWagonId: assignment.physicalWagonId, status: 'RESERVED', }); await manager.getRepository(Wagon).update(assignment.physicalWagonId, { trainSetWagonId: assignment.trainSetWagonId, currentTrainScheduleId: scheduleId, status: WagonStatus.Assigned, }); } }); 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`); } 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); // 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); 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 }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); } 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')`) .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'); return this.getTrainScheduleById(scheduleId); } 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 { 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) { 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, wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, loadType: allocation.loadType ?? null, allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, containerNumbers: (allocation.containerItems ?? []) .map((item) => item.containerNumber) .filter(Boolean), })), })), 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, }; } private buildExportLoadListHtml(schedule: TrainSchedule): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); 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])); const rows = (schedule.trainSet?.wagons ?? []) .flatMap((wagon) => (wagon.allocations ?? []).map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; 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 ` ${esc(wagon.sequenceNo)} ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} ${esc(Number(wagon.capacityTons || 0).toFixed(3))} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} ${esc(sealNumbers)} `; }), ) .join(''); const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( (sum, wagon) => sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); return ` Export Marshalling Document
Ethio-Djibouti Railway S.C.

Export Marshalling Document / Load List

Train / Schedule ${esc(schedule.trainNumber ?? schedule.id)} Generated: ${esc(new Date().toLocaleString('en-GB'))}
Train ID${esc(schedule.trainNumber ?? schedule.id)}
Departure date${esc(date(schedule.scheduledDepartureDate))}
Departure time${esc(time(schedule.scheduledDepartureDate))}
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
Total loaded weight${esc(totalWeight.toFixed(3))} T
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
Status${esc(schedule.status)}
Direction${esc(schedule.direction)}
${rows || ''}
Seq Wagon No Wagon Type Equated Length Tare Weight Load Capacity Customer Name Customer ID Cargo Type Container No Chassis No Seal No
No wagon allocations found for this export train.
Loading and dispatch staff must verify wagon identity, seal number, container number, cargo type, and customer booking against the physical consist before departure.
Prepared person / date
Check person / date
Operations authorization / date
`; } 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'; } private buildImportLoadListHtml(loadList: Awaited>): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); 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 allocationRows = loadList.wagons .flatMap((wagon) => wagon.allocations.map( (allocation) => ` ${esc(wagon.sequenceNo)} ${esc(wagon.wagonNumber)} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `, ), ) .join(''); return ` Import Load List / Marshalling Document
Ethio-Djibouti Railway S.C.

Import Load List /
Marshalling Document

Djibouti-side gatepass, loading, and departure manifest
Train / Schedule ${esc(loadList.trainNumber ?? loadList.trainScheduleId)} Generated: ${esc(date(loadList.generatedAt))}
Route${esc(loadList.route)}
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
Wagons${esc(loadList.wagons.length)}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
Documents
Gatepass
Ready
Loaded
Departed
Document

Wagon Marshalling Allocation

${allocationRows || ''}
Seq Wagon Booking Load Container numbers Weight T
No wagon allocations found for this train.
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.
Prepared by Djibouti operations
Train loading supervisor
EDR operations authorization
`; } private safeDocumentName(value: string): string { return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); } private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise { 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 { 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 { 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 { 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 { if (schedule.trainNumber) 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. const activeNumbered = await manager .getRepository(TrainSchedule) .createQueryBuilder('schedule') .setLock('pessimistic_write') .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) .andWhere('schedule.train_number IS NOT NULL') .getMany(); const usedNumbers = activeNumbered .map((s) => s.trainNumber) .filter((n): n is string => Boolean(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 { 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); } 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 }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'COMPLETED', }); } // 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, status: 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() { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, route: true, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, }, // Newest-created first (the client can re-sort; this is the default order). order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' }, }); return schedules.map((s) => this.mapScheduleListItem(s)); } 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`); } 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' }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } // 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, status: WagonStatus.Available, }); } } for (const sb of schedule.scheduleBookings ?? []) { const booking = await this.bookingsRepository.findById(sb.bookingId); await this.bookingsRepository.updateSchedulingFields( sb.bookingId, { schedulingStatus: this.resolvePostUnassignStatus(booking) }, manager, ); } }); // 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, 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(', ')}`, ); } if ( await (async () => { // 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. let stops = [dto.originStationId, dto.destinationStationId]; if (targetScheduleId) { const target = await this.trainSchedulesRepository.findById(targetScheduleId); if (target) stops = await this.stopYardsForSchedule(target); } return 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)'); } 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()})`, ); } const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); if (overweightLines.length) { violations.push( `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, ); } } } let wagonType: WagonType; let containerWagonType: WagonType; let bulkWagonType: WagonType; let demandPlan: WagonPlanSlot[]; let fittingBookings = bookings; let deferredBookings: DeferredBookingRow[] = []; let fleetAvailability: FleetAvailabilityRow[] = []; if (resolvedMode === 'MIXED') { const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); bulkWagonType = await this.resolveWagonType('BULK', bookingIds); wagonType = containerWagonType; demandPlan = buildMixedWagonPlan( containerBookings, bulkBookings, containerWagonType, bulkWagonType, ); } else { wagonType = await this.resolveWagonType(resolvedMode, bookingIds); containerWagonType = wagonType; bulkWagonType = wagonType; demandPlan = resolvedMode === 'CONTAINER' ? buildContainerWagonPlan(bookings, wagonType) : buildBulkWagonPlan(bookings, wagonType); } const originYardId = dto.originStationId; // Dynamic consist: a slot's physical 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. const boardYardIds = [ ...new Set( [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), ), ]; const fleetCountsByYard = await Promise.all( boardYardIds.map((yardId) => this.countFleetAvailability(yardId, targetScheduleId), ), ); const mergedFleet = new Map(); for (const rows of fleetCountsByYard) { for (const row of rows) { const existing = mergedFleet.get(row.wagonTypeId) ?? { code: row.wagonTypeCode, available: 0, }; existing.available += row.available; mergedFleet.set(row.wagonTypeId, existing); } } const fleetCounts = [...mergedFleet.entries()].map( ([wagonTypeId, value]) => ({ wagonTypeId, wagonTypeCode: value.code, available: value.available, }), ); const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); fleetAvailability = computeFleetAvailability( demandPlan, fleetByTypeId, new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), ); const selection = selectBookingsWithinFleetCap( bookings, fleetByTypeId, (booking) => booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, Number(bulkWagonType.capacityTons), ); fittingBookings = selection.fitting; deferredBookings = selection.deferred; warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); const wagonPlan = buildCappedWagonPlan({ bookings: fittingBookings, resolvedMode, containerWagonType, bulkWagonType, }); this.stampSlotLegs( wagonPlan, fittingBookings, dto.originStationId, dto.destinationStationId, ); 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); if (resolvedMode === 'MIXED') { pushLimit( validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); violations.push( ...validateContainerPlacements( containerBookings, wagonPlan, containerPlacements, placementRules, ), ); violations.push( ...(await this.validateFleetContainers(containerPlacements, containerBookings)), ); } } else { pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( ...validateContainerPlacements( fittingBookings, wagonPlan, containerPlacements, placementRules, ), ); violations.push( ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), ); } } const totalWeightTons = totalAssignedWeight(fittingBookings); const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; if (!violations.includes(message) && !warnings.includes(message)) { pushLimit([message]); } } 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 = minLocomotiveLimits(assignedLocomotives); if (offYard) { warnings.push( `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); } if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < totalWeightTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < totalLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', ]); } } 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) >= totalWeightTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= totalLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); } } return { valid: violations.length === 0, violations, warnings, bookings: fittingBookings, wagonType, wagonPlan, fleetAvailability, deferredBookings, summary: { totalBookings: fittingBookings.length, totalWeightTons, wagonType: resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, wagonsNeeded: wagonPlan.length, totalLengthMeters, freightMode: resolvedMode, }, }; } private async loadGlobalRulesRow(): Promise { 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 8–17, 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 (8–17). Run pending migrations. ` + `Cause: ${(err as Error).message}`, ); return null; } } private async resolveTrainLimitConfig( dto?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, locomotive?: Pick< Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' >, ): Promise> { 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) { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { maxTrainWeightTons: ruleWeightCap, maxTrainLengthMeters: ruleLengthCap, }, ); return { maxWeightTons: derived.maxWeightTons, maxLengthMeters: derived.maxLengthMeters, maxWagonsPerTrain: dto?.maxWagonsPerTrain != null ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots)) : 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 { 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, }, ]; } private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { const [wagons, wagonTypes] = await Promise.all([ this.dataSource.getRepository(Wagon).find(), this.dataSource.getRepository(WagonType).find(), ]); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); for (const wagon of wagons) { const pinnedOnTarget = targetScheduleId ? wagon.currentTrainScheduleId === targetScheduleId : false; if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) 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, })); } private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); for (const slot of slots) { if (!slot.physicalWagonId) continue; await manager.getRepository(Wagon).update(slot.physicalWagonId, { status: WagonStatus.Available, trainSetWagonId: null, currentTrainScheduleId: null, }); } } private async autoPinWagonsForSchedule( manager: EntityManager, scheduleId: string, originYardId: string, slots: TrainSetWagon[], ) { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); 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, })); const unpinnable = this.findUnpinnableWagonSlots( planSlots, wagons, scheduleId, originYardId, ); if (unpinnable.length) { throw new BadRequestException({ message: 'Insufficient physical wagons to pin all train slots', violations: unpinnable, }); } const assignedPhysicalIds = new Set(); for (const slot of planSlots) { const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, assignedPhysicalIds, ); if (!physical) continue; await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, { physicalWagonId: physical.id, status: 'RESERVED', }); await manager.getRepository(Wagon).update(physical.id, { trainSetWagonId: slot.trainSetWagonId, currentTrainScheduleId: scheduleId, status: WagonStatus.Assigned, }); assignedPhysicalIds.add(physical.id); } } /** Pre-assign check: every planned slot must have a matching physical wagon. */ private async validatePhysicalFleetForPlan( wagonPlan: WagonPlanSlot[], originYardId: string, targetScheduleId?: string, ): Promise { if (!wagonPlan.length) return []; const wagons = await this.dataSource.getRepository(Wagon).find(); return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, boardYardId: slot.boardYardId ?? null, })), wagons, targetScheduleId, originYardId, ); } private findUnpinnableWagonSlots( slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string; boardYardId?: string | null; }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, ): string[] { const violations: string[] = []; const assignedPhysicalIds = new Set(); for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, assignedPhysicalIds, ); if (!physical) { violations.push( `No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`, ); continue; } assignedPhysicalIds.add(physical.id); } 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, assignedPhysicalIds: Set, ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; if (assignedPhysicalIds.has(wagon.id)) return false; const pinnedOnSchedule = scheduleId ? wagon.currentTrainScheduleId === scheduleId : false; return wagon.status === WagonStatus.Available || pinnedOnSchedule; }; // 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 { 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. */ private async resolveWagonType( freightType: 'CONTAINER' | 'BULK', bookingIds: string[], ): Promise { const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); if (freightType === 'CONTAINER') { // First container type present on the batch drives the container wagon // type (matches the prior single-wagon-type-per-consist behavior). const containerType = bookings .flatMap((b) => b.bookingContainers ?? []) .map((line) => line.containerType) .find((ct): ct is NonNullable => Boolean(ct)); if (!containerType) { throw new BadRequestException('No container type found on the container booking(s)'); } const wagonType = await this.loadWagonTypeForType( containerType.wagonTypeId ?? null, `Container type "${containerType.label ?? containerType.code}"`, ); return wagonType; } const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); if (!cargoType) { throw new BadRequestException('No cargo type found on the bulk booking(s)'); } return this.loadWagonTypeForType( cargoType.wagonTypeId ?? null, `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, ); } /** * Load an active wagon type by FK id, throwing a clear error when the id is * unset (type not configured) or points at a missing/inactive wagon type. */ private async loadWagonTypeForType( wagonTypeId: string | null, typeLabel: string, ): Promise { if (!wagonTypeId) { throw new BadRequestException( `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, ); } const [wagonType] = await this.wagonTypesRepository.findAll({ where: { id: wagonTypeId, isActive: true }, }); if (!wagonType) { throw new NotFoundException( `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, ); } return wagonType; } /** * 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, ): 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) continue; slot.boardYardId = first.originYardId === scheduleOriginYardId ? null : first.originYardId; slot.alightYardId = first.destinationYardId === scheduleDestinationYardId ? null : first.destinationYardId; } } private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, wagonType: WagonType, wagonPlan: WagonPlanSlot[], ) { const wagons = wagonPlan.map((slot) => manager.getRepository(TrainSetWagon).create({ trainSetId, wagonTypeId: slot.wagonTypeId ?? wagonType.id, 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(); 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> { 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 { 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[]) { const [primary] = locomotives; const trainSet = manager.getRepository(TrainSet).create({ // `locomotiveId` retained as the primary locomotive for single-loco read paths. locomotiveId: primary.id, 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) { 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, weightTons: roundTons(booking.cargoTotalWeightVgm), 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, }; } 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--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 { 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) { return { id: schedule.id, reference: schedule.reference ?? null, createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 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, 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, })); } /** * 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. */ async getBookingWindowsForCompany(companyId: string | null) { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ON (ts.id) ts.id AS schedule_id, ts.reference AS reference, 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 FROM freight.train_schedules ts LEFT JOIN freight.contract_routes cr ON cr.origin_yard_id = ts.origin_station_id AND cr.destination_yard_id = ts.destination_station_id AND cr.deleted_at IS NULL 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 = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, ts.reference AS reference, 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 FROM freight.train_schedules ts JOIN freight.contract_routes cr ON cr.origin_yard_id = ts.origin_station_id AND cr.destination_yard_id = ts.destination_station_id AND cr.contract_id = $1 AND cr.deleted_at IS NULL 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 & { train_number: string | null; } > = 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 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, }), trainNumber: r.train_number, })); } private mapBookingWindowRow(r: BookingWindowRow) { return { scheduleId: r.schedule_id, reference: r.reference ?? 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, bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, origin: r.origin_label ?? r.origin_code ?? null, destination: r.destination_label ?? r.destination_code ?? null, }; } /** 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 } }, route: { milestones: true }, originStation: true, destinationStation: true, scheduleBookings: { booking: 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; }); } async getBookableSchedules(originYardId?: string, destinationYardId?: string) { const schedules = await this.getBookableScheduleEntities( originYardId, destinationYardId, ); return schedules.map((s) => this.mapScheduleListItem(s)); } /** * 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(); 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). Wagon availability is deliberately NOT * checked here: whether a matching wagon currently sits in the right yard is an * operational question staff resolve when they approve or reject the booking, * not something the customer can act on while choosing a date. Same * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; destinationYardId?: string; freightType: 'CONTAINER' | 'BULK'; cargoTypeCode?: string | null; totalWeightTons?: number; containers?: Array<{ containerSize: string; quantity: number }>; }): Promise<{ days: string[] }> { const schedules = await this.getBookableScheduleEntities( input.originYardId, input.destinationYardId, ); if (schedules.length === 0) return { days: [] }; const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } /** * 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 { 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 { 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 { 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', ); } } } 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); let loadingConfirmed = !requiresLoadingConfirmation; if (requiresLoadingConfirmation) { const op = await this.dataSource .getRepository(ImportDjiboutiOperation) .findOne({ where: { trainScheduleId: schedule.id } }); loadingConfirmed = Boolean(op?.loadedOnTrainAt); } const windowCfg = await this.getWindowConfig(); const [containerItems, bulkLoads] = await Promise.all([ 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 }, }) : [], ]); const containerItemsByAllocation = new Map(); 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]), ); return { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, 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 reopenDelayMinutes), 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, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, paymentWindowMinutes: 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, 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)), 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: [...(schedule.trainSet.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((wagon) => ({ id: wagon.id, sequenceNo: wagon.sequenceNo, capacityTons: roundTons(Number(wagon.capacityTons)), lengthMeters: roundTons(Number(wagon.lengthMeters)), assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), status: wagon.status, physicalWagonId: wagon.physicalWagonId ?? null, physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, wagonType: wagon.wagonType ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } : null, allocations: 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, })) ?? [], })), } : 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: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? 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), })) ?? [], }; } 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, schedule.trainSet.locomotive); 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, ); } /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, ): Promise { const schedule = 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 { 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 { 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); const previewDto = { bookingIds: eligible.map((b) => b.id), scheduleDate: schedule.scheduledDepartureDate.toISOString(), originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, }; const limits = await this.resolveTrainLimitConfig( undefined, schedule.trainSet.locomotive, ); let validation: Awaited>; 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(); for (const m of missingNumbers) { if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue); } const placeholderWarnings = new Map(); 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 { 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); await manager.getRepository(TrainSet).update(schedule.trainSetId, { wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1), totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 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 }; } async getUnassignedBookings(scheduleId: string): Promise { 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, })); 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), status: b.status ?? null, schedulingStatus: b.schedulingStatus ?? null, ...assignability, }; }), ); return { fleetAtOrigin, bookings }; } private async previewUnassignedBookingAssignability( schedule: TrainSchedule, wagonAssignedIds: Set, booking: Booking, fleetByTypeId: Map, ): Promise<{ wagonsRequired: number; requiredWagonTypeCode: string; yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; }> { if (!schedule.trainSet?.locomotive) { return { wagonsRequired: 0, requiredWagonTypeCode: '', yardWagonsAvailable: 0, canAssign: false, blockReason: 'Schedule has no locomotive', }; } const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER'; let wagonType: WagonType; try { wagonType = await this.resolveWagonType(freightType, [booking.id]); } catch { return { wagonsRequired: 0, requiredWagonTypeCode: '', yardWagonsAvailable: 0, canAssign: false, blockReason: 'No suitable wagon type found', }; } const bulkCapacity = freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined; const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); const resolvedBooking = fullBooking ?? booking; const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity); const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 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, schedule.trainSet.locomotive, ); let validation: Awaited>; try { validation = await this.validateBookingsForScheduling( previewDto, null, false, [], false, limits, schedule.id, ); } catch (err) { return { wagonsRequired, requiredWagonTypeCode: wagonType.code, yardWagonsAvailable, canAssign: false, blockReason: err instanceof Error ? err.message : 'Validation failed', }; } if (!validation.valid) { return { wagonsRequired, requiredWagonTypeCode: wagonType.code, yardWagonsAvailable, canAssign: false, blockReason: validation.violations[0] ?? 'Booking validation failed', }; } 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 ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` : null; return { wagonsRequired, requiredWagonTypeCode: wagonType.code, yardWagonsAvailable, canAssign: false, blockReason: deferred?.reason ?? yardShortfall ?? `Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`, }; } 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: wagonType.code, yardWagonsAvailable, canAssign: false, blockReason: missing.issue, }; } } return { wagonsRequired, requiredWagonTypeCode: wagonType.code, yardWagonsAvailable, canAssign: true, blockReason: null, }; } /** 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 { return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); } private async getWagonAssignedBookingIds(scheduleId: string): Promise> { const schedule = 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)); } }