Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts

7823 lines
321 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import {
AllocationLoadType,
Freight,
LoadingStatus,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
WagonMovementKind,
WagonStatus,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
DataSource,
EntityManager,
FindOptionsWhere,
ILike,
In,
IsNull,
Not,
QueryFailedError,
Raw,
} from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import {
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 { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
import {
computeFleetAvailability,
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from './wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
getContainerSlotSequenceNos,
roundTons,
sumWagonsRequired,
type TrainLimitConfig,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
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_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
} from './booking-batch.constants';
import {
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
import { TrainCheckpointEventsRepository } from './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,
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
* config, with the live config filling any snapshot field a legacy row lacks.
*
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
* the runtime cycle engine matches exactly what the board drew and the customer
* saw — a later global-rule edit must not retro-change an existing train. The
* doc-review / payment split is an internal process timing (not part of the
* window the customer sees) and is not stored split in the snapshot, so it always
* takes the live values; their sum is only used as a fallback reopen gap when the
* row predates `ruleReopenDelayMinutes`.
*/
export function effectiveWindowConfig(
schedule: {
ruleWindowOpenHour?: number | null;
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
ruleImportCloseOffsetMinutes?: number | null;
ruleExportCloseOffsetMinutes?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
return {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
// The close offset is frozen per-schedule: a snapshot value of null means
// "created with no offset" and must NOT inherit a later live offset (that
// would retro-shrink an open train's window). Only a truly legacy row that
// predates the snapshot column (value undefined) falls back to live config.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
}
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BookingWagonAllocationIssue {
bookingId: string;
status: BookingWagonAllocationStatus;
issue: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: DeferredBookingRow[];
issues: BookingWagonAllocationIssue[];
violations: string[];
}
export interface CompositionUnassignedBookingRow {
id: string;
reference: string | null;
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
shortage: BookingWagonShortage | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
doc_review_ends_at: Date | null;
payment_phase_ends_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
route_stations: string[] | null;
}
@Injectable()
export class TrainSchedulingService {
private readonly logger = new Logger(TrainSchedulingService.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly locomotivesRepository: LocomotivesRepository,
// Kept in the DI signature for constructor-arity stability (specs mock it);
// wagon-type resolution now flows through the config lists on the bookings.
_wagonTypesRepository: WagonTypesRepository,
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
private readonly bookingJourneyService: BookingJourneyService,
private readonly bookingNotifier: BookingNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
// forwardRef: BookingBatchService injects this service back; @Optional so
// existing specs that construct the service without it keep working.
@Optional()
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService?: BookingBatchService,
) {}
/**
* Notify each booking's customer that their shipment was dispatched / arrived,
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
*/
private async notifyScheduleBookings(
schedule: TrainSchedule,
event: 'dispatched' | 'arrived',
): Promise<void> {
try {
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
if (!ids.length) return;
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
const destination =
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
const bookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(ids) },
relations: { company: true },
});
for (const b of bookings) {
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
else this.bookingNotifier.arrived(b, origin, destination);
}
} catch (err) {
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
}
}
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
* bookings without milestone rows (non-customs bookings), so this is safe to
* call for every direction and flow. Never blocks the operational action.
*/
private async completeMilestonesForScheduleBookings(
scheduleId: string,
codes: string[],
filter?: { originYardId?: string; destinationYardId?: string },
): Promise<void> {
if (!this.milestoneService || codes.length === 0) return;
try {
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
const params: unknown[] = [scheduleId];
if (filter?.originYardId) {
params.push(filter.originYardId);
conditions.push(`b.origin_yard_id = $${params.length}`);
}
if (filter?.destinationYardId) {
params.push(filter.destinationYardId);
conditions.push(`b.destination_yard_id = $${params.length}`);
}
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
`SELECT tsb.booking_id
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id
WHERE ${conditions.join(' AND ')}`,
params,
);
for (const { booking_id } of rows) {
for (const code of codes) {
await this.milestoneService.completeByDocTrigger(
{ bookingId: booking_id },
code,
);
}
}
} catch (err) {
this.logger.warn(
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* Push a schedule's current booking-window state over the socket so the
* portal home card and backoffice GL/batch views update in real time —
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
* group together, since the route entity keys only origin + destination). All
* schedules in a group must run ONE shared booking-window timeline so a
* customer booking on a later-created train is never expired by a sibling
* train's payment window closing on a different clock.
*
* Grouping keys off the columns the schedule already carries — no new schema.
* Callers pass a live `manager` so both the create (inside its transaction) and
* the update paths see uncommitted siblings.
*/
private async findGroupSiblings(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule[]> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
// A cancelled train is not a sibling: cancel retires its window as DONE,
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getMany();
}
/**
* A built train makes at most ONE departure per route per EAT day. Returns
* the non-cancelled schedule already holding this train on this route for
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
* legal — siblings must be different trains.
*/
private async findTrainRouteDayConflict(
trainId: string,
routeId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule | null> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = this.dataSource
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.innerJoin('s.trainSet', 'ts')
.where('ts.trainId = :trainId', { trainId })
.andWhere('s.routeId = :routeId', { routeId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getOne();
}
/**
* The window timeline a brand-new schedule must adopt to join its route+day
* group. Returns the canonical open/close times + rule snapshot copied from an
* existing sibling, or null when this is the first schedule in the group (the
* caller then computes its own times as before — nothing changes for the
* single-schedule case).
*
* The anchor is the sibling that best represents where the GROUP currently is
* on its shared clock, so a train created mid-cycle joins the group AT its
* current phase (with the group's exact doc-review / payment deadlines) instead
* of restarting the whole cycle on its own `now`. When the group has advanced
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
* the phase the joiner must adopt to see the same "N minutes left" the group
* already shows. When every sibling is still PRE_WINDOW we pick the
* earliest-opening one (the group's frozen open clock).
*/
private async findGroupWindowAnchor(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
): Promise<TrainSchedule | null> {
const siblings = await this.findGroupSiblings(
manager,
originStationId,
destinationStationId,
departure,
);
if (siblings.length === 0) return null;
// A DONE window is retired (the day's last cycle already ran) — anchoring
// to it would hand the newborn a dead window no tick ever advances. With no
// live or pending sibling left, fall back to fresh times (return null).
const withWindow = siblings.filter(
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
);
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
// not yet DONE) — the joiner must land in that same phase on the same clock.
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
const live = withWindow.filter(
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
s.windowPhase !== 'PRE_WINDOW',
);
if (live.length > 0) {
// Most-advanced phase leads; ties broken by earliest open for determinism.
return live.reduce((best, s) => {
const a = PHASE_ORDER.indexOf(s.windowPhase!);
const b = PHASE_ORDER.indexOf(best.windowPhase!);
if (a !== b) return a > b ? s : best;
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
});
}
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
// defines the group clock (the one a customer would have seen first).
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
const pool = pending.length > 0 ? pending : withWindow;
return pool.reduce((earliest, s) =>
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
);
}
/**
* The full window state an anchor sibling hands down to a schedule JOINING its
* group. Copies not just the open/close times + frozen rule snapshot but the
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
* the group: it enters directly at the group's current phase with the group's
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
* `now` and stamping its own later `paymentPhaseEndsAt`.
*
* `targetDeparture` is the JOINING schedule's own departure: every time is
* clamped to it so a group whose trains depart at different times of the same
* day never hands an earlier-departing train a deadline that outlives its
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
* preserves that invariant when the anchor departed later).
*/
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
const cap = targetDeparture.getTime();
const clamp = (d: Date | null | undefined): Date | null =>
d == null ? null : d.getTime() > cap ? targetDeparture : d;
return {
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
windowPhase: anchor.windowPhase,
bookingWindowStatus:
anchor.bookingWindowStatus === 'FULL'
? 'OPEN'
: anchor.bookingWindowStatus,
bookingCycleNo: anchor.bookingCycleNo,
windowOpensAt: clamp(anchor.windowOpensAt),
windowClosesAt: clamp(anchor.windowClosesAt),
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
// Frozen rule snapshot.
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
};
}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
// resolving the schedule's route + day and filtering on the day instead.
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
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,
});
const tareDims = await this.loadWagonTareDims();
return {
count: bookings.length,
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
};
}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
}
async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'BULK' });
}
async getTrainSchedulingGlobalRules() {
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
const {
maxTrainLengthMeters: _len,
maxTrainWeightTons: _wt,
max20ftContainerWeightTons: _cw,
max20ftPairWeightDiffTons: _pd,
...pub
} = row;
return pub;
}
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
const row = await this.loadGlobalRulesRow();
if (!row) {
throw new NotFoundException('Train scheduling global rules not configured');
}
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
// Store 0 as null so "no offset" is a single canonical value.
if (dto.importCloseOffsetMinutes !== undefined)
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
if (dto.exportCloseOffsetMinutes !== undefined)
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
// overnight range that wraps past midnight (openHour > closeHour, e.g.
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowCloseHour != null ||
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null ||
dto.importCloseOffsetMinutes !== undefined ||
dto.exportCloseOffsetMinutes !== undefined;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.save(row);
// The cron reads config fresh every tick, so derived timings (doc review,
// payment, reopen) take effect on the next tick with no restart. But each
// schedule's initial open/close times were FROZEN at creation — re-stamp the
// ones whose window has not opened yet so a config edit applies to them too.
if (windowTimingChanged) {
await this.restampPendingWindows();
}
return this.toPublicGlobalRules(saved);
}
/**
* Override the booking-window rule for ONE schedule (staff action on the ops
* board). Only the fields provided are changed; the rest keep the schedule's
* existing snapshot (falling back to the live global config for legacy rows).
* The window must not have opened yet — an OPEN/past schedule stays frozen so
* customers keep the times they were shown. windowOpensAt/ClosesAt are
* re-derived from the merged rule, and the snapshot is updated so the board
* draws the new cycles.
*/
async updateScheduleWindowRule(
id: string,
dto: UpdateScheduleWindowRuleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'Booking window settings can only be changed before the window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
throw new BadRequestException(
'This schedule has already departed or has no departure date.',
);
}
// Merge the override onto the schedule's current effective rule (its snapshot,
// or the live config where a legacy row has no snapshot).
const liveCfg = await this.getWindowConfig();
const merged: BookingWindowConfig = {
importWindowLeadDays:
dto.importWindowLeadDays ??
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
dto.windowDurationHours ??
(schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours),
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
// A per-schedule override isn't a close-offset control, so inherit the
// offset already frozen on the schedule (null = none), or the live one for
// legacy rows — the override must not silently drop the global offset.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
// WHOLE group — every schedule sharing this origin + destination + EAT
// departure day. They all adopt the SAME rule snapshot and share the SAME
// window open/close timeline (the whole point of grouping). The shared times
// are clamped to each train's OWN departure so a group whose trains depart at
// different times of the same day never hands an earlier-departing sibling a
// window that outlives its departure. Only still-PRE_WINDOW siblings are
// touched — a sibling that has already opened, finalized, or dispatched stays
// frozen on the times its customers were shown and simply drops out of the
// group; the remaining pending trains stay in sync. EXPORT is excluded
// (departure-anchored FCFS window, no cross-expiry), so an export override
// only touches its own schedule.
const ruleFields = windowRuleSnapshot(merged);
const repo = this.dataSource.getRepository(TrainSchedule);
const cap = (d: Date, departure: Date): Date =>
d.getTime() > departure.getTime() ? departure : d;
const targets: Array<{ id: string; departure: Date }> = [
{ id, departure: schedule.scheduledDepartureDate },
];
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
}
}
}
for (const t of targets) {
await repo.update(t.id, {
windowOpensAt: cap(times.windowOpensAt, t.departure),
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
});
}
this.logger.log(
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Reschedule ONE train's departure date (staff action on the ops board). Only
* allowed while the booking window has not opened yet — an OPEN/past schedule
* stays frozen so customers keep the times they were shown. The new date must
* still leave room for the booking lead window before departure (same floor as
* schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
* open/close times are re-derived from the schedule's existing rule snapshot.
*/
async updateScheduleDate(
id: string,
dto: UpdateScheduleDateDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'The departure date can only be changed before the booking window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
const departure = new Date(dto.scheduleDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days;
// EXPORT lead is in hours. Mirrors the create-schedule check.
const windowCfg = await this.getWindowConfig();
const earliest = earliestSchedulableDeparture(
schedule.direction,
windowCfg,
now,
);
if (departure.getTime() < earliest.getTime()) {
const detail =
schedule.direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()}).`,
);
}
// Moving onto a day where this same built train already runs this route
// would double-book the physical train — blocked for planning moves.
if (schedule.trainSetId && schedule.routeId) {
const trainSet = await this.dataSource
.getRepository(TrainSet)
.findOne({ where: { id: schedule.trainSetId } });
if (trainSet?.trainId) {
const conflict = await this.findTrainRouteDayConflict(
trainSet.trainId,
schedule.routeId,
departure,
id,
);
if (conflict) {
throw new ConflictException(
`This train is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
}
}
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, now);
// Moving the departure moves this train between route+day GROUPS. If the
// destination day already has a group (a sibling on the same origin +
// destination + new EAT day), adopt that group's shared timeline instead of
// the times just derived, so the rescheduled train lines up with the group
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
// own re-derived times and becomes the anchor for that day. EXPORT is
// excluded — its window is anchored to its own departure, not shared.
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
const windowFields = anchor
? this.groupWindowFieldsFrom(anchor, departure)
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...windowFields,
});
// Only customers whose bookings already HOLD wagons on this train are told
// about the move (SMS + email + portal inbox). Linked-but-unallocated
// bookings are skipped — nothing of theirs is riding this departure yet.
let notifiedCount = 0;
if (schedule.trainSetId) {
const allocations = await this.dataSource
.getRepository(WagonBookingAllocation)
.createQueryBuilder('a')
.innerJoin('a.trainSetWagon', 'slot')
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
.getMany();
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
if (allocatedBookingIds.length) {
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(allocatedBookingIds) },
relations: { company: true },
});
for (const booking of allocatedBookings) {
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
this.bookingNotifier.rescheduled(booking, departure);
notifiedCount += 1;
}
}
}
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
`${notifiedCount} allocated customer booking(s) notified`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW only: the stamped open/close were derived from the old
// departure and the window hasn't opened yet, so re-derive them from the
// schedule's own rule snapshot against the new date (joining the target
// day's route group timeline when one exists, exactly like
// updateScheduleDate). Mid/post-window schedules keep their timeline.
const windowFields =
schedule.windowPhase === 'PRE_WINDOW'
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, new Date());
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
return anchor
? this.groupWindowFieldsFrom(anchor, departure)
: {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
};
})()
: {};
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
...windowFields,
});
// Everything aboard or targeted rides along: bookings linked on the train
// (schedule_bookings) plus reservations still pointing at it via
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
const targeted = await this.dataSource.getRepository(Booking).find({
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
relations: { company: true },
});
const aboard = targeted.filter(
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
);
if (aboard.length) {
await this.dataSource
.getRepository(Booking)
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
for (const booking of aboard) {
this.bookingNotifier.maintenanceMoved(booking, departure);
}
}
this.logger.log(
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
`${aboard.length} booking(s) moved with the train.`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped.
*/
async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig();
const now = new Date();
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
],
});
const repo = this.dataSource.getRepository(TrainSchedule);
let restamped = 0;
for (const s of schedules) {
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
const times =
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
// snapshot alongside the re-stamped times — the board then draws the new
// window from this same rule.
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(cfg),
});
restamped += 1;
// New times take effect immediately on every card (the tick then opens
// the window within seconds if the re-derived open is already due).
void this.emitWindowState(s.id);
}
if (restamped > 0) {
this.logger.log(
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
);
}
return restamped;
}
/**
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
* Numeric columns come back from pg as strings — normalize every field.
*/
async getWindowConfig(): Promise<BookingWindowConfig> {
const row = await this.loadGlobalRulesRow();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
// Offsets are optional: a missing/unset value means "no offset", not a
// numeric default — keep it null so bookingCloseCutoff falls back to
// departure. Zero and negatives are treated as "no offset" too.
const offset = (v: unknown): number | null => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
};
return {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
windowOpenHour: num(row?.windowOpenHour, 8),
windowCloseHour: num(row?.windowCloseHour, 17),
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
};
}
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<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>) {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
return {
valid: validation.valid,
violations: validation.violations,
warnings: validation.warnings,
summary: validation.summary,
fleetAvailability: validation.fleetAvailability,
deferredBookings: validation.deferredBookings,
bookingIds: validation.bookings.map((b) => b.id),
wagonPlan: validation.wagonPlan,
containerUnits: containerBookings.length
? expandBookingContainerUnits(containerBookings)
: [],
containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan),
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getSchedulableRoute(dto.routeId);
const scheduleWarnings: string[] = [];
// The pulling set comes either from a built train (Train Builder) or from
// hand-picked locomotive ids (legacy path). A built train also links the
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
// and yard follow the schedule.
let builtTrain: Train | null = null;
let locomotiveIds: string[];
if (dto.trainId) {
builtTrain = await this.dataSource.getRepository(Train).findOne({
where: { id: dto.trainId },
relations: { locomotives: true },
order: { locomotives: { sequenceNo: 'ASC' } },
});
if (!builtTrain) {
throw new NotFoundException(`Train ${dto.trainId} not found`);
}
if (
builtTrain.status === Freight.TrainStatus.OutOfService ||
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
builtTrain.status === Freight.TrainStatus.Deactivated
) {
throw new ConflictException(
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
);
}
locomotiveIds = (builtTrain.locomotives ?? [])
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 2) {
throw new BadRequestException(
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
scheduleWarnings.push(
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
new Date(dto.scheduleDate),
);
if (conflict) {
throw new ConflictException(
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
// multiple future schedules and does not need to be at the origin yard yet — staff
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
// everything else surfaces as a warning.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status === 'OUT_OF_SERVICE') {
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
}
if (locked.status !== 'AVAILABLE') {
scheduleWarnings.push(
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
);
}
if (locked.currentYardId !== route.originYardId) {
scheduleWarnings.push(
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
);
}
lockedLocomotives.push(locked);
}
// Frozen on the route at create/update from the yard-country enum;
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
// Direction-matched fixed number from the built train's typed pair.
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
// (assignTrainNumber is idempotent, so both paths compose).
const pairTrainNumber = builtTrain
? (direction === 'IMPORT'
? builtTrain.importTrainNumber
: builtTrain.exportTrainNumber) ?? null
: null;
if (builtTrain && !pairTrainNumber) {
scheduleWarnings.push(
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
);
}
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotives,
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = 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);
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
// on this origin + destination + EAT departure day, this new train JOINS
// its group and adopts the group's shared window timeline (open/close +
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
// That keeps every train on the day advancing through the same open/
// doc-review/payment/close instants, so a booking on one train is never
// expired by a sibling train's payment window closing on a different clock.
// First train in the group falls through to the normal computation.
//
// EXPORT is excluded: an export window is a single FCFS window anchored to
// each train's OWN departure (windowClosesAt = departure) with no
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
const groupAnchor =
direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
manager,
route.originYardId,
route.destinationYardId,
departure,
);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to
// the configured limit.
const builtTrainWagonCount = builtTrain
? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } })
: 0;
const maxWagons =
builtTrainWagonCount > 0
? builtTrainWagonCount
: (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain;
// Retry past a concurrent insert that grabbed the same S-<year> sequence
// (the unique index rejects the loser; it re-reads the max and tries again).
const saved = await this.insertScheduleWithReference(manager, (reference) =>
manager.getRepository(TrainSchedule).create({
reference,
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
...windowFields,
}),
);
// Locomotives stay in their current status until dispatch — advance scheduling
// must not block the locomotive from serving earlier trains.
if (builtTrain) {
await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id);
}
return saved.id;
});
const created = await this.getTrainScheduleById(createdScheduleId);
// New window announced — portal home / GL cards pick it up immediately.
void this.emitWindowState(createdScheduleId);
return { ...created, warnings: scheduleWarnings };
}
async assignBookingsToSchedule(
scheduleId: string,
dto: AssignBookingsDto,
freightType?: 'CONTAINER' | 'BULK',
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
if (!schedule.trainSet) {
throw new BadRequestException('Schedule has no train set');
}
// Batch parity: a schedule may only allocate bookings from its route-day POOL.
// Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
// a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
// pinned to whichever train in the day's group first held it. Every train
// sharing this origin + destination + EAT departure day draws from ONE shared
// pool (one shared booking window), so a booking is allocatable here when it is
// either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
// pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
// day/capacity fit is enforced downstream by validateBookingsForScheduling.
// EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
if (dto.bookingIds.length) {
const groupScheduleIds = new Set<string>([scheduleId]);
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
scheduleId,
);
for (const sib of siblings) groupScheduleIds.add(sib.id);
}
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
const stray = targeted.filter(
(b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
);
if (stray.length) {
throw new BadRequestException(
`These bookings are pinned to a train on a different route or day: ${stray
.map((b) => b.reference ?? b.id)
.join(', ')}`,
);
}
}
// The rebuild below deletes EVERY schedule↔booking link row and recreates
// only what makes the new plan. Ride-along (intercity) bookings are linked
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
// workspace's picked ids, so planning from dto.bookingIds alone silently
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
// every list. Every (re)assignment therefore re-plans the WHOLE train:
// the requested ids plus everything currently linked.
const linkedRows =
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
const allBookingIds = [
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
// created — every (re)assignment rebuilds the plan under the same flag so the
// stored train order stays consistent no matter how bookings are added.
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = 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").
// Callers hand-pick placements only for the bookings they know about; the
// union above may have folded in linked ride-alongs those placements never
// covered. Auto-fill whatever units are missing (all of them when no
// placements were sent at all) so the placement validator doesn't reject
// container bookings the caller couldn't have placed.
let containerPlacements = dto.containerPlacements;
{
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
[],
false,
limits,
scheduleId,
);
const containerBookings = preview.bookings.filter(
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const providedKeys = new Set(
(containerPlacements ?? []).map(
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
),
);
const unplacedUnits = units.filter(
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = [...(containerPlacements ?? []), ...generated];
}
}
}
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
containerPlacements,
true,
limits,
scheduleId,
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
// Every REQUESTED booking must have made the plan. Silently dropping a
// deferred one let the workspace "Add from pool" report success while the
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
// A stock shortage is a physical impossibility, so forceAssign cannot
// override it either.
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const details = droppedRequested.map(
(id) =>
reasonById.get(id) ??
`${id}: does not fit the train's wagon stock or capacity`,
);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case. Each
// locomotive's overageToleranceTons/Meters extends the hard cap before that
// override is even needed (e.g. the fertilizer example's +90T deviation).
const weightCapWithOverage =
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
// A built train hauls EVERY coupled wagon's tare — empty ones included —
// so train-bound schedules count the full consist, not just planned slots.
const consistWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
const planTareTons = consistWagons
? roundTons(
consistWagons.reduce(
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
0,
),
)
: roundTons(
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + planTareTons);
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`,
);
}
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;
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
if (deletedAllocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
}
await manager.getRepository(TrainSetWagon).delete({ trainSetId });
await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });
await manager.getRepository(TrainSet).update(trainSetId, {
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: 'ASSIGNED',
});
const savedWagons = await this.persistTrainSetWagons(
manager,
trainSetId,
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
manager,
savedWagons,
wagonPlan,
bookings,
containerPlacements ?? [],
);
// The link above puts these bookings on the train: they are SCHEDULED, not
// ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next
// batch fill, which unlinks it and frees its wagons on the next window cycle.
const scheduledAt = new Date();
for (const booking of bookings) {
await this.bookingsRepository.updateSchedulingFields(
booking.id,
{
schedulingStatus: SchedulingStatus.Scheduled,
scheduledAt,
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
},
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Draft,
{},
manager,
);
}
await this.autoPinWagonsForSchedule(
manager,
scheduleId,
schedule.originStationId,
savedWagons,
schedule.reverseWagonOrder ?? false,
);
});
const detail = await this.getTrainScheduleById(scheduleId);
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
if (!link) {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
}
const booking = await this.bookingsRepository.findById(bookingId);
const bookingReference = booking?.reference ?? null;
await this.dataSource.transaction(async (manager) => {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.filter((a) => a.bookingId === bookingId)
.map((a) => a.id);
if (allocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
allocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
}
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
bookingId,
manager,
);
const booking = await this.bookingsRepository.findById(bookingId);
const schedulingStatus = this.resolvePostUnassignStatus(booking);
// Clear the schedule pointer too: unassign fully detaches the booking from
// this train. Leaving trainScheduleId set glued the booking to a schedule
// that may then be dispatched/cancelled/deleted, orphaning it — the
// assign-bookings parity guard would reject it from every OTHER schedule.
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus, wagonsRequired: null, trainScheduleId: null },
manager,
);
// Recompute the train-set composition from whatever survives this removal.
// The removed booking's allocations were already deleted above, so any slot
// left with zero allocations was ridden only by this booking — release it
// (frees its reserved wagon slot). Shared slots keep their surviving
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
// fully-empty train was reset).
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
relations: { allocations: true },
});
let recomputedWeightTons = 0;
let recomputedLengthMeters = 0;
let recomputedWagonCount = 0;
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
const slotWeight = slotAllocations.reduce(
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
0,
);
if (Number(slot.assignedWeightTons) !== slotWeight) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
}
recomputedWeightTons += slotWeight;
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
recomputedWagonCount += 1;
}
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: roundTons(recomputedWeightTons),
totalLengthMeters: roundTons(recomputedLengthMeters),
wagonCount: recomputedWagonCount,
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
// the current status (an object literal lets TypeORM's contextual typing
// accept the partial without pulling in relation fields).
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
});
});
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 tareDims = await this.loadWagonTareDims();
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(b, tareDims),
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
}
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations, bookings] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const invalid: string[] = [];
for (const id of dto.bookingIds) {
const booking = bookingById.get(id);
if (
!scheduledIds.has(id) ||
!allocatedIds.has(id) ||
!booking ||
booking.tradeDirection !== 'IMPORT' ||
booking.paymentStatus !== 'PAID'
) {
invalid.push(id);
}
}
if (invalid.length) {
throw new BadRequestException(
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getImportLoadingBookings(scheduleId);
}
/**
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
* direction (import/export/domestic). Distinct from unassign: the booking stays
* on its wagon; this only records whether cargo is physically loaded. Allowed
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
*/
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
// Only bookings that are on this train AND pinned to a wagon can be loaded —
// no direction/payment filter, staff load whatever is physically on the set.
const invalid = dto.bookingIds.filter(
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
);
if (invalid.length) {
throw new BadRequestException(
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
const slots = schedule.trainSet?.wagons ?? [];
const slotIds = new Set(slots.map((w) => w.id));
const slotById = new Map(slots.map((w) => [w.id, w]));
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
// Occupancy is judged against THIS schedule's own slots only — a wagon
// pinned on another schedule (e.g. the same train's July 17 run) stays
// pinnable here.
const slotIdByPhysicalId = new Map(
slots
.filter((w) => w.physicalWagonId)
.map((w) => [w.physicalWagonId as string, w.id]),
);
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
if (!slotIds.has(assignment.trainSetWagonId)) {
throw new BadRequestException(
`Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
);
}
const physicalWagon = await manager.getRepository(Wagon).findOne({
where: { id: assignment.physicalWagonId },
});
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
const occupyingSlot = slotById.get(occupyingSlotId);
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
);
}
if (builtTrainId) {
// Train-bound schedule: only the built train's own consist may be
// pinned — wherever the wagons currently sit, they travel with the
// train, so no yard/status gate applies.
if (physicalWagon.trainId !== builtTrainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
);
}
} else {
if (physicalWagon.trainId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
);
}
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
);
}
if (
physicalWagon.currentTrainScheduleId &&
physicalWagon.currentTrainScheduleId !== scheduleId
) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
);
}
if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
);
}
}
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
// its status untouched so other schedules can still use the wagon.
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
for (const [physicalId, slotId] of slotIdByPhysicalId) {
if (slotId === assignment.trainSetWagonId) {
slotIdByPhysicalId.delete(physicalId);
break;
}
}
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
}
});
return this.getTrainScheduleById(scheduleId);
}
async finalizeSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
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);
}
/**
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
* loaded onto the wagons allocated to the booking, so anything still in the
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
* booking has warehouse inventory that never made it onto a wagon (received /
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
* or drop the booking's wagon allocation so it rides a later train.
*
* Import/domestic are untouched: their cargo isn't loaded out of an origin
* warehouse, so warehouse inventory says nothing about what's aboard.
*
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
* wagon before the goods arrive is normal planning; they simply aren't aboard.
*/
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
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`,
[scheduleId],
);
if (!route) return;
const direction = deriveTradeDirection(
{ country: route.originCountry },
{ country: route.destinationCountry },
);
if (direction !== 'EXPORT') return;
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
`WITH ${SCHEDULE_BOOKINGS_CTE}
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
FROM sched_bookings sb
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE sb.schedule_id = $1
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
[scheduleId],
);
if (rows.length) {
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
throw new BadRequestException(
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
);
}
}
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);
// Export only: don't leave received cargo behind in the warehouse.
await this.assertAllocatedCargoLoaded(scheduleId);
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
// Same rule for wagons: many schedules may pin the same wagon, but it can
// only be OUT on one dispatched train at a time.
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (pinnedPhysicalIds.length) {
const rolling = await this.dataSource.getRepository(Wagon).find({
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
});
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
if (busy.length) {
throw new ConflictException(
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
);
}
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
}
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
}
// A built train follows its schedule out: IN_SERVICE until arrival.
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
}
// The train is out — every pinned wagon is ASSIGNED to this schedule and
// stays pinned so no other schedule can pick it while it's rolling.
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (dispatchedPhysicalIds.length) {
await manager
.getRepository(Wagon)
.update(
{ id: In(dispatchedPhysicalIds) },
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: SchedulingStatus.Dispatched },
manager,
);
}
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
// that the operator didn't load individually are auto-loaded now — the
// train is leaving with them. Mid-corridor boarders stay PAID until the
// operator loads them at their own yard.
await manager.query(
`UPDATE freight.bookings b
SET status = 'IN_TRANSIT',
loaded_at = COALESCE(b.loaded_at, $3)
FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = b.id
AND tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
[scheduleId, schedule.originStationId, now],
);
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
await manager
.getRepository(Booking)
.createQueryBuilder()
.update()
.set({
status: 'EXPIRED',
schedulingStatus: SchedulingStatus.Eligible,
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.execute();
});
if (this.isImportDjiboutiSchedule(schedule)) {
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
});
console.log(
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
);
}
// Dispatch closed the window — drop it from portal/GL cards right away.
void this.emitWindowState(scheduleId);
// Customer tracking: cargo is on the departing train — loading milestones
// plus the direction's "departed" handoff milestone. Restricted to bookings
// that BOARD at the train's origin; mid-corridor boarders get their loading
// milestones from their own operator load at their own yard.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(
scheduleId,
[
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
// doc-trigger path no-ops it for import bookings.
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
schedule.direction === 'IMPORT'
? 'DEPARTED_FROM_DJIBOUTI'
: 'DEPARTED_TO_DJIBOUTI',
],
{ originYardId: schedule.originStationId },
);
}
void this.notifyScheduleBookings(schedule, 'dispatched');
const detail = await this.getTrainScheduleById(scheduleId);
// Surface a compact dispatch confirmation so the caller can toast the "train
// is out" info (train number, departure, wagons committed) without re-deriving it.
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
(slot) => slot.physicalWagonId,
).length;
return Object.assign(detail, {
dispatchInfo: {
// The real train number was assigned inside the txn — read it back off
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
departedAt: now.toISOString(),
wagonsDispatched: dispatchedWagonCount,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
},
});
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
return this.mapImportDjiboutiOperation(schedule, operation);
}
async uploadImportDjiboutiDocument(
scheduleId: string,
dto: UploadImportDjiboutiDocumentDto,
) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const documents = {
...(operation.documents ?? {}),
[dto.documentType]: {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
},
};
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
documents,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
const documents = { ...(operation.documents ?? {}) };
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
documents.GATE_PASS = {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
};
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
documents,
gatepassGrantedAt: securedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
console.log(
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
* milestone for every customs booking on this schedule, so contract/booking
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*
* A booking only earns its gate pass once the customer has settled the freight
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
* train and its paid bookings proceed. Re-securing the gate pass after payment
* settles picks the booking up; so does any later call to this bridge.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
securedAt: Date,
): Promise<void> {
const bookings = await this.dataSource.getRepository(Booking).find({
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
});
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const bookingIds = bookings.map((b) => b.id);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookingIds),
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
},
});
const paidBookingIds = new Set(
rows
.filter(
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
)
.map((r) => r.bookingId),
);
// A booking whose payment settled through a path that never wrote the
// milestone still counts as paid — the clearance views self-heal the row on
// read, and the gate pass must not lag behind that.
for (const booking of bookings) {
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
paidBookingIds.add(booking.id);
}
}
const skipped: string[] = [];
for (const row of rows) {
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
if (row.status === 'COMPLETED') continue;
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
skipped.push(row.bookingId ?? '(unknown)');
continue;
}
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
if (skipped.length > 0) {
this.logger.warn(
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Confirm cargo is loaded on the train from the workspace, for any direction.
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
* For every other schedule there is no departure loading gate, so this is a
* success no-op and simply returns the current detail.
*/
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
if (this.isImportDjiboutiSchedule(schedule)) {
await this.confirmImportLoadedOnTrain(scheduleId, dto);
}
// Confirming loading also marks every wagon-assigned booking LOADED, so the
// per-booking loading flag and the dispatch gate agree (otherwise the
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
// Export cargo must be received at the warehouse with a GRN before it can
// be confirmed loaded — an allocation is not proof the goods are in hand.
if (this.isExportSchedule(schedule)) {
await this.assertExportBookingsReceived([...wagonAssignedIds]);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
LoadingStatus.Loaded,
);
}
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
// is the export-side "cargo reached origin yard" step that precedes it).
void this.completeMilestonesForScheduleBookings(scheduleId, [
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
]);
return this.getTrainScheduleById(scheduleId);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return this.getImportDjiboutiOperation(schedule.id);
}
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const generatedAt = operation.loadListGeneratedAt ?? new Date();
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
loadListGeneratedAt: generatedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
return {
generatedAt: generatedAt.toISOString(),
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
route: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
totalBookings: schedule.scheduleBookings?.length ?? 0,
// Every wagon on the train set, loaded or not, in consist order. An empty
// wagon has an empty `allocations` array — it is still part of the train
// and still belongs on the marshalling document.
wagons: [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
const rows = wagons
.flatMap((wagon) => {
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
// reads as a wagon that is not there, and the count stops matching.
if (allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="6">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | 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 `<tr>
${wagonCells}
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
<td>${esc(sealNumbers)}</td>
</tr>`;
});
})
.join('');
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Export Marshalling Document</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Export Marshalling Document / Load List</h1>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
</div>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon No</th>
<th>Wagon Type</th>
<th class="num">Equated Length</th>
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Customer Name</th>
<th>Customer ID</th>
<th>Cargo Type</th>
<th>Container No</th>
<th>Chassis No</th>
<th>Seal No</th>
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
</tbody>
</table>
<div class="notice">
Loading and dispatch staff must verify wagon identity, seal number, container number,
cargo type, and customer booking against the physical consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared person / date</div>
<div class="line">Check person / date</div>
<div class="line">Operations authorization / date</div>
</div>
</body>
</html>`;
}
private isExportSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return direction === 'EXPORT';
}
/**
* Every export booking being confirmed loaded must already be received at the
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
* is the check that the cargo is physically in the yard before we call it loaded.
*/
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
if (!bookingIds.length) return;
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT b.reference
FROM freight.bookings b
WHERE b.id = ANY($1)
AND b.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
)`,
[bookingIds],
);
if (rows.length) {
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
throw new BadRequestException(
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
);
}
}
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = loadList.wagons.reduce(
(sum, wagon) =>
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
const allocationRows = loadList.wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return wagon.allocations.map(
(allocation) => `<tr>
${wagonCells}
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`,
);
})
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Import Load List / Marshalling Document</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.doc { min-height: 100vh; position: relative; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.tile strong { font-size: 13px; }
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
.pending { background: #f8fafc; color: #64748b; }
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
.num { text-align: right; }
tr.empty td { background: #f8fafc; color: #64748b; }
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
</style>
</head>
<body>
<div class="doc">
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Import Load List /<br />Marshalling Document</h1>
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
</div>
<div class="meta">
Train / Schedule
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
Generated: ${esc(date(loadList.generatedAt))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
<div class="status">
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
</div>
<h2>Wagon Marshalling Allocation</h2>
<table>
<thead>
<tr>
<th>Seq</th>
<th>Wagon</th>
<th>Booking</th>
<th>Load</th>
<th>Container numbers</th>
<th class="num">Weight T</th>
</tr>
</thead>
<tbody>
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
</tbody>
</table>
<div class="notice">
Gate and loading staff must verify this document against the granted gatepass,
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
</div>
<div class="signatures">
<div class="line">Prepared by Djibouti operations</div>
<div class="line">Train loading supervisor</div>
<div class="line">EDR operations authorization</div>
</div>
<div class="footer">
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
</div>
</div>
</body>
</html>`;
}
private safeDocumentName(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
}
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
if (!this.isImportDjiboutiSchedule(schedule)) return;
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
// Loading confirmation does NOT gate dispatch. Per-booking loading is
// tracking only and the loaded-on-train step is optional — a scheduled train
// dispatches without waiting on loading.
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
}
return schedule;
}
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isDjiboutiGatepassSchedule(schedule)) {
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
}
return schedule;
}
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
}
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'IMPORT' &&
this.isDjiboutiPortDestination(
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
)
);
}
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'EXPORT' &&
this.isDjiboutiPortDestination(
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
)
);
}
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
if (existing) return existing;
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
}
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
void operation;
return [];
}
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
if (!operation?.gatepassGrantedAt) {
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
}
}
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
return {
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
status: {
documentsComplete: missingDocuments.length === 0,
missingDocuments,
gatepassStatus,
gatepassGranted: Boolean(operation.gatepassGrantedAt),
readyForLoading: Boolean(operation.readyForLoadingAt),
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
loadListGenerated: Boolean(operation.loadListGeneratedAt),
},
documents: operation.documents ?? {},
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
gatepassStatus,
readyForLoadingAt: operation.readyForLoadingAt ?? null,
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
performedBy: operation.performedBy ?? null,
notes: operation.notes ?? null,
};
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) {
// Creation-assigned pair number: two live runs may never share a number,
// so block dispatch while another DISPATCHED schedule still carries it.
const clash = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
.andWhere('s.id != :id', { id: schedule.id })
.getOne();
if (clash) {
throw new ConflictException(
`Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
);
}
return schedule.trainNumber;
}
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
// creation and must be invisible to pool picks.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status IN (:...statuses)', {
statuses: [
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
TrainScheduleStatusEnum.Dispatched,
],
})
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
// Every typed train pair is reserved for its train — the pool may never
// hand one out, even when that train has no active schedule right now.
const pairRows: { n: string }[] = await manager.query(
`SELECT import_train_number AS n FROM freight.trains
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
UNION
SELECT export_train_number FROM freight.trains
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
);
const usedNumbers = [
...activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n)),
...pairRows.map((row) => row.n),
];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
if (status === 'OPEN') {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule?.bookingWindowStatus === 'FULL') {
throw new ConflictException('Train is full — the booking window cannot be reopened');
}
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
void this.emitWindowState(scheduleId);
}
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
private async buildScheduleStations(schedule: TrainSchedule) {
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
const stations: Station[] = [];
const route = schedule.routeId
? await this.dataSource.getRepository(Route).findOne({
where: { id: schedule.routeId },
relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
})
: null;
if (route) {
// `route.milestones` is the complete ordered corridor and already includes
// the origin (first) and destination (last) yards — `route.originYardId`
// and `route.destinationYardId` are derived from them. Use the milestones
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
if (milestones.length > 0) {
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
return stations;
}
// Route with no milestones recorded — fall back to its origin/destination.
const origin = route.originYard;
const destination = route.destinationYard;
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
});
return stations;
}
// Fallback: no route milestones — just origin → destination from the schedule stations.
stations.push({
sequenceNo: 0,
yardId: schedule.originStationId,
label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
code: schedule.originStation?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
code: schedule.destinationStation?.code ?? '',
});
return stations;
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
// Resolve each checkpoint's position by its yard against the canonical
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
// under an older station numbering still line up with the current stations.
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const resolvedSeq = (e: TrainCheckpointEvent) =>
seqByYard.get(e.yardId) ?? e.sequenceNo;
const currentSequenceNo = events.length
? Math.max(...events.map(resolvedSeq))
: -1;
return {
scheduleId,
status: schedule.status,
direction: schedule.direction ?? null,
trainNumber: schedule.trainNumber ?? null,
actualDepartureAt: schedule.actualDepartureAt
? schedule.actualDepartureAt.toISOString()
: null,
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
scheduledDepartureAt: schedule.scheduledDepartureDate
? schedule.scheduledDepartureDate.toISOString()
: null,
scheduledArrivalAt: schedule.scheduledArrivalDate
? schedule.scheduledArrivalDate.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: resolvedSeq(e),
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
note: e.note ?? null,
})),
};
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can be tracked');
}
const stations = await this.buildScheduleStations(schedule);
const finalSeq = stations[stations.length - 1].sequenceNo;
const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
}
const kind =
dto.kind ??
(dto.sequenceNo === 0
? TrainCheckpointKind.Departed
: dto.sequenceNo === finalSeq
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
});
} else {
await this.trainCheckpointEventsRepository.create({
trainScheduleId: scheduleId,
yardId: station.yardId,
sequenceNo: dto.sequenceNo,
kind,
occurredAt,
note: dto.note ?? null,
});
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
} else {
// Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to
// unload each one by hand. The final station is covered by
// arriveSchedule's bulk fallback above.
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
}
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: 'COMPLETED',
});
}
// A built train arrives with its schedule: settle it at the destination
// yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait).
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(
manager,
schedule.trainSet.trainId,
schedule.destinationStationId,
);
}
// Per-booking journey: bookings destined for the FINAL yard that the
// operator didn't unload individually get their arrival stamped now as a
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
// their own unload (possibly already done while the train kept rolling).
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
// Release every locomotive of the set (not just the legacy primary) and move it
// to the destination yard where it physically arrived.
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (arrivedLocoIds.length) {
await manager.getRepository(Locomotive).update(
{ id: In(arrivedLocoIds) },
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
);
}
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (!wagon) continue;
// A wagon that already alighted mid-route (unload released it, possibly
// re-pinned elsewhere since) is no longer this schedule's to move.
if (wagon.currentTrainScheduleId !== scheduleId) continue;
// Dynamic consist: the wagon settles at its slot's alight yard, not
// blanket at the train's destination.
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool.
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
currentYardId: settleYardId,
});
// Ledger: the wagon rode this schedule to its settle yard.
const slotAllocations = slot.allocations ?? [];
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: slot.boardYardId ?? schedule.originStationId,
toYardId: settleYardId,
trainScheduleId: scheduleId,
bookingId: slotAllocations[0]?.bookingId ?? null,
kind: slotAllocations.length
? WagonMovementKind.Loaded
: WagonMovementKind.EmptyReposition,
occurredAt: now,
}),
);
}
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
const stations = await this.buildScheduleStations(schedule);
const finalStation = stations[stations.length - 1];
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
});
if (!existingFinal) {
await manager.getRepository(TrainCheckpointEvent).save(
manager.getRepository(TrainCheckpointEvent).create({
trainScheduleId: scheduleId,
yardId: finalStation.yardId,
sequenceNo: finalStation.sequenceNo,
kind: TrainCheckpointKind.Arrived,
occurredAt: now,
}),
);
}
});
// Customer tracking: the train reached the corridor's far end. Restricted
// to bookings destined for the FINAL yard — mid-corridor bookings get their
// arrival milestone from their own operator unload at their own yard.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(
scheduleId,
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
{ destinationYardId: schedule.destinationStationId },
);
}
void this.notifyScheduleBookings(schedule, 'arrived');
const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation });
}
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
// Exact-match filters (enum/id semantics). Freight type is derived from
// the bookings aboard — no column to match — so it rides on `id` as an
// EXISTS fragment instead.
const base: FindOptionsWhere<TrainSchedule> = {};
if (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId;
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
// Search fans out across every human-recognizable label; each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, reference: like as never },
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
// Newest-created first (the client can re-sort; this is the default order).
const sortBy = query.sortBy ?? 'createdAt';
const sortOrder = query.sortOrder ?? 'DESC';
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
// Yards carry the route's display name used by mapScheduleListItem;
// milestones (with yards) let it show the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { [sortBy]: sortOrder } as never,
skip,
take,
});
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
async getContainerTrainScheduleById(id: string) {
return this.getTrainScheduleById(id);
}
async cancelTrainSchedule(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new BadRequestException(
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
{
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
// Cancelled run: the built train never left — re-derive its status
// (back to AVAILABLE unless other runs still reference it).
if (schedule.trainSet?.trainId) {
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
}
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
// but never stomp a locomotive that is currently pulling another dispatched train.
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
cancelledLocoIds,
id,
manager,
);
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
if (releasable.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
}
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
currentTrainScheduleId: null,
trainSetWagonId: null,
// Built-train wagons stay coupled to their train (ASSIGNED); loose
// wagons return to the open AVAILABLE pool.
status: wagon.physicalWagon?.trainId
? WagonStatus.Assigned
: WagonStatus.Available,
// A cancelled train never left — its wagons stay/return at the origin
// yard, free to be re-pinned onto another schedule from there.
currentYardId: schedule.originStationId,
});
}
}
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository.findById(sb.bookingId);
// Detach from the cancelled schedule — clear the pointer so the freed
// booking can be assigned to another train. Leaving it set orphans the
// booking against a schedule that is about to be gone.
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{
schedulingStatus: this.resolvePostUnassignStatus(booking),
trainScheduleId: null,
},
manager,
);
}
});
// Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id);
return this.getTrainScheduleById(id);
}
private async getTrainScheduleById(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return this.mapScheduleDetail(schedule);
}
private async validateBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
freightType: 'CONTAINER' | 'BULK' | null,
forceAssign = false,
containerPlacements: ContainerPlacementInput[] = [],
requireContainerPlacements = false,
trainLimits: Required<TrainLimitConfig>,
targetScheduleId?: string,
) {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException('At least one booking is required');
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const violations: string[] = [];
const warnings: string[] = [];
if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((b) => b.id));
violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`);
}
const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds);
const conflictingLinks = targetScheduleId
? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId)
: scheduledLinks;
if (conflictingLinks.length > 0) {
violations.push('One or more selected bookings are already assigned to a train schedule');
}
const bookingTypes = new Set(bookings.map((b) => b.freightType));
const isMixed = bookingTypes.size > 1;
const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' =
freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK'));
if (freightType === 'CONTAINER' || freightType === 'BULK') {
const wrongType = bookings.filter((b) => b.freightType !== freightType);
if (wrongType.length) {
violations.push(`Only ${freightType} bookings are supported`);
}
}
const invalidStatus = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
!b.isGovernment,
);
if (invalidStatus.length) {
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
violations.push(
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`,
);
}
// Corridor-aware: a booking belongs on this train when its origin and
// destination lie on the schedule's stop list in order — sub-corridor
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
// stop list is also what makes the wagon plan leg-aware below.
let stops = [dto.originStationId, dto.destinationStationId];
if (targetScheduleId) {
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
if (target) stops = await this.stopYardsForSchedule(target);
}
if (
bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
const fromIdx = stops.indexOf(b.originYardId);
const toIdx = stops.indexOf(b.destinationYardId);
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
})
) {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
// Day-match: a booking scheduled for a specific EAT day must board a train
// departing that same day. forceAssign downgrades a mismatch to a warning
// so staff can knowingly move a booking onto an adjacent-day train.
const scheduleDay = eatDay(new Date(dto.scheduleDate));
const dayMismatched = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
b.scheduledDate != null &&
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
);
if (dayMismatched.length) {
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
.map((b) => b.reference ?? b.id)
.join(', ')}`;
if (forceAssign) {
warnings.push(message);
} else {
violations.push(message);
}
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {
warnings.push(
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
);
}
// Overweight is the soft threshold (maxVgmTons): the customer already
// paid the overweight surcharge at booking. The hard ceiling
// (maxCapacityTons) blocks booking creation, so anything reaching
// scheduling is shippable — warn the planner, never block allocation.
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
if (overweightLines.length) {
warnings.push(
`Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`,
);
}
}
}
// Wagon-type resolution is list-based: each container/cargo type carries
// the wagon types that can haul it, and the plan mixes wagon types within
// one consist. A schedule created from a built train (Train Builder) plans
// against ONLY that train's own wagons — full when every consist wagon is
// allocated; legacy schedules plan against the boarding yards' pool.
const allowed = await this.loadAllowedWagonTypes(bookings);
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
stock = await this.builtTrainStock(builtTrainId);
} else {
// 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 remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
const legByBookingId = new Map(
bookings.flatMap((b) => {
const from = stops.indexOf(b.originYardId);
const to = stops.indexOf(b.destinationYardId);
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
}),
);
const planned = planWagonsWithStock({
bookings,
allowed,
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
// the stored train order, allocations and snapshot all carry the reversed
// order together. No-op unless the schedule set the flag.
const wagonPlan = applyWagonOrderReversal(
planned.plan,
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
);
// Availability rows come from the BOUNDED plan — the one that actually
// mixes wagon types against real stock. The old unbounded "pure demand"
// plan had infinite stock of every allowed type, so its tie-break parked a
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
// the deferred bookings' own shortage rows.
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
planned.plan,
stock.remainingByTypeId,
stock.codesByTypeId,
);
warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
this.stampSlotLegs(
wagonPlan,
fittingBookings,
dto.originStationId,
dto.destinationStationId,
);
violations.push(
...(await this.validatePhysicalFleetForPlan(
wagonPlan,
originYardId,
targetScheduleId,
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
// The plan can mix wagon types, so limit math always runs against the
// distinct types actually planned (shortest length drives the wagon-count
// fallback — validateMixedTrainLimits generalizes the single-type check).
const plannedWagonTypes = [
...new Map(
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
).values(),
];
pushLimit(
validateMixedTrainLimitsPerEdge(
wagonPlan,
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
trainLimits,
stops,
),
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
violations.push(
...validateContainerPlacements(
containerBookings,
wagonPlan,
containerPlacements,
placementRules,
),
);
violations.push(
...(await this.validateFleetContainers(containerPlacements, containerBookings)),
);
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
// figure spent against it must be gross too — cargo alone under-reports the
// train by the full consist tare and disagrees with the assign path.
const totalTareTons = roundTons(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (grossWeightTons > trainLimits.maxWeightTons) {
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) 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) <
grossWeightTons ||
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) >=
grossWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
)
) {
pushLimit(['No locomotive can support the total train weight and length']);
}
}
const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))];
return {
valid: violations.length === 0,
violations,
warnings,
bookings: fittingBookings,
wagonPlan,
fleetAvailability,
deferredBookings,
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons,
totalTareTons,
// Human-readable wagon type(s) of the plan — mixed consists list all.
wagonType: plannedTypeCodes.join('/') || 'NONE',
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
freightMode: resolvedMode,
},
};
}
private async loadGlobalRulesRow(): Promise<TrainSchedulingGlobalRules | null> {
try {
const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({
order: { createdAt: 'ASC' },
take: 1,
});
return rows[0] ?? null;
} catch (err) {
// A read failure here silently downgrades every booking window to the
// hardcoded defaults (desk 817, duration 3h, lead 3) while the settings
// UI keeps showing the saved row — a maddening mismatch. The usual cause
// is a missing column (migrations not run on this database). Scream.
this.logger.error(
`Failed to read train-scheduling global rules — booking windows are ` +
`running on HARDCODED DEFAULTS (817). Run pending migrations. ` +
`Cause: ${(err as Error).message}`,
);
return null;
}
}
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
// truth — global-rules / env caps do not floor them (a mis-set global
// row once capped every train at 14m). Only an explicit per-request dto
// override still applies.
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
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<WagonTypeDimensions[]> {
const types = await this.dataSource
.getRepository(WagonType)
.find({ where: { isActive: true } });
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
capacityTons: 70,
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
{
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
capacityTons: 60,
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
},
];
}
/**
* Built train (Train Builder) behind a schedule's train set, if the schedule
* was created by picking a train instead of loose locomotives.
*/
private async builtTrainIdOfSchedule(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<string | null> {
if (!scheduleId) return null;
const runner = manager ?? this.dataSource;
const rows: { train_id: string | null }[] = await runner.query(
`SELECT tset.train_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.id = $1`,
[scheduleId],
);
return rows[0]?.train_id ?? null;
}
private async countFleetAvailability(
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
for (const wagon of wagons) {
// Train-bound schedule: the built train's own consist IS the fleet — only
// its wagons count (wherever they currently sit; they travel with the
// train), and loose yard wagons never do.
if (builtTrainId) {
if (wagon.trainId !== builtTrainId) continue;
} else {
// Schedule-scoped availability: pins held by OTHER schedules never
// consume a wagon here — the same physical wagon may serve the July 17
// and the July 20 run. A wagon is unusable only when it is coupled to a
// built train's consist, physically blocked, or out on a dispatched
// train right now.
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
if (wagon.trainId) continue;
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== targetScheduleId
) {
continue;
}
if (wagon.currentYardId !== originYardId) continue;
}
const typeId = wagon.wagonTypeId;
const code = typeCodeById.get(typeId) ?? typeId;
const existing = counts.get(typeId) ?? { code, available: 0 };
existing.available += 1;
counts.set(typeId, existing);
}
return [...counts.entries()].map(([wagonTypeId, value]) => ({
wagonTypeId,
wagonTypeCode: value.code,
available: value.available,
}));
}
/**
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
trainSetWagonId: wagon.id,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
slotStatus: wagon.status ?? null,
boardYardId: wagon.boardYardId ?? null,
alightYardId: wagon.alightYardId ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
loadType: allocation.loadType ?? null,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
})),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
};
}
/**
* A wagon in a blocked physical state can never be planned or pinned.
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
* train or stamped by a live run — schedule-level occupancy is tracked on
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
*/
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
return (
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
);
}
/**
* Physical wagons already pinned to THIS schedule's slots. Availability is
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
* pins held by other schedules of the same train are irrelevant.
*/
private async pinnedPhysicalWagonIdsForSchedule(
scheduleId: string | undefined,
manager?: EntityManager,
): Promise<Set<string>> {
if (!scheduleId) return new Set();
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ts.id = $1
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
[scheduleId],
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
/**
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
* schedule. Used to guard consist trims — the Wagon entity itself carries no
* schedule-occupancy state anymore.
*/
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
const runner = manager ?? this.dataSource;
const rows: { physical_wagon_id: string }[] = await runner.query(
`SELECT DISTINCT tsw.physical_wagon_id
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND tsw.physical_wagon_id IS NOT NULL`,
);
return new Set(rows.map((row) => row.physical_wagon_id));
}
private async autoPinWagonsForSchedule(
manager: EntityManager,
scheduleId: string,
originYardId: string,
slots: TrainSetWagon[],
reverseWagonOrder = false,
) {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
scheduleId,
manager,
);
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
const planSlots = [...slots]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
trainSetWagonId: slot.id,
boardYardId: slot.boardYardId ?? null,
}));
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
wagons,
scheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
);
if (unpinnable.length) {
throw new BadRequestException({
message: 'Insufficient physical wagons to pin all train slots',
violations: unpinnable,
});
}
const assignedPhysicalIds = new Set<string>();
for (const slot of planSlots) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
);
if (!physical) continue;
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
// touched here, so the same physical wagon stays free for every other
// schedule (it gets stamped at dispatch, when it physically leaves).
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
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<string[]> {
if (!wagonPlan.length) return [];
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: slot.wagonTypeCode,
boardYardId: slot.boardYardId ?? null,
})),
wagons,
targetScheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
);
}
private findUnpinnableWagonSlots(
slots: Array<{
sequenceNo: number;
wagonTypeId: string;
wagonTypeCode: string;
boardYardId?: string | null;
}>,
wagons: Wagon[],
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set<string>();
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
);
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<string>,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
// Loose pool never lends a wagon coupled to a built train's consist.
if (wagon.trainId) return false;
// Out on a dispatched train right now — physically gone.
if (
wagon.currentTrainScheduleId &&
wagon.currentTrainScheduleId !== scheduleId
) {
return false;
}
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
};
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
// wherever they currently sit (they travel with the train), never a loose
// yard wagon.
if (builtTrainId) {
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
}
return reverseWagonOrder
? b.sequenceNumber - a.sequenceNumber
: a.sequenceNumber - b.sequenceNumber;
});
return candidates[0];
}
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
// fall back to one riding from the train's origin.
if (slot.boardYardId) {
const atBoardYard = wagons.find(
(w) => usable(w) && w.currentYardId === slot.boardYardId,
);
if (atBoardYard) return atBoardYard;
}
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
}
private positiveNumber(value: number | undefined, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
private async validateFleetContainers(
placements: ContainerPlacementInput[],
containerBookings: Booking[],
): Promise<string[]> {
const violations: string[] = [];
const inventoryIds = [
...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))),
];
if (!inventoryIds.length) return violations;
const lineById = new Map(
containerBookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, line] as const),
),
);
const containers = await this.dataSource.getRepository(Container).find({
where: { id: In(inventoryIds) },
});
const containerById = new Map(containers.map((c) => [c.id, c]));
for (const placement of placements) {
if (!placement.containerId) continue;
const fleet = containerById.get(placement.containerId);
if (!fleet) {
violations.push(`Fleet container ${placement.containerId} not found`);
continue;
}
if (fleet.status !== 'AVAILABLE') {
violations.push(`Container ${fleet.containerNumber} is not available`);
}
const line = lineById.get(placement.bookingContainerId);
if (line && fleet.containerTypeId !== line.containerTypeId) {
violations.push(
`Container ${fleet.containerNumber} type does not match booking line`,
);
}
if (
placement.containerNumber &&
fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase()
) {
violations.push(
`Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`,
);
}
}
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
/**
* Wagon types allowed to carry each container/cargo type on these bookings.
* The many-to-many configuration lists (active wagon types only), keyed by
* type id — the flexible planner mixes wagon types within one consist.
* Bookings must arrive from findByIdsForScheduling so the `wagonTypes`
* relations are loaded.
*/
private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap {
const byContainerTypeId = new Map<string, WagonType[]>();
const byCargoTypeId = new Map<string, WagonType[]>();
const active = (list?: WagonType[] | null) =>
(list ?? []).filter((wt) => wt.isActive !== false);
for (const booking of bookings) {
for (const line of booking.bookingContainers ?? []) {
const containerType = line.containerType;
if (containerType && !byContainerTypeId.has(containerType.id)) {
byContainerTypeId.set(containerType.id, active(containerType.wagonTypes));
}
}
const cargoType = booking.cargoType;
if (cargoType && !byCargoTypeId.has(cargoType.id)) {
byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes));
}
}
return { byContainerTypeId, byCargoTypeId };
}
/**
* TRAIN-mode wagon stock: the built train's own consist, grouped by wagon
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
relations: { wagonType: true },
});
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const wagon of wagons) {
remainingByTypeId.set(
wagon.wagonTypeId,
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
);
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
}
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
}
/**
* Stamp each plan slot with the leg it occupies (dynamic consist): the
* boarding/alighting yards of the bookings it carries. Null means the
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
* carrying bookings with mixed corridors stays whole-route (conservative).
*/
private stampSlotLegs(
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
scheduleOriginYardId: string,
scheduleDestinationYardId: string,
): 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,
wagonPlan: WagonPlanSlot[],
) {
const wagons = wagonPlan.map((slot) =>
manager.getRepository(TrainSetWagon).create({
trainSetId,
wagonTypeId: slot.wagonTypeId,
sequenceNo: slot.sequenceNo,
capacityTons: slot.capacityTons,
lengthMeters: slot.lengthMeters,
assignedWeightTons: slot.assignedWeightTons,
status: 'PLANNED',
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
}),
);
return manager.getRepository(TrainSetWagon).save(wagons);
}
private async persistAllocationsAndLoads(
manager: EntityManager,
savedWagons: TrainSetWagon[],
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
containerPlacements: ContainerPlacementInput[] = [],
) {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const lineById = new Map(
bookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
),
);
const allocationBySlotBooking = new Map<string, string>();
const containerItems: Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string | null;
grossWeightTons: number;
positionOnWagon: number | null;
containerId?: string | null;
containerNumber?: string | null;
sealNumber?: string | null;
}> = [];
const bulkLoads: Array<{
wagonBookingAllocationId: string;
bookingId: string;
cargoTypeId: string | null;
cargoDescription: string | null;
weightTons: number;
quantity: number;
}> = [];
for (let i = 0; i < savedWagons.length; i += 1) {
const slot = wagonPlan[i];
const trainSetWagon = savedWagons[i];
if (!slot || !trainSetWagon) continue;
for (const alloc of slot.allocations) {
const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: trainSetWagon.id,
bookingId: alloc.bookingId,
allocatedWeightTons: alloc.allocatedWeightTons,
loadType: alloc.loadType,
status: 'PLANNED',
}),
);
allocationBySlotBooking.set(
`${slot.sequenceNo}:${alloc.bookingId}`,
savedAllocation.id,
);
const booking = bookingById.get(alloc.bookingId);
if (!booking) continue;
if (alloc.loadType === AllocationLoadType.Bulk) {
bulkLoads.push({
wagonBookingAllocationId: savedAllocation.id,
bookingId: booking.id,
cargoTypeId: booking.cargoTypeId ?? null,
cargoDescription: booking.cargoFreeText ?? null,
weightTons: alloc.allocatedWeightTons,
quantity: 1,
});
}
}
}
for (const placement of containerPlacements) {
const lineEntry = lineById.get(placement.bookingContainerId);
if (!lineEntry) continue;
// Durably persist the container number on the booking container line first, so it
// survives a refresh regardless of whether a wagon allocation slot can be matched
// below. booking_container is the source of truth re-read into the preview units.
if (placement.containerNumber && placement.containerNumber.trim()) {
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
containerNumber: placement.containerNumber.trim(),
});
}
const allocationId = allocationBySlotBooking.get(
`${placement.sequenceNo}:${lineEntry.bookingId}`,
);
if (!allocationId) continue;
const { line } = lineEntry;
containerItems.push({
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
containerTypeId: line.containerTypeId ?? null,
grossWeightTons: Number(line.vgmPerUnitTons),
positionOnWagon: placement.unitIndex + 1,
containerId: placement.containerId ?? null,
containerNumber: placement.containerNumber?.trim() ?? null,
sealNumber: placement.sealNumber ?? null,
});
if (placement.containerId) {
await manager.getRepository(Container).update(placement.containerId, {
status: 'LOADED',
bookingId: lineEntry.bookingId,
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
});
}
}
if (containerItems.length) {
await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager);
}
if (bulkLoads.length) {
await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager);
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
/**
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
* legacy single-locomotive column on the train set.
*/
private async findLocomotiveIdsDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
manager?: EntityManager,
): Promise<Set<string>> {
if (!locomotiveIds.length) return new Set();
const runner = manager ?? this.dataSource;
const rows: { locomotive_id: string }[] = await runner.query(
`SELECT DISTINCT loco.locomotive_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND ts.id <> $1
AND loco.locomotive_id = ANY($2)`,
[excludeScheduleId, locomotiveIds],
);
return new Set(rows.map((r) => r.locomotive_id));
}
private async assertLocomotivesNotDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
): Promise<void> {
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
locomotiveIds,
excludeScheduleId,
);
if (!busy.size) return;
const locos = await this.dataSource
.getRepository(Locomotive)
.find({ where: { id: In([...busy]) } });
const codes = locos.map((l) => l.code).join(', ');
throw new ConflictException(
`Locomotive(s) ${codes} are currently out on another dispatched train`,
);
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
totalLengthMeters: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locomotive.status !== 'AVAILABLE') {
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
}
if (
Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
totalWeightTons
) {
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
}
if (
Number(locomotive.maxTrainLengthMeters) +
(Number(locomotive.overageToleranceMeters) || 0) <
totalLengthMeters
) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive;
}
private async buildEmptyTrainSet(
manager: EntityManager,
locomotives: Locomotive[],
builtTrainId: string | null = null,
) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
// Built fleet train this set was formed from (Train Builder), if any.
trainId: builtTrainId,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getSchedulableRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
relations: { originYard: true, destinationYard: true },
});
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
if (route.status !== 'AVAILABLE') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
);
}
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route;
}
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(
booking: Booking,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
) {
return {
id: booking.id,
reference: booking.reference,
freightType: booking.freightType,
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
priorityScore: booking.priorityScore,
schedulingStatus: booking.schedulingStatus,
containerType:
booking.bookingContainers
?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
quantity:
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(booking, tareDims),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
status: booking.status,
};
}
/**
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
* Schedules with no bookings (type null) match nothing. Applied to `id` so
* the list query stays on findAndCount instead of a query-builder rewrite.
*/
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
`AND b.freight_type ${cmp} :${param})`;
if (freightType === 'MIXED') {
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
);
}
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
{ ftIs: freightType, ftNot: freightType },
);
}
private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
const types = new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.freightType)
.filter((t): t is string => Boolean(t)),
);
if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
if (types.size > 1) return 'MIXED';
return null;
}
/**
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
* past a concurrent insert that grabbed the same sequence (the unique index
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
* runs inside the caller's transaction manager so the row joins the same commit.
*/
private async insertScheduleWithReference(
manager: EntityManager,
build: (reference: string) => TrainSchedule,
): Promise<TrainSchedule> {
const year = new Date().getFullYear();
const repo = manager.getRepository(TrainSchedule);
for (let attempt = 0; attempt < 5; attempt += 1) {
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
try {
return await repo.save(build(reference));
} catch (err) {
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
continue;
}
throw err;
}
}
// Unreachable — the loop either returns or throws — but satisfies the compiler.
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return {
id: schedule.id,
reference: schedule.reference ?? null,
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
id: schedule.trainSet.train.id,
code: schedule.trainSet.train.code,
trainName: schedule.trainSet.train.trainName ?? null,
}
: null,
locomotive: schedule.trainSet?.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
maxWagons: schedule.maxWagons ?? 0,
remainingWagons: Math.max(
0,
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
),
};
}
/** AVAILABLE locomotives at the route's origin yard. */
/**
* All in-service locomotives, annotated for the schedule-creation picker.
* Advance scheduling means nothing is filtered out — staff see status, whether the
* locomotive is at the origin yard yet, and how many future schedules it already has.
*/
async getAvailableLocomotivesForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
const locomotives = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
order: { code: 'ASC' },
});
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
? await this.dataSource.query(
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND loco.locomotive_id = ANY($1)
GROUP BY loco.locomotive_id`,
[locomotives.map((l) => l.id)],
)
: [];
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
return locomotives.map((loco) => ({
...loco,
atOriginYard: loco.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
}));
}
/**
* All schedulable built trains (Train Builder), annotated for the
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with fewer than two locomotives are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
const trains = await this.dataSource.getRepository(Train).find({
where: {
status: Not(
In([
Freight.TrainStatus.OutOfService,
Freight.TrainStatus.UnderMaintenance,
Freight.TrainStatus.Deactivated,
]),
),
},
relations: {
currentYard: true,
locomotives: { locomotive: true },
wagons: { wagonType: true },
},
order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } },
});
const counts: { train_id: string; future_count: string }[] = trains.length
? await this.dataSource.query(
`SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND tset.train_id = ANY($1)
GROUP BY tset.train_id`,
[trains.map((t) => t.id)],
)
: [];
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 2)
.map((train) => {
const wagons = train.wagons ?? [];
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
id: train.currentYard.id,
code: train.currentYard.code,
label: train.currentYard.label,
}
: null,
locomotives: (train.locomotives ?? [])
.filter((link) => link.locomotive)
.map((link) => ({
id: link.locomotive!.id,
code: link.locomotive!.code,
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
totalTareTons: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
),
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: roundTons(Number(train.capacityTons)),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
});
}
/**
* Consist snapshot for the adjust-consist UI: the built train's wagons with
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
* against the locomotive limits incl. overage tolerance, addable yard wagons,
* and the adjustment history.
*/
async getScheduleConsist(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const builtTrain = schedule.trainSet?.train;
if (!builtTrain) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrain.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const addableWagons = await this.dataSource.getRepository(Wagon).find({
where: {
trainId: IsNull(),
status: WagonStatus.Available,
currentYardId: builtTrain.currentYardId ?? undefined,
},
relations: { wagonType: true },
order: { wagonNumber: 'ASC' },
});
const adjustments = await this.dataSource
.getRepository(ScheduleWagonAdjustmentLog)
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
// Slots with cargo aboard — their physical wagons are "loaded" and can
// never be trimmed.
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const consistTareTons = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
);
const consistLengthMeters = roundTons(
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
);
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
// capacity, so trimming/coupling wagons moves the FULL line live.
const wagonUsage =
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
const mapWagon = (wagon: Wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
}
: null,
});
return {
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
train: {
id: builtTrain.id,
code: builtTrain.code,
trainName: builtTrain.trainName ?? null,
currentYardId: builtTrain.currentYardId ?? null,
},
limits: {
maxPullWeightTons,
overageToleranceTons,
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
maxTrainLengthMeters,
overageToleranceMeters,
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
},
totals: {
wagonCount: wagons.length,
cargoTons,
consistTareTons,
grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters,
},
scheduleCapacity: wagonUsage
? {
...wagonUsage,
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
}
: null,
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
})),
addableWagons: addableWagons.map(mapWagon),
adjustments: adjustments.map((log) => ({
id: log.id,
action: log.action,
wagonId: log.wagonId,
wagonNumber: log.wagonNumber,
adjustedByUserId: log.adjustedByUserId,
occurredAt: log.occurredAt,
})),
editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
};
}
/**
* Permanently adjust the built train's consist from a schedule: trim free
* wagons (their tare no longer rides — the usual fix when gross weight beats
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
* length headroom remain (limits incl. overage tolerance). The built train
* updates in place, the schedule's wagon cap follows, and every change is
* logged for the schedule's history.
*/
async adjustScheduleConsist(
scheduleId: string,
dto: AdjustScheduleConsistDto,
userId?: string | null,
) {
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
if (!addWagonIds.length && !removeWagonIds.length) {
throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
}
const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
if (overlap.length) {
throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
}
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(
'The consist is frozen once the train is dispatched — adjust before departure',
);
}
const builtTrainRef = schedule.trainSet?.train;
if (!builtTrainRef) {
throw new BadRequestException(
'This schedule was not created from a built train — its consist cannot be adjusted here',
);
}
const loadedWagonIds = new Set(
(schedule.trainSet?.wagons ?? [])
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);
const lengthCapMeters = roundTons(
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({
where: { id: builtTrainRef.id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
const consist = await manager.getRepository(Wagon).find({
where: { trainId: train.id },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
});
const consistById = new Map(consist.map((w) => [w.id, w]));
// --- validate removals: must be coupled and free (no cargo, no pin) ---
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
const removed: Wagon[] = [];
for (const wagonId of removeWagonIds) {
const wagon = consistById.get(wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
}
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
);
}
removed.push(wagon);
}
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
);
}
added.push(wagon);
}
// --- headroom check (only additions can push the train over a cap) ---
const removedIds = new Set(removed.map((w) => w.id));
const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
const finalGrossTons = roundTons(cargoTons + finalTareTons);
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
throw new BadRequestException(
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
);
}
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
throw new BadRequestException(
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
);
}
// --- apply: detach trims, couple additions, compact the sequence ---
for (const wagon of removed) {
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
}
const remaining = consist.filter((w) => !removedIds.has(w.id));
for (let i = 0; i < remaining.length; i++) {
if (remaining[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
}
}
let sequence = remaining.length;
for (const wagon of added) {
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
// The schedule is full when every consist wagon is allocated.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { maxWagons: finalConsist.length });
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
const now = new Date();
await logRepo.save(
[
...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
].map(({ action, wagon }) =>
logRepo.create({
trainScheduleId: scheduleId,
trainId: train.id,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: userId ?? null,
occurredAt: now,
}),
),
);
});
// The consist IS the schedule's booking capacity, so an edit moves the
// FULL line: freeing slots on a FULL schedule reopens its window, taking
// the last slot closes it. Staff may shrink below what is already
// committed — allowed, but reported back as a warning (never silently).
const warnings: string[] = [];
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
if (usage) {
const nowFull = usage.remainingSlots <= 0;
if (usage.overAllocatedBy > 0) {
warnings.push(
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
`${usage.allocatedWagons}${usage.overAllocatedBy} wagon(s) over capacity. ` +
'Couple more wagons or free bookings before departure.',
);
}
if (wasFull && !nowFull) {
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
warnings.push(
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
'so it is no longer FULL and can take bookings again.',
);
} else if (!wasFull && nowFull) {
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
warnings.push(
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
);
}
}
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
}
/**
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
*/
private async syncBuiltTrainAfterScheduleChange(
manager: EntityManager,
trainId: string,
moveToYardId?: string | null,
): Promise<void> {
const train = await manager.getRepository(Train).findOne({ where: { id: trainId } });
if (!train) return;
const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {};
const managed = [
Freight.TrainStatus.Available,
Freight.TrainStatus.Scheduled,
Freight.TrainStatus.InService,
];
if (!managed.includes(train.status)) {
if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch);
return;
}
const rows: { status: string }[] = await manager.query(
`SELECT DISTINCT ts.status
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[trainId],
);
const statuses = new Set(rows.map((r) => r.status));
const next = statuses.has('DISPATCHED')
? Freight.TrainStatus.InService
: statuses.size
? Freight.TrainStatus.Scheduled
: Freight.TrainStatus.Available;
await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch });
}
/**
* A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a
* FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s
* milestones with the destination stop AFTER the origin stop — OR (routes with
* no milestones recorded) the pair equals the schedule's own endpoints. This
* mirrors the sub-leg matching the create-booking path already does
* (`getBookableScheduleEntities`), so a through train (Djibouti → Kality →
* Dire) is announced and bookable for every leg it actually serves
* (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two
* endpoints. Static SQL fragment (no user input) interpolated into the window
* queries below; `ts` and `cr` must be the schedule and contract_route aliases.
*/
private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `(
EXISTS (
SELECT 1
FROM freight.route_milestones mo
JOIN freight.route_milestones md
ON md.route_id = mo.route_id
AND md.sequence_no > mo.sequence_no
WHERE mo.route_id = ts.route_id
AND mo.yard_id = cr.origin_yard_id
AND md.yard_id = cr.destination_yard_id
)
OR (cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id)
)`;
/**
* Upcoming/open booking windows announced on the portal home "booking
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
* are listed so every customer sees what is opening — not just those on their
* contract lanes; DOMESTIC trains are always open and need no announcement.
*
* When `companyId` is given, a matching active contract on the lane is
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
* "Book now"); customers with no covering contract still see the window with a
* null contract, and the portal routes them to the contract list to get one.
* A contract covering any FORWARD sub-leg of the corridor counts as covering
* the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`).
*/
async getBookingWindowsForCompany(companyId: string | null) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.contract_routes cr
ON cr.deleted_at IS NULL
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
LEFT JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
[companyId],
);
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
// per-row ordering, so re-sort the mapped rows by departure for the client.
return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
return ta - tb;
});
}
/**
* Upcoming/open booking windows on a single contract's routes. Used to gate the
* booking form for the customer AND Ethiopian GL (who books on the customer's
* behalf): no window row with isOpenNow=true → booking entry is hidden.
*/
async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.contract_id = $1
AND cr.deleted_at IS NULL
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[contractId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
}
/**
* All announced booking windows across every lane — import window cycles AND
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
* phase filter as the customer-facing lists, no contract scoping.
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
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,
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
FROM freight.route_milestones rm
JOIN freight.yards rmy ON rmy.id = rm.yard_id
WHERE rm.route_id = ts.route_id) AS route_stations
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
}
private mapBookingWindowRow(r: BookingWindowRow) {
const origin = r.origin_label ?? r.origin_code ?? null;
const destination = r.destination_label ?? r.destination_code ?? null;
// Full corridor from the route's milestones (origin → stops → destination).
// Falls back to the schedule's origin/destination when no milestones exist.
const milestoneStops = (r.route_stations ?? []).filter(
(s): s is string => Boolean(s),
);
const routeStations =
milestoneStops.length >= 2
? milestoneStops
: [origin, destination].filter((s): s is string => Boolean(s));
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
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,
destination,
routeStations,
};
}
/** OPEN schedules a new booking may target (with rough remaining capacity).
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
* returns schedules whose route passes through both yards in the correct order.
*/
/**
* Raw OPEN same-route schedule entities a new booking may target (with the
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
* originStationId / scheduledDepartureDate / trainSet).
*/
private async getBookableScheduleEntities(
originYardId?: string,
destinationYardId?: string,
): Promise<
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
> {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
route: { milestones: true },
originStation: true,
destinationStation: true,
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<string>();
for (const s of schedules) {
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
}
return { days: [...days].sort() };
}
/**
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
* train capacity (not fully allocated) AND its wagon stock can physically carry
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
* NOT gated — a booking bigger than the free capacity is accepted and the batch
* engine offers a partial split later. No counts are exposed: same
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
* not a train.
*/
async getAvailableDaysForCargo(input: {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
input.destinationYardId,
);
if (schedules.length === 0) return { days: [] };
const withCapacity = schedules.filter(
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
);
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
const days = new Set<string>();
for (const s of compatible) {
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
/**
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
* whose wagon stock can physically carry the selected cargo — every container
* line (or the bulk cargo type) must map to at least one wagon type the
* schedule's stock actually has. Stock = the built train's own consist, or the
* origin yard's loose pool for schedules assembled from loose locomotives.
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
* a partial split offer from the batch engine later.
*/
private async filterCargoCompatibleSchedules(
schedules: TrainSchedule[],
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
},
): Promise<TrainSchedule[]> {
if (!schedules.length) return schedules;
const required = await this.requiredWagonTypeSets(cargo);
// No cargo identity supplied — nothing to gate on (legacy callers).
if (required === null) return schedules;
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
return schedules.filter((s) => {
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
return required.every((set) => {
for (const typeId of set) if (stock.has(typeId)) return true;
return false;
});
});
}
/**
* One Set of allowed wagon-type ids per required cargo dimension: per
* container line's type (or per container size when only sizes are known),
* or a single set for the bulk cargo type. `null` = no cargo identity given,
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
* configured) — the gate then blocks every schedule, mirroring the hard
* config violation scheduling raises for the same state.
*/
private async requiredWagonTypeSets(cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
cargoTypeCode?: string | null;
containers?: Array<{ containerSize: string; quantity: number }>;
containerTypeIds?: string[];
}): Promise<Set<string>[] | null> {
if (cargo.freightType === 'CONTAINER') {
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
if (typeIds.length) {
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
[typeIds],
);
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
}
return [...byType.values()];
}
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
// ANY active container type of that size has a matching wagon type.
const sizes = [
...new Set(
(cargo.containers ?? [])
.map((line) => parseInt(String(line.containerSize), 10))
.filter((n) => Number.isFinite(n) && n > 0),
),
];
if (!sizes.length) return null;
const rows: { size_ft: number; wagon_type_id: string | null }[] =
await this.dataSource.query(
`SELECT ct.size_ft, wt.id AS wagon_type_id
FROM freight.container_types ct
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
AND (ct.is_active IS DISTINCT FROM false)`,
[sizes],
);
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
for (const row of rows) {
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
}
return [...bySize.values()];
}
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
`SELECT wt.id AS wagon_type_id
FROM freight.cargo_types c
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
LEFT JOIN freight.wagon_types wt
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
WHERE c.deleted_at IS NULL
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
);
const set = new Set<string>();
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
return [set];
}
/**
* Wagon-type ids each schedule's stock can offer: the built train's own
* consist for train-bound schedules, the origin yard's loose usable pool
* otherwise. Batched — two queries for the whole schedule list.
*/
private async scheduleWagonTypeStock(
schedules: TrainSchedule[],
): Promise<Map<string, Set<string>>> {
const builtTrainIds = [
...new Set(
schedules
.map((s) => s.trainSet?.trainId)
.filter((id): id is string => Boolean(id)),
),
];
const looseOriginYardIds = [
...new Set(
schedules
.filter((s) => !s.trainSet?.trainId)
.map((s) => s.originStationId)
.filter(Boolean),
),
];
const [trainRows, yardRows] = await Promise.all([
builtTrainIds.length
? (this.dataSource.query(
`SELECT train_id, wagon_type_id
FROM freight.wagons
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
GROUP BY train_id, wagon_type_id`,
[builtTrainIds],
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
looseOriginYardIds.length
? (this.dataSource.query(
`SELECT current_yard_id, wagon_type_id
FROM freight.wagons
WHERE train_id IS NULL AND deleted_at IS NULL
AND status IN ('AVAILABLE', 'ASSIGNED')
AND current_yard_id = ANY($1::uuid[])
GROUP BY current_yard_id, wagon_type_id`,
[looseOriginYardIds],
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
]);
const byTrain = new Map<string, Set<string>>();
for (const row of trainRows) {
const set = byTrain.get(row.train_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byTrain.set(row.train_id, set);
}
const byYard = new Map<string, Set<string>>();
for (const row of yardRows) {
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
set.add(row.wagon_type_id);
byYard.set(row.current_yard_id, set);
}
const result = new Map<string, Set<string>>();
for (const s of schedules) {
const trainId = s.trainSet?.trainId;
result.set(
s.id,
trainId
? byTrain.get(trainId) ?? new Set()
: byYard.get(s.originStationId) ?? new Set(),
);
}
return result;
}
/**
* Booking-time gate for a chosen day: does the route have an OPEN departure
* that day at all, and can any of that day's departures physically carry the
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
* partial split offer instead).
*/
async checkDayCargoCompatibility(
originYardId: string,
destinationYardId: string,
day: string,
cargo: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
containerTypeIds?: string[];
},
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
const schedules = await this.getBookableScheduleEntities(
originYardId,
destinationYardId,
);
const onDay = schedules.filter(
(s) =>
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
);
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
}
/**
* Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
*/
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
let milestoneYards: string[] = [];
if (schedule.route?.milestones?.length) {
milestoneYards = [...schedule.route.milestones]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yardId);
} else if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
milestoneYards = milestones.map((m) => m.yardId);
}
const raw = milestoneYards.length >= 2
? milestoneYards
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
const unique: string[] = [];
for (const yardId of raw) {
if (yardId && !unique.includes(yardId)) unique.push(yardId);
}
return unique;
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
/**
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per detail/board request without letting edits go stale long. */
private wagonTareDimsCache: {
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
expiresAt: number;
} | null = null;
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
return this.wagonTareDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
t.id,
{
tareWeightTons: Number(t.tareWeightTons) || 0,
capacityTons: Number(t.capacityTons) || 0,
},
]),
);
const value = {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
},
container: {
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
* every wagon the booking occupies — the same gross axis the batch engine
* spends against the locomotive's pull limit. Wagon count mirrors the batch
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
* tons ÷ payload for bulk — whichever is largest).
*/
private grossBookingWeightTons(
booking: Pick<
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
const wagonTypeId =
booking.freightType === 'BULK'
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
const dims = {
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
capacityTons: typed?.capacityTons || fallback.capacityTons,
};
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight =
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
const wagons = Math.max(1, stored, byLength, byWeight);
return roundTons(cargo + wagons * dims.tareWeightTons);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
// Snapshot state decides below whether the live consist may be drawn at
// all, so it is derived before the consist wagons are fetched.
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer
// describe THIS train. If a frozen snapshot was captured at the transition,
// the per-slot wagon number + booking allocations are read from it instead.
const snapshot = schedule.wagonAllocationSnapshot ?? null;
const isWagonAllocationFrozen = Boolean(
snapshot &&
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled,
);
const snapshotSlotByTrainSetWagonId = new Map(
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
await Promise.all([
this.loadWagonTareDims(),
requiresLoadingConfirmation
? this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } })
: null,
this.getWindowConfig(),
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
// Mirror the pinning direction: a reverse-order schedule draws the
// whole consist back-to-front, empties included.
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
})
: [],
]);
const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt)
: true;
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
// train's remaining wagons as consist-only entries and the composition views
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
// really is: loaded slots first, then the empty consist. Skipped for frozen
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
// later trains, so the live consist no longer describes THIS departure.
const coveredPhysicalIds = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(slot.id)
: undefined;
const physicalId = frozenSlot
? frozenSlot.physicalWagonId
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}));
return {
id: schedule.id,
reference: schedule.reference ?? null,
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null,
windowOpensAt: schedule.windowOpensAt
? schedule.windowOpensAt.toISOString()
: null,
windowClosesAt: schedule.windowClosesAt
? schedule.windowClosesAt.toISOString()
: null,
docReviewEndsAt: schedule.docReviewEndsAt
? schedule.docReviewEndsAt.toISOString()
: null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
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,
// Built train (Train Builder) behind this departure, when scheduled by train.
train: schedule.trainSet?.train
? {
id: schedule.trainSet.train.id,
code: schedule.trainSet.train.code,
trainName: schedule.trainSet.train.trainName ?? null,
}
: null,
trainSet: schedule.trainSet
? {
id: schedule.trainSet.id,
status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
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) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
// comes live. Falls back to live if a slot is missing from the snap.
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
return {
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: frozenSlot
? frozenSlot.physicalWagonId
: wagon.physicalWagonId ?? null,
physicalWagonNumber: frozenSlot
? frozenSlot.physicalWagonNumber
: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations: frozenSlot
? frozenSlot.allocations.map((allocation) => ({
id: null,
bookingId: allocation.bookingId,
bookingReference: allocation.bookingReference,
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
loadType: allocation.loadType,
status: null,
// Frozen: container detail collapses to the captured numbers;
// per-container geometry isn't re-derivable post-release.
containerItems: allocation.containerNumbers.map((containerNumber) => ({
id: null,
containerNumber,
containerTypeId: null,
grossWeightTons: null,
containerId: null,
positionOnWagon: null,
bookingContainerId: null,
})),
bulkLoad: null,
}))
: wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
};
})
.concat(emptyConsistWagons),
}
: null,
bookings:
schedule.scheduleBookings?.map((sb) => ({
id: sb.booking?.id ?? sb.bookingId,
reference: sb.booking?.reference ?? null,
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
weightTons: sb.booking
? this.grossBookingWeightTons(sb.booking, tareDims)
: 0,
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Which leg of the corridor this booking rides — the workspace can't
// tell a ride-along (intercity) or sub-corridor booking from through
// cargo without it.
tradeDirection: sb.booking?.tradeDirection ?? null,
originYardId: sb.booking?.originYardId ?? null,
destinationYardId: sb.booking?.destinationYardId ?? null,
origin:
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
destination:
sb.booking?.destinationYard?.label ??
sb.booking?.destinationYard?.code ??
null,
wagonsRequired:
sb.booking?.wagonsRequired != null
? Number(sb.booking.wagonsRequired)
: null,
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
isWagonAllocationFrozen,
wagonAllocationSnapshot: snapshot,
};
}
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
private mapScheduleStops(
schedule: TrainSchedule,
): Array<{ yardId: string; label: string }> {
const milestones = [...(schedule.route?.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const raw = milestones.length >= 2
? milestones.map((m) => ({
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
}))
: [
{
yardId: schedule.originStationId,
label:
schedule.originStation?.label ??
schedule.originStation?.code ??
schedule.originStationId,
},
{
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
schedule.destinationStationId,
},
];
const seen = new Set<string>();
return raw.filter((stop) => {
if (!stop.yardId || seen.has(stop.yardId)) return false;
seen.add(stop.yardId);
return true;
});
}
private isHoldActive(booking: Booking): boolean {
if (!booking.holdExpiresAt) return false;
return booking.holdExpiresAt.getTime() > Date.now();
}
private resolvePostUnassignStatus(booking: Booking | null): string {
if (!booking) return SchedulingStatus.NotScheduled;
if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
return SchedulingStatus.Holding;
}
return SchedulingStatus.Eligible;
}
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!schedule.trainSet?.locomotive) {
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not linked to this schedule');
}
if (!this.isReadyToLoadBooking(booking)) {
throw new BadRequestException('Booking is not paid and ready to load');
}
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.has(bookingId)) {
throw new BadRequestException('Booking is already assigned to a wagon');
}
const allBookingIds = [...wagonAssignedIds, bookingId];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(undefined, 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,
// Callers that already hold the full schedule graph (batch board detail)
// pass it in so the preview doesn't re-load the same heavy graph.
preloadedSchedule?: TrainSchedule,
): Promise<WagonAllocationAttemptResult> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, false);
}
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
async tryAutoWagonAllocation(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, true);
}
private async buildAllocationAttempt(
schedule: TrainSchedule,
performAssign: boolean,
): Promise<WagonAllocationAttemptResult> {
const empty: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
if (!schedule.trainSet?.locomotive) {
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
return {
...empty,
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
};
}
const linkedBookings = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
schedule.id,
schedule,
);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Validation failed';
return {
...empty,
violations: [message],
issues: eligible.map((b) => ({
bookingId: b.id,
status: 'FAILED' as const,
issue: message,
})),
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
const deferredMap = new Map(
validation.deferredBookings.map((d) => [d.id, d.reason]),
);
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingNumbers = findMissingContainerNumberIssues(units, placements);
const missingByBooking = new Map<string, string>();
for (const m of missingNumbers) {
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
}
const placeholderWarnings = new Map<string, string>();
for (const p of placements) {
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
const unit = units.find(
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
);
if (unit && !placeholderWarnings.has(unit.bookingId)) {
placeholderWarnings.set(
unit.bookingId,
'Container number auto-assigned — verify before dispatch.',
);
}
}
const assignableIds = validation.bookings
.filter((b) => !missingByBooking.has(b.id))
.map((b) => b.id);
const assignableSet = new Set(assignableIds);
const assignPlacements = placementsForBookings(
placements,
assignableSet,
units,
);
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
}
if (missingByBooking.has(b.id)) {
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
}
if (deferredMap.has(b.id)) {
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
}
if (!fittingIds.has(b.id)) {
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
return {
bookingId: b.id,
status: 'FAILED',
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
};
}
if (wagonAssignedIds.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
}
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
});
const result: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: validation.deferredBookings,
issues,
violations: validation.violations,
};
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
};
}
try {
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
result.assignedBookingIds = assignableIds;
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId)) {
issue.status = 'ASSIGNED';
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
}
}
} catch (err) {
const message =
err instanceof BadRequestException
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
'; ',
) ??
(err.getResponse() as { message?: string }).message ??
err.message)
: err instanceof Error
? err.message
: 'Allocation failed';
result.violations = [...result.violations, message];
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
issue.status = 'FAILED';
issue.issue = message;
}
}
}
return result;
}
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
}
const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
if (!wagon) {
throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
}
if ((wagon.allocations ?? []).length > 0) {
throw new BadRequestException(
'Cannot remove a wagon slot that has active allocations; remove the booking first',
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
// Recount from the slot rows rather than decrementing the cached counter.
// A blind `wagonCount - 1` desyncs the moment two removals race or the
// in-memory schedule graph is stale, and the counter is what the schedule
// capacity math reads.
const remaining = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
select: { id: true, lengthMeters: true },
});
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
wagonCount: remaining.length,
totalLengthMeters: roundTons(
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
),
});
});
return this.getTrainScheduleById(scheduleId);
}
async updateContainerItem(
scheduleId: string,
itemId: string,
dto: UpdateContainerItemDto,
): Promise<{ id: string; containerNumber: string | null }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status === 'DISPATCHED') {
throw new BadRequestException('Cannot edit a dispatched schedule');
}
const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
where: { id: itemId },
relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
});
if (!item) {
throw new NotFoundException(`Container item ${itemId} not found`);
}
const wagonId = item.wagonBookingAllocationId;
const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
where: { id: wagonId },
relations: ['trainSetWagon'],
});
if (!wagonAllocation?.trainSetWagon) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
const trainSetWagonId = wagonAllocation.trainSetWagon.id;
const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.includes(trainSetWagonId)) {
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
}
await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
containerNumber: dto.containerNumber ?? null,
});
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
/**
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
* Whole-load moves keep every packing rule intact by construction (a valid
* load stays valid on any wagon whose type supports it), which is what lets
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
* swap with containers.
*
* Three shapes, picked from the target:
* - target is an empty consist-only wagon (coupled on the built train, no
* slot row): REPIN — the source slot simply points at that physical wagon
* (type/capacity/length follow), and the wagon it left shows as empty.
* - target is an empty slot: allocations repoint to it and the load-coupled
* slot fields (assigned weight, status, board/alight leg) move across.
* - target is a loaded slot: the two loads swap wagons the same way.
*
* Validated per direction: the receiving wagon's type must support the
* incoming load type, and the incoming cargo must fit its rated payload.
*/
async moveWagonLoad(
scheduleId: string,
sourceWagonId: string,
dto: MoveWagonLoadDto,
): Promise<any> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
}
if (sourceWagonId === dto.targetWagonId) {
return this.getTrainScheduleById(scheduleId);
}
const slots = schedule.trainSet?.wagons ?? [];
const source = slots.find((w) => w.id === sourceWagonId);
if (!source) {
throw new NotFoundException('Source wagon is not part of this schedule');
}
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
const loadAllocations = (trainSetWagonId: string) =>
allocRepo.find({ where: { trainSetWagonId } });
const sourceAllocs = await loadAllocations(source.id);
if (!sourceAllocs.length) {
throw new BadRequestException('Source wagon has no load to move');
}
// Target: a slot of this train set, or an empty consist-only wagon of the
// built train (physical wagon with no slot row yet).
const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const consistWagon = targetSlot
? null
: schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).findOne({
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
})
: null;
if (!targetSlot && !consistWagon) {
throw new NotFoundException('Target wagon is not part of this schedule');
}
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
];
const cargoOf = (allocs: WagonBookingAllocation[]) =>
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
capacityTons: number,
) => {
const incoming = loadTypesOf(allocs);
// Unknown type or no declared support list → staff decides; don't block.
if (wagonType) {
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
for (const loadType of incoming) {
const ok =
supported.includes(loadType) ||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
supported.length === 0;
if (!ok) {
throw new BadRequestException(
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
);
}
}
}
const cargo = cargoOf(allocs);
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
throw new BadRequestException(
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
);
}
};
// What the target must be able to receive…
checkReceives(
sourceAllocs,
wagonLabel(targetSlot, consistWagon),
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
);
// …and, on a swap, what comes back to the source.
if (targetAllocs.length) {
checkReceives(
targetAllocs,
`#${source.sequenceNo}`,
source.wagonType,
Number(source.capacityTons),
);
}
await this.dataSource.transaction(async (manager) => {
const slotRepo = manager.getRepository(TrainSetWagon);
const allocs = manager.getRepository(WagonBookingAllocation);
// Empty consist wagon: repin the loaded slot onto that physical wagon.
// Allocations and load fields stay put; only the wagon identity changes.
if (consistWagon) {
await slotRepo.update(source.id, {
physicalWagonId: consistWagon.id,
wagonTypeId: consistWagon.wagonTypeId,
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
});
return;
}
const target = targetSlot as TrainSetWagon;
// Load-coupled slot fields travel with the load; wagon identity stays.
const loadFieldsOf = (slot: TrainSetWagon) => ({
assignedWeightTons: slot.assignedWeightTons,
status: slot.status,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
});
const emptyLoadFields = {
assignedWeightTons: 0,
status: 'PLANNED',
boardYardId: null,
alightYardId: null,
};
const sourceLoadFields = loadFieldsOf(source);
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
for (const alloc of sourceAllocs) {
await allocs.update(alloc.id, { trainSetWagonId: target.id });
}
for (const alloc of targetAllocs) {
await allocs.update(alloc.id, { trainSetWagonId: source.id });
}
await slotRepo.update(target.id, sourceLoadFields);
await slotRepo.update(source.id, targetLoadFields);
});
return this.getTrainScheduleById(scheduleId);
}
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const allBookings = await this.bookingsRepository.findAll({
where: { trainScheduleId: scheduleId },
select: [
'id',
'reference',
'freightType',
'priorityScore',
'cargoTotalWeightVgm',
'status',
'schedulingStatus',
'paymentStatus',
'isGovernment',
],
});
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const unassigned = allBookings
.filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
.sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
wagonTypeId: row.wagonTypeId,
wagonTypeCode: row.wagonTypeCode,
needed: 0,
available: row.available,
shortfall: 0,
}));
// Gross weight needs the scheduling graph (containers, cargo type, wagon
// types) that the trimmed select above deliberately skips.
const tareDims = await this.loadWagonTareDims();
const fullById = new Map(
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
(b) => [b.id, b],
),
);
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
b as Booking,
fleetByTypeId,
);
return {
id: b.id,
reference: b.reference ?? null,
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
// GROSS: cargo + tare of the wagons the booking occupies.
grossWeightTons: this.grossBookingWeightTons(
(fullById.get(b.id) ?? b) as Booking,
tareDims,
),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,
};
}),
);
return { fleetAtOrigin, bookings };
}
private async previewUnassignedBookingAssignability(
schedule: TrainSchedule,
wagonAssignedIds: Set<string>,
booking: Booking,
fleetByTypeId: Map<string, { code: string; available: number }>,
): Promise<{
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
shortage: BookingWagonShortage | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
shortage: null,
};
}
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
const resolvedBooking = fullBooking ?? booking;
// List-based resolution: every wagon type allowed for the booking's
// container/cargo type counts toward its availability.
const allowed = this.loadAllowedWagonTypes([resolvedBooking]);
const candidates =
freightType === 'BULK'
? [...allowed.byCargoTypeId.values()].flat()
: [...allowed.byContainerTypeId.values()].flat();
if (!candidates.length) {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
shortage: null,
};
}
const bulkCapacity =
freightType === 'BULK'
? Math.max(...candidates.map((wt) => Number(wt.capacityTons)))
: undefined;
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/');
const yardWagonsAvailable = candidates.reduce(
(sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0),
0,
);
const allBookingIds = [...wagonAssignedIds, booking.id];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
shortage: null,
};
}
if (!validation.valid) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
shortage: null,
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
if (!fittingIds.has(booking.id)) {
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
const yardShortfall =
yardWagonsAvailable < wagonsRequired
? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
: null;
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason:
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
shortage:
deferred?.shortage ??
(yardShortfall
? {
wagonTypeCodes: requiredWagonTypeCode,
wagonsNeeded: wagonsRequired,
wagonsAvailable: yardWagonsAvailable,
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
}
: null),
};
}
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
if (containerBookings.some((b) => b.id === booking.id)) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === booking.id,
);
if (missing) {
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
shortage: null,
};
}
}
return {
wagonsRequired,
requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: true,
blockReason: null,
shortage: null,
};
}
/**
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
* structured per-type shortage this booking would hit if placed on top of the
* schedule's current wagon assignments, or null when it fits (or is blocked
* by something other than missing wagons — those keep the legacy link-then-
* fix-manually path).
*/
async previewPaidBookingWagonShortage(
scheduleId: string,
bookingId: string,
): Promise<BookingWagonShortage | null> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule?.trainSet?.locomotive) return null;
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
booking,
fleetByTypeId,
);
return assignability.shortage;
}
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: {
status: string;
paymentStatus?: string | null;
isGovernment?: boolean;
}): boolean {
if (booking.status === 'EXPIRED') return false;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return false;
}
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
if (booking.isGovernment) return true;
return false;
}
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(
scheduleId: string,
// Pass when the caller already holds the schedule with trainSet.wagons —
// only wagon ids are read here, the old full-graph reload was pure waste.
preloadedSchedule?: TrainSchedule,
): Promise<Set<string>> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { trainSetWagonId: In(wagonIds) },
select: ['bookingId'],
});
return new Set(allocations.map((a) => a.bookingId));
}
}