mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
9768 lines
405 KiB
TypeScript
9768 lines
405 KiB
TypeScript
import {
|
||
AllocationLoadType,
|
||
Freight,
|
||
LoadingStatus,
|
||
SchedulingStatus,
|
||
TrainCheckpointKind,
|
||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||
WagonAllocationSnapshot,
|
||
WagonMovementKind,
|
||
WagonStatus,
|
||
} from '@edr/types';
|
||
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
forwardRef,
|
||
Inject,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
Optional,
|
||
} from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { InjectDataSource } from '@nestjs/typeorm';
|
||
import {
|
||
DataSource,
|
||
EntityManager,
|
||
FindOptionsWhere,
|
||
ILike,
|
||
In,
|
||
IsNull,
|
||
Not,
|
||
QueryFailedError,
|
||
Raw,
|
||
} from 'typeorm';
|
||
|
||
import {
|
||
buildPaginationMeta,
|
||
normalizePagination,
|
||
} from '../../../common/utils/pagination.util';
|
||
import { BookingsRepository } from '../../bookings/bookings.repository';
|
||
import { Booking } from '../../bookings/entities/booking.entity';
|
||
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
|
||
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
|
||
import { Contract } from '../../contracts/entities/contract.entity';
|
||
import { Container } from '../../container-management/entities/container.entity';
|
||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
|
||
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
|
||
import { Train } from '../../trains/entities/train.entity';
|
||
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
|
||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
|
||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
|
||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
||
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
||
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
||
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
||
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
|
||
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
|
||
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
|
||
import {
|
||
ListTrainSchedulesQueryDto,
|
||
TrainScheduleFreightType,
|
||
} from '../dto/list-train-schedules-query.dto';
|
||
import { PinWagonsDto } from '../dto/pin-wagons.dto';
|
||
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
|
||
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
|
||
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
|
||
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
|
||
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||
import {
|
||
ImportDjiboutiOperation,
|
||
type ImportDjiboutiDocumentType,
|
||
} from '../entities/import-djibouti-operation.entity';
|
||
import {
|
||
ImportDjiboutiActionDto,
|
||
UploadImportDjiboutiDocumentDto,
|
||
} from '../dto/import-djibouti-operation.dto';
|
||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
|
||
import { type BookingWindowConfig } from '../booking-window.config';
|
||
import { BookingWindowGateway } from '../booking-window.gateway';
|
||
import { BookingNotifierService } from '../booking-notifier.service';
|
||
import { BookingBatchService } from '../booking-batch.service';
|
||
import {
|
||
computeFleetAvailability,
|
||
summarizeFleetWarnings,
|
||
totalAssignedWeight,
|
||
wagonsRequiredForBooking,
|
||
type BookingWagonShortage,
|
||
type DeferredBookingRow,
|
||
type FleetAvailabilityRow,
|
||
} from '../utils/fleet-plan.util';
|
||
import {
|
||
applyWagonOrderReversal,
|
||
planWagonsWithStock,
|
||
type AllowedWagonTypeMap,
|
||
type WagonStock,
|
||
} from '../wagon-plan-flex.util';
|
||
import {
|
||
containerWagonsForLines,
|
||
expandBookingContainerUnits,
|
||
getContainerSlotSequenceNos,
|
||
roundTons,
|
||
sumWagonsRequired,
|
||
type TrainLimitConfig,
|
||
maxEdgeConsistUsage,
|
||
perEdgeConsistUsage,
|
||
validateContainerPlacements,
|
||
validateMixedTrainLimitsPerEdge,
|
||
MAX_TEU_SLOTS_PER_WAGON,
|
||
type ContainerPlacementInput,
|
||
type WagonPlanSlot,
|
||
} from '../utils/wagon-plan.util';
|
||
import { CorridorBudget } from '../corridor-capacity.util';
|
||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||
import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util';
|
||
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||
import {
|
||
bookingCargoTons,
|
||
bulkItemsFitFor,
|
||
bulkItemWagonsRequired,
|
||
bulkTonsPerWagon,
|
||
consistViolations,
|
||
deriveTrainCapacityFromLocomotive,
|
||
combinedLocomotiveLimits,
|
||
trainHardCaps,
|
||
trainSetLocomotiveLimits,
|
||
wagonTypeDimensionsFromEntity,
|
||
LocomotiveLimits,
|
||
WagonTypeDimensions,
|
||
} from '../train-capacity.util';
|
||
import {
|
||
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||
DEFAULT_BULK_WAGON_TARE_TONS,
|
||
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||
paymentDrainEndsAtIso,
|
||
} from '../booking-batch.constants';
|
||
import { orderConsistWagons } from '../consist-order.util';
|
||
import {
|
||
computeExportWindowTimes,
|
||
computeImportWindowTimes,
|
||
earliestSchedulableDeparture,
|
||
eatDay,
|
||
eatDayToUtc,
|
||
shiftEatDay,
|
||
} from '../batch-window.util';
|
||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||
import { BookingJourneyService } from '../booking-journey.service';
|
||
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
||
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
|
||
import {
|
||
autoFillPlacements,
|
||
findMissingContainerNumberIssues,
|
||
isPlaceholderContainerNumber,
|
||
placementsForBookings,
|
||
type ContainerUnitForPlacement,
|
||
} from '../container-placement.util';
|
||
|
||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||
|
||
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||
function pickDefined<T extends object>(source: T): Partial<T> {
|
||
return Object.fromEntries(
|
||
Object.entries(source).filter(([, v]) => v !== undefined),
|
||
) as Partial<T>;
|
||
}
|
||
|
||
/**
|
||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||
* its display cycles from this snapshot, so a later global-rules edit never redraws
|
||
* an already-open schedule's windows. The reopen gap is derived here — doc review +
|
||
* payment — because that is the real delay between a cycle closing and reopening.
|
||
*/
|
||
function windowRuleSnapshot(cfg: BookingWindowConfig) {
|
||
return {
|
||
ruleWindowOpenHour: cfg.windowOpenHour,
|
||
ruleWindowCloseHour: cfg.windowCloseHour,
|
||
ruleWindowDurationHours: cfg.windowDurationHours,
|
||
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
|
||
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
|
||
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
|
||
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The booking-window config a specific schedule runs under: its frozen rule
|
||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||
* config, with the live config filling any snapshot field a legacy row lacks.
|
||
*
|
||
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
|
||
* the runtime cycle engine matches exactly what the board drew and the customer
|
||
* saw — a later global-rule edit must not retro-change an existing train. The
|
||
* doc-review / payment split is an internal process timing (not part of the
|
||
* window the customer sees) and is not stored split in the snapshot, so it always
|
||
* takes the live values; their sum is only used as a fallback reopen gap when the
|
||
* row predates `ruleReopenDelayMinutes`.
|
||
*/
|
||
export function effectiveWindowConfig(
|
||
schedule: {
|
||
ruleWindowOpenHour?: number | null;
|
||
ruleWindowCloseHour?: number | null;
|
||
ruleWindowDurationHours?: number | null;
|
||
ruleReopenDelayMinutes?: number | null;
|
||
rulePaymentWindowMinutes?: number | null;
|
||
ruleImportWindowLeadDays?: number | null;
|
||
ruleExportBookingLeadHours?: number | null;
|
||
ruleImportCloseOffsetMinutes?: number | null;
|
||
ruleExportCloseOffsetMinutes?: number | null;
|
||
},
|
||
liveCfg: BookingWindowConfig,
|
||
): BookingWindowConfig {
|
||
return {
|
||
importWindowLeadDays:
|
||
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
|
||
exportBookingLeadHours:
|
||
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
|
||
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
|
||
windowDurationHours:
|
||
schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: liveCfg.windowDurationHours,
|
||
docReviewMinutes: liveCfg.docReviewMinutes,
|
||
// Pay windows read live values unless staff explicitly overrode this ONE
|
||
// schedule (rule_payment_window_minutes is only ever written by that
|
||
// override, never stamped at creation). The override wins for whichever
|
||
// direction the schedule runs.
|
||
paymentWindowMinutes:
|
||
schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
|
||
exportPaymentWindowMinutes:
|
||
schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes,
|
||
// The close offset is frozen per-schedule: a snapshot value of null means
|
||
// "created with no offset" and must NOT inherit a later live offset (that
|
||
// would retro-shrink an open train's window). Only a truly legacy row that
|
||
// predates the snapshot column (value undefined) falls back to live config.
|
||
importCloseOffsetMinutes:
|
||
schedule.ruleImportCloseOffsetMinutes !== undefined
|
||
? schedule.ruleImportCloseOffsetMinutes
|
||
: liveCfg.importCloseOffsetMinutes,
|
||
exportCloseOffsetMinutes:
|
||
schedule.ruleExportCloseOffsetMinutes !== undefined
|
||
? schedule.ruleExportCloseOffsetMinutes
|
||
: liveCfg.exportCloseOffsetMinutes,
|
||
};
|
||
}
|
||
|
||
export type BookingWagonAllocationStatus =
|
||
| 'NOT_ATTEMPTED'
|
||
| 'ASSIGNED'
|
||
| 'DEFERRED'
|
||
| 'FAILED';
|
||
|
||
export interface BookingWagonAllocationIssue {
|
||
bookingId: string;
|
||
status: BookingWagonAllocationStatus;
|
||
issue: string | null;
|
||
}
|
||
|
||
export interface WagonAllocationAttemptResult {
|
||
assignedBookingIds: string[];
|
||
deferred: DeferredBookingRow[];
|
||
issues: BookingWagonAllocationIssue[];
|
||
violations: string[];
|
||
}
|
||
|
||
export interface CompositionUnassignedBookingRow {
|
||
id: string;
|
||
reference: string | null;
|
||
freightType: string | null;
|
||
priorityScore: number;
|
||
cargoTotalWeightVgm: number;
|
||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||
grossWeightTons: number;
|
||
status: string | null;
|
||
schedulingStatus: string | null;
|
||
wagonsRequired: number;
|
||
requiredWagonTypeCode: string;
|
||
yardWagonsAvailable: number;
|
||
canAssign: boolean;
|
||
blockReason: string | null;
|
||
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
|
||
shortage: BookingWagonShortage | null;
|
||
}
|
||
|
||
export interface UnassignedBookingsResponse {
|
||
fleetAtOrigin: FleetAvailabilityRow[];
|
||
bookings: CompositionUnassignedBookingRow[];
|
||
}
|
||
|
||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||
maxWeightTons: 3500,
|
||
maxLengthMeters: 760,
|
||
maxWagonsPerTrain: Math.floor(760 / 14),
|
||
max20ftContainerWeightTons: 30,
|
||
max20ftPairWeightDiffTons: 10,
|
||
};
|
||
|
||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||
interface BookingWindowRow {
|
||
schedule_id: string;
|
||
reference: string | null;
|
||
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
|
||
train_number: string | null;
|
||
contract_id: string | null;
|
||
contract_kind: string | null;
|
||
direction: string | null;
|
||
window_phase: string | null;
|
||
window_opens_at: Date | null;
|
||
window_closes_at: Date | null;
|
||
doc_review_ends_at: Date | null;
|
||
payment_phase_ends_at: Date | null;
|
||
booking_window_status: string;
|
||
booking_cycle_no: number;
|
||
scheduled_departure_date: Date;
|
||
origin_label: string | null;
|
||
origin_code: string | null;
|
||
destination_label: string | null;
|
||
destination_code: string | null;
|
||
/** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
|
||
route_stations: string[] | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class TrainSchedulingService {
|
||
private readonly logger = new Logger(TrainSchedulingService.name);
|
||
|
||
constructor(
|
||
@InjectDataSource()
|
||
private readonly dataSource: DataSource,
|
||
private readonly bookingsRepository: BookingsRepository,
|
||
private readonly locomotivesRepository: LocomotivesRepository,
|
||
// Kept in the DI signature for constructor-arity stability (specs mock it);
|
||
// wagon-type resolution now flows through the config lists on the bookings.
|
||
_wagonTypesRepository: WagonTypesRepository,
|
||
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
|
||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||
private readonly bookingJourneyService: BookingJourneyService,
|
||
private readonly bookingNotifier: BookingNotifierService,
|
||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||
private readonly configService?: ConfigService,
|
||
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||
// existing specs that construct the service without it keep working.
|
||
@Optional()
|
||
@Inject(forwardRef(() => BookingBatchService))
|
||
private readonly bookingBatchService?: BookingBatchService,
|
||
) {}
|
||
|
||
/**
|
||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||
*/
|
||
private async notifyScheduleBookings(
|
||
schedule: TrainSchedule,
|
||
event: 'dispatched' | 'arrived',
|
||
): Promise<void> {
|
||
try {
|
||
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||
if (!ids.length) return;
|
||
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||
const destination =
|
||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||
where: { id: In(ids) },
|
||
relations: { company: true },
|
||
});
|
||
for (const b of bookings) {
|
||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||
else this.bookingNotifier.arrived(b, origin, destination);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Complete customer-tracking clearance milestones for every booking on a
|
||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
|
||
* bookings without milestone rows (non-customs bookings), so this is safe to
|
||
* call for every direction and flow. Never blocks the operational action.
|
||
*/
|
||
private async completeMilestonesForScheduleBookings(
|
||
scheduleId: string,
|
||
codes: string[],
|
||
filter?: { originYardId?: string; destinationYardId?: string },
|
||
): Promise<void> {
|
||
if (!this.milestoneService || codes.length === 0) return;
|
||
try {
|
||
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
|
||
const params: unknown[] = [scheduleId];
|
||
if (filter?.originYardId) {
|
||
params.push(filter.originYardId);
|
||
conditions.push(`b.origin_yard_id = $${params.length}`);
|
||
}
|
||
if (filter?.destinationYardId) {
|
||
params.push(filter.destinationYardId);
|
||
conditions.push(`b.destination_yard_id = $${params.length}`);
|
||
}
|
||
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||
`SELECT tsb.booking_id
|
||
FROM freight.train_schedule_bookings tsb
|
||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||
WHERE ${conditions.join(' AND ')}`,
|
||
params,
|
||
);
|
||
for (const { booking_id } of rows) {
|
||
for (const code of codes) {
|
||
await this.milestoneService.completeByDocTrigger(
|
||
{ bookingId: booking_id },
|
||
code,
|
||
);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Push a schedule's current booking-window state over the socket so the
|
||
* portal home card and backoffice GL/batch views update in real time —
|
||
* used for lifecycle changes outside the window tick (create, cancel,
|
||
* finalize, restamp). A push failure must never break the mutation.
|
||
*/
|
||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||
try {
|
||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
|
||
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
|
||
* group together, since the route entity keys only origin + destination). All
|
||
* schedules in a group must run ONE shared booking-window timeline so a
|
||
* customer booking on a later-created train is never expired by a sibling
|
||
* train's payment window closing on a different clock.
|
||
*
|
||
* Grouping keys off the columns the schedule already carries — no new schema.
|
||
* Callers pass a live `manager` so both the create (inside its transaction) and
|
||
* the update paths see uncommitted siblings.
|
||
*/
|
||
private async findGroupSiblings(
|
||
manager: EntityManager,
|
||
originStationId: string,
|
||
destinationStationId: string,
|
||
departure: Date,
|
||
excludeScheduleId?: string,
|
||
): Promise<TrainSchedule[]> {
|
||
const day = eatDay(departure);
|
||
const dayStart = eatDayToUtc(day, 0);
|
||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||
const qb = manager
|
||
.getRepository(TrainSchedule)
|
||
.createQueryBuilder('s')
|
||
.where('s.originStationId = :originStationId', { originStationId })
|
||
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||
// A cancelled train is not a sibling: cancel retires its window as DONE,
|
||
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||
.andWhere('s.status != :cancelledStatus', {
|
||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||
});
|
||
if (excludeScheduleId) {
|
||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||
}
|
||
return qb.getMany();
|
||
}
|
||
|
||
/**
|
||
* A built train makes at most ONE departure per route per EAT day. Returns
|
||
* the non-cancelled schedule already holding this train on this route for
|
||
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
|
||
* legal — siblings must be different trains.
|
||
*/
|
||
private async findTrainRouteDayConflict(
|
||
trainId: string,
|
||
routeId: string,
|
||
departure: Date,
|
||
excludeScheduleId?: string,
|
||
): Promise<TrainSchedule | null> {
|
||
const day = eatDay(departure);
|
||
const dayStart = eatDayToUtc(day, 0);
|
||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||
const qb = this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.createQueryBuilder('s')
|
||
.innerJoin('s.trainSet', 'ts')
|
||
.where('ts.trainId = :trainId', { trainId })
|
||
.andWhere('s.routeId = :routeId', { routeId })
|
||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||
.andWhere('s.status != :cancelledStatus', {
|
||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||
});
|
||
if (excludeScheduleId) {
|
||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||
}
|
||
return qb.getOne();
|
||
}
|
||
|
||
/**
|
||
* The window timeline a brand-new schedule must adopt to join its route+day
|
||
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||
* existing sibling, or null when this is the first schedule in the group (the
|
||
* caller then computes its own times as before — nothing changes for the
|
||
* single-schedule case).
|
||
*
|
||
* The anchor is the sibling that best represents where the GROUP currently is
|
||
* on its shared clock, so a train created mid-cycle joins the group AT its
|
||
* current phase (with the group's exact doc-review / payment deadlines) instead
|
||
* of restarting the whole cycle on its own `now`. When the group has advanced
|
||
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
|
||
* the phase the joiner must adopt to see the same "N minutes left" the group
|
||
* already shows. When every sibling is still PRE_WINDOW we pick the
|
||
* earliest-opening one (the group's frozen open clock).
|
||
*/
|
||
private async findGroupWindowAnchor(
|
||
manager: EntityManager,
|
||
originStationId: string,
|
||
destinationStationId: string,
|
||
departure: Date,
|
||
): Promise<TrainSchedule | null> {
|
||
const siblings = await this.findGroupSiblings(
|
||
manager,
|
||
originStationId,
|
||
destinationStationId,
|
||
departure,
|
||
);
|
||
if (siblings.length === 0) return null;
|
||
// A DONE window is retired (the day's last cycle already ran) — anchoring
|
||
// to it would hand the newborn a dead window no tick ever advances. With no
|
||
// live or pending sibling left, fall back to fresh times (return null).
|
||
const withWindow = siblings.filter(
|
||
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
|
||
);
|
||
if (withWindow.length === 0) return null;
|
||
|
||
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
|
||
// not yet DONE) — the joiner must land in that same phase on the same clock.
|
||
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
|
||
const live = withWindow.filter(
|
||
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
|
||
s.windowPhase !== 'PRE_WINDOW',
|
||
);
|
||
if (live.length > 0) {
|
||
// Most-advanced phase leads; ties broken by earliest open for determinism.
|
||
return live.reduce((best, s) => {
|
||
const a = PHASE_ORDER.indexOf(s.windowPhase!);
|
||
const b = PHASE_ORDER.indexOf(best.windowPhase!);
|
||
if (a !== b) return a > b ? s : best;
|
||
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
|
||
});
|
||
}
|
||
|
||
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
|
||
// defines the group clock (the one a customer would have seen first).
|
||
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
|
||
const pool = pending.length > 0 ? pending : withWindow;
|
||
return pool.reduce((earliest, s) =>
|
||
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The full window state an anchor sibling hands down to a schedule JOINING its
|
||
* group. Copies not just the open/close times + frozen rule snapshot but the
|
||
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
|
||
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
|
||
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
|
||
* the group: it enters directly at the group's current phase with the group's
|
||
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
|
||
* `now` and stamping its own later `paymentPhaseEndsAt`.
|
||
*
|
||
* `targetDeparture` is the JOINING schedule's own departure: every time is
|
||
* clamped to it so a group whose trains depart at different times of the same
|
||
* day never hands an earlier-departing train a deadline that outlives its
|
||
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
|
||
* preserves that invariant when the anchor departed later).
|
||
*/
|
||
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
|
||
const cap = targetDeparture.getTime();
|
||
const clamp = (d: Date | null | undefined): Date | null =>
|
||
d == null ? null : d.getTime() > cap ? targetDeparture : d;
|
||
return {
|
||
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
|
||
windowPhase: anchor.windowPhase,
|
||
bookingWindowStatus:
|
||
anchor.bookingWindowStatus === 'FULL'
|
||
? 'OPEN'
|
||
: anchor.bookingWindowStatus,
|
||
bookingCycleNo: anchor.bookingCycleNo,
|
||
windowOpensAt: clamp(anchor.windowOpensAt),
|
||
windowClosesAt: clamp(anchor.windowClosesAt),
|
||
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
|
||
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
|
||
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
|
||
// Frozen rule snapshot.
|
||
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
|
||
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
|
||
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
|
||
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
|
||
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
|
||
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
|
||
};
|
||
}
|
||
|
||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||
// resolving the schedule's route + day and filtering on the day instead.
|
||
let day: string | undefined;
|
||
let originStationId = query.originStationId;
|
||
let destinationStationId = query.destinationStationId;
|
||
// Corridor mode: a schedule with intermediate stops pools every booking
|
||
// whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just
|
||
// exact endpoint matches — otherwise a mid-corridor booking unassigned from
|
||
// a wagon vanishes from the "Paid · unassigned" pool forever.
|
||
let corridorStops: string[] | undefined;
|
||
if (query.trainScheduleId) {
|
||
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||
if (schedule?.scheduledDepartureDate) {
|
||
day = eatDay(schedule.scheduledDepartureDate);
|
||
originStationId = originStationId ?? schedule.originStationId;
|
||
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||
const stops = await this.stopYardsForSchedule(schedule);
|
||
if (stops.length > 2) corridorStops = stops;
|
||
}
|
||
}
|
||
|
||
let bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||
freightType: query.freightType,
|
||
originStationId,
|
||
destinationStationId,
|
||
schedulingStatus: query.schedulingStatus,
|
||
trainScheduleId: query.trainScheduleId,
|
||
day,
|
||
corridorYardIds: corridorStops,
|
||
});
|
||
if (corridorStops) {
|
||
// The IN-filter admits both yards anywhere on the route; only origin
|
||
// strictly before destination is actually rideable on this train.
|
||
const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i]));
|
||
bookings = bookings.filter((b) => {
|
||
const from = stopIdx.get(b.originYardId);
|
||
const to = stopIdx.get(b.destinationYardId);
|
||
return from != null && to != null && from < to;
|
||
});
|
||
}
|
||
const tareDims = await this.loadWagonTareDims();
|
||
return {
|
||
count: bookings.length,
|
||
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
|
||
};
|
||
}
|
||
|
||
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
||
return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
|
||
}
|
||
|
||
async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
|
||
return this.getEligibleBookings({ ...query, freightType: 'BULK' });
|
||
}
|
||
|
||
async getTrainSchedulingGlobalRules() {
|
||
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
|
||
}
|
||
|
||
/**
|
||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
||
* planning still reads them off the row); they are no longer exposed or
|
||
* editable through the global-rules endpoints.
|
||
*/
|
||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||
if (!row) return row;
|
||
const {
|
||
maxTrainLengthMeters: _len,
|
||
maxTrainWeightTons: _wt,
|
||
max20ftContainerWeightTons: _cw,
|
||
max20ftPairWeightDiffTons: _pd,
|
||
...pub
|
||
} = row;
|
||
return pub;
|
||
}
|
||
|
||
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||
const row = await this.loadGlobalRulesRow();
|
||
if (!row) {
|
||
throw new NotFoundException('Train scheduling global rules not configured');
|
||
}
|
||
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
|
||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||
if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
|
||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||
if (dto.exportPaymentWindowMinutes != null)
|
||
row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes;
|
||
// Store 0 as null so "no offset" is a single canonical value.
|
||
if (dto.importCloseOffsetMinutes !== undefined)
|
||
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
|
||
if (dto.exportCloseOffsetMinutes !== undefined)
|
||
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
|
||
|
||
// The booking desk supports three shapes: a same-day range
|
||
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
|
||
// overnight range that wraps past midnight (openHour > closeHour, e.g.
|
||
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
|
||
|
||
// Fields that change the STAMPED open/close times of a schedule. docReview/
|
||
// payment/reopen are read live by the cron each tick, so they need no
|
||
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
|
||
const windowTimingChanged =
|
||
dto.importWindowLeadDays != null ||
|
||
dto.windowOpenHour != null ||
|
||
dto.windowCloseHour != null ||
|
||
dto.windowDurationHours != null ||
|
||
dto.docReviewMinutes != null ||
|
||
dto.paymentWindowMinutes != null ||
|
||
dto.exportBookingLeadHours != null ||
|
||
dto.importCloseOffsetMinutes !== undefined ||
|
||
dto.exportCloseOffsetMinutes !== undefined;
|
||
|
||
const saved = await this.dataSource
|
||
.getRepository(TrainSchedulingGlobalRules)
|
||
.save(row);
|
||
|
||
// The cron reads config fresh every tick, so derived timings (doc review,
|
||
// payment, reopen) take effect on the next tick with no restart. But each
|
||
// schedule's initial open/close times were FROZEN at creation — re-stamp the
|
||
// ones whose window has not opened yet so a config edit applies to them too.
|
||
if (windowTimingChanged) {
|
||
await this.restampPendingWindows();
|
||
}
|
||
|
||
return this.toPublicGlobalRules(saved);
|
||
}
|
||
|
||
/**
|
||
* Override the booking-window rule for ONE schedule (staff action on the ops
|
||
* board). Only the fields provided are changed; the rest keep the schedule's
|
||
* existing snapshot (falling back to the live global config for legacy rows).
|
||
* The window must not have opened yet — an OPEN/past schedule stays frozen so
|
||
* customers keep the times they were shown. windowOpensAt/ClosesAt are
|
||
* re-derived from the merged rule, and the snapshot is updated so the board
|
||
* draws the new cycles.
|
||
*/
|
||
async updateScheduleWindowRule(
|
||
id: string,
|
||
dto: UpdateScheduleWindowRuleDto,
|
||
): Promise<TrainSchedule> {
|
||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
if (schedule.windowPhase !== 'PRE_WINDOW') {
|
||
throw new BadRequestException(
|
||
'Booking window settings can only be changed before the window opens ' +
|
||
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
|
||
);
|
||
}
|
||
const now = new Date();
|
||
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
|
||
throw new BadRequestException(
|
||
'This schedule has already departed or has no departure date.',
|
||
);
|
||
}
|
||
|
||
// Merge the override onto the schedule's current effective rule (its snapshot,
|
||
// or the live config where a legacy row has no snapshot).
|
||
const liveCfg = await this.getWindowConfig();
|
||
const merged: BookingWindowConfig = {
|
||
importWindowLeadDays:
|
||
dto.importWindowLeadDays ??
|
||
schedule.ruleImportWindowLeadDays ??
|
||
liveCfg.importWindowLeadDays,
|
||
exportBookingLeadHours:
|
||
dto.exportBookingLeadHours ??
|
||
schedule.ruleExportBookingLeadHours ??
|
||
liveCfg.exportBookingLeadHours,
|
||
windowOpenHour:
|
||
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||
windowCloseHour:
|
||
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
|
||
windowDurationHours:
|
||
dto.windowDurationHours ??
|
||
(schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: liveCfg.windowDurationHours),
|
||
// The reopen gap is doc review + payment; keep the config values unless the
|
||
// override changes them, so the derived snapshot delay stays consistent.
|
||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
||
paymentWindowMinutes:
|
||
dto.paymentWindowMinutes ??
|
||
schedule.rulePaymentWindowMinutes ??
|
||
liveCfg.paymentWindowMinutes,
|
||
exportPaymentWindowMinutes:
|
||
dto.paymentWindowMinutes ??
|
||
schedule.rulePaymentWindowMinutes ??
|
||
liveCfg.exportPaymentWindowMinutes,
|
||
// A per-schedule override isn't a close-offset control, so inherit the
|
||
// offset already frozen on the schedule (null = none), or the live one for
|
||
// legacy rows — the override must not silently drop the global offset.
|
||
importCloseOffsetMinutes:
|
||
schedule.ruleImportCloseOffsetMinutes !== undefined
|
||
? schedule.ruleImportCloseOffsetMinutes
|
||
: liveCfg.importCloseOffsetMinutes,
|
||
exportCloseOffsetMinutes:
|
||
schedule.ruleExportCloseOffsetMinutes !== undefined
|
||
? schedule.ruleExportCloseOffsetMinutes
|
||
: liveCfg.exportCloseOffsetMinutes,
|
||
};
|
||
|
||
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
|
||
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
|
||
|
||
const times =
|
||
schedule.direction === 'EXPORT'
|
||
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
||
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
||
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
|
||
throw new BadRequestException(
|
||
'These settings leave no booking window before departure — with the ' +
|
||
'desk hours applied, the window would only open once the train has left.',
|
||
);
|
||
}
|
||
|
||
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
|
||
// WHOLE group — every schedule sharing this origin + destination + EAT
|
||
// departure day. They all adopt the SAME rule snapshot and share the SAME
|
||
// window open/close timeline (the whole point of grouping). The shared times
|
||
// are clamped to each train's OWN departure so a group whose trains depart at
|
||
// different times of the same day never hands an earlier-departing sibling a
|
||
// window that outlives its departure. Only still-PRE_WINDOW siblings are
|
||
// touched — a sibling that has already opened, finalized, or dispatched stays
|
||
// frozen on the times its customers were shown and simply drops out of the
|
||
// group; the remaining pending trains stay in sync. EXPORT is excluded
|
||
// (departure-anchored FCFS window, no cross-expiry), so an export override
|
||
// only touches its own schedule.
|
||
const ruleFields = windowRuleSnapshot(merged);
|
||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||
const cap = (d: Date, departure: Date): Date =>
|
||
d.getTime() > departure.getTime() ? departure : d;
|
||
|
||
const targets: Array<{ id: string; departure: Date }> = [
|
||
{ id, departure: schedule.scheduledDepartureDate },
|
||
];
|
||
if (schedule.direction !== 'EXPORT') {
|
||
const siblings = await this.findGroupSiblings(
|
||
this.dataSource.manager,
|
||
schedule.originStationId,
|
||
schedule.destinationStationId,
|
||
schedule.scheduledDepartureDate,
|
||
id,
|
||
);
|
||
for (const sib of siblings) {
|
||
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
|
||
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
|
||
}
|
||
}
|
||
}
|
||
|
||
// The pay-window override persists only when staff actually sent it (or the
|
||
// schedule already had one) — windowRuleSnapshot never stamps it, so NULL
|
||
// keeps meaning "follow the live global value for my direction".
|
||
const rulePaymentWindowMinutes =
|
||
dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null;
|
||
for (const t of targets) {
|
||
await repo.update(t.id, {
|
||
windowOpensAt: cap(times.windowOpensAt, t.departure),
|
||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||
...ruleFields,
|
||
rulePaymentWindowMinutes,
|
||
// Deliberately overridden — exempt from the global re-stamp, which would
|
||
// otherwise revert this schedule the next time global rules are saved.
|
||
windowRuleCustom: true,
|
||
});
|
||
}
|
||
this.logger.log(
|
||
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
|
||
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
|
||
);
|
||
for (const t of targets) void this.emitWindowState(t.id);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Correct a departure's operational run identifiers — the train number and
|
||
* voyage number yards and customs quote.
|
||
*
|
||
* Editable only until the train leaves: once DISPATCHED (or beyond) the
|
||
* numbers are printed on paperwork and quoted downstream, so a late edit would
|
||
* desync records that already left with the train. The audit row is written by
|
||
* the global AuditInterceptor from the registered route.
|
||
*/
|
||
async updateScheduleTrainNumber(
|
||
id: string,
|
||
dto: UpdateScheduleTrainNumberDto,
|
||
): Promise<TrainSchedule> {
|
||
if (dto.trainNumber === undefined && dto.voyageNumber === undefined) {
|
||
throw new BadRequestException(
|
||
'Provide a train number or a voyage number to update.',
|
||
);
|
||
}
|
||
|
||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
|
||
// Only a train that has not left can be renumbered. CANCELLED is excluded
|
||
// too — renumbering a dead schedule has no meaning.
|
||
const editable: string[] = [
|
||
TrainScheduleStatusEnum.Draft,
|
||
TrainScheduleStatusEnum.Scheduled,
|
||
];
|
||
if (!editable.includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot change the train or voyage number of a ${schedule.status} schedule — ` +
|
||
'the numbers are fixed once the train is dispatched.',
|
||
);
|
||
}
|
||
|
||
// An empty string clears the field; an omitted field is left untouched.
|
||
const patch: Partial<TrainSchedule> = {};
|
||
if (dto.trainNumber !== undefined) {
|
||
patch.trainNumber = dto.trainNumber.trim() || null;
|
||
}
|
||
if (dto.voyageNumber !== undefined) {
|
||
patch.voyageNumber = dto.voyageNumber.trim() || null;
|
||
}
|
||
|
||
await this.trainSchedulesRepository.update(id, patch);
|
||
this.logger.log(
|
||
`Schedule ${schedule.reference ?? id} renumbered` +
|
||
(patch.trainNumber !== undefined
|
||
? ` — train ${schedule.trainNumber ?? '—'} → ${patch.trainNumber ?? '—'}`
|
||
: '') +
|
||
(patch.voyageNumber !== undefined
|
||
? ` — voyage ${schedule.voyageNumber ?? '—'} → ${patch.voyageNumber ?? '—'}`
|
||
: '') +
|
||
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
|
||
);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Reschedule ONE train's departure date (staff action on the ops board). Only
|
||
* allowed while the booking window has not opened yet — an OPEN/past schedule
|
||
* stays frozen so customers keep the times they were shown. The new date must
|
||
* still leave room for the booking lead window before departure (same floor as
|
||
* schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
|
||
* open/close times are re-derived from the schedule's existing rule snapshot.
|
||
*/
|
||
async updateScheduleDate(
|
||
id: string,
|
||
dto: UpdateScheduleDateDto,
|
||
): Promise<TrainSchedule> {
|
||
const schedule = await this.trainSchedulesRepository.findById(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
if (schedule.windowPhase !== 'PRE_WINDOW') {
|
||
throw new BadRequestException(
|
||
'The departure date can only be changed before the booking window opens ' +
|
||
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
const departure = new Date(dto.scheduleDate);
|
||
if (Number.isNaN(departure.getTime())) {
|
||
throw new BadRequestException('Invalid departure date.');
|
||
}
|
||
|
||
// Staff cannot schedule inside the lead window — there must be room for a
|
||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days;
|
||
// EXPORT lead is in hours. Mirrors the create-schedule check.
|
||
const windowCfg = await this.getWindowConfig();
|
||
const earliest = earliestSchedulableDeparture(
|
||
schedule.direction,
|
||
windowCfg,
|
||
now,
|
||
);
|
||
if (departure.getTime() < earliest.getTime()) {
|
||
const detail =
|
||
schedule.direction === 'EXPORT'
|
||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||
throw new BadRequestException(
|
||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||
`${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||
`(earliest ${earliest.toISOString()}).`,
|
||
);
|
||
}
|
||
|
||
// Moving onto a day where this same built train already runs this route
|
||
// would double-book the physical train — blocked for planning moves.
|
||
if (schedule.trainSetId && schedule.routeId) {
|
||
const trainSet = await this.dataSource
|
||
.getRepository(TrainSet)
|
||
.findOne({ where: { id: schedule.trainSetId } });
|
||
if (trainSet?.trainId) {
|
||
const conflict = await this.findTrainRouteDayConflict(
|
||
trainSet.trainId,
|
||
schedule.routeId,
|
||
departure,
|
||
id,
|
||
);
|
||
if (conflict) {
|
||
throw new ConflictException(
|
||
`This train is already scheduled on this route for that day ` +
|
||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
||
// the live config where a legacy row has no snapshot) against the new date.
|
||
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||
|
||
const times =
|
||
schedule.direction === 'EXPORT'
|
||
? computeExportWindowTimes(departure, merged)
|
||
: computeImportWindowTimes(departure, merged, now);
|
||
|
||
// Moving the departure moves this train between route+day GROUPS. If the
|
||
// destination day already has a group (a sibling on the same origin +
|
||
// destination + new EAT day), adopt that group's shared timeline instead of
|
||
// the times just derived, so the rescheduled train lines up with the group
|
||
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
|
||
// own re-derived times and becomes the anchor for that day. EXPORT is
|
||
// excluded — its window is anchored to its own departure, not shared.
|
||
const anchor =
|
||
schedule.direction === 'EXPORT'
|
||
? null
|
||
: await this.findGroupWindowAnchor(
|
||
this.dataSource.manager,
|
||
schedule.originStationId,
|
||
schedule.destinationStationId,
|
||
departure,
|
||
);
|
||
const windowFields = anchor
|
||
? this.groupWindowFieldsFrom(anchor, departure)
|
||
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
|
||
|
||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||
scheduledDepartureDate: departure,
|
||
...windowFields,
|
||
});
|
||
|
||
// Only customers whose bookings already HOLD wagons on this train are told
|
||
// about the move (SMS + email + portal inbox). Linked-but-unallocated
|
||
// bookings are skipped — nothing of theirs is riding this departure yet.
|
||
let notifiedCount = 0;
|
||
if (schedule.trainSetId) {
|
||
const allocations = await this.dataSource
|
||
.getRepository(WagonBookingAllocation)
|
||
.createQueryBuilder('a')
|
||
.innerJoin('a.trainSetWagon', 'slot')
|
||
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
|
||
.getMany();
|
||
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
|
||
if (allocatedBookingIds.length) {
|
||
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
|
||
where: { id: In(allocatedBookingIds) },
|
||
relations: { company: true },
|
||
});
|
||
for (const booking of allocatedBookings) {
|
||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||
this.bookingNotifier.rescheduled(booking, departure);
|
||
notifiedCount += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
this.logger.log(
|
||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
|
||
`${notifiedCount} allocated customer booking(s) notified`,
|
||
);
|
||
void this.emitWindowState(id);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||
* phase and inside the booking lead window — a maintenance move is an
|
||
* operational fact, not a planning choice. What moves and what stays:
|
||
*
|
||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||
* so a booking left on the old day would fall out of its own train's pool).
|
||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||
* schedule mid- or post-window keeps its timeline untouched.
|
||
*
|
||
* Customers of every moved booking are notified (maintenanceMoved).
|
||
*/
|
||
async maintenanceReschedule(
|
||
id: string,
|
||
dto: MaintenanceRescheduleDto,
|
||
): Promise<TrainSchedule> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||
);
|
||
}
|
||
|
||
const departure = new Date(dto.newDepartureDate);
|
||
if (Number.isNaN(departure.getTime())) {
|
||
throw new BadRequestException('Invalid departure date.');
|
||
}
|
||
if (departure.getTime() <= Date.now()) {
|
||
throw new BadRequestException('New departure must be in the future.');
|
||
}
|
||
|
||
const deltaMs =
|
||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||
: undefined;
|
||
|
||
// PRE_WINDOW: the stamped open/close were derived from the old departure
|
||
// and the window hasn't opened yet, so re-derive them from the schedule's
|
||
// own rule snapshot against the new date (joining the target day's route
|
||
// group timeline when one exists, exactly like updateScheduleDate).
|
||
//
|
||
// DONE: the window already finished (e.g. the close offset hit and then the
|
||
// train was moved to a later departure). The window must follow the new
|
||
// departure, so it REOPENS: re-derive open/close the same way, reset the
|
||
// phase to PRE_WINDOW and clamp a past open into the present so the tick
|
||
// opens it immediately. A FULL train stays closed — there is nothing left
|
||
// to sell — and so does one whose re-derived window would already be over.
|
||
//
|
||
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
|
||
const reopenFromDone =
|
||
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
|
||
const windowFields =
|
||
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
|
||
? await (async () => {
|
||
const merged = effectiveWindowConfig(
|
||
schedule,
|
||
await this.getWindowConfig(),
|
||
);
|
||
const times =
|
||
schedule.direction === 'EXPORT'
|
||
? computeExportWindowTimes(departure, merged)
|
||
: computeImportWindowTimes(departure, merged, new Date());
|
||
const anchor =
|
||
schedule.direction === 'EXPORT'
|
||
? null
|
||
: await this.findGroupWindowAnchor(
|
||
this.dataSource.manager,
|
||
schedule.originStationId,
|
||
schedule.destinationStationId,
|
||
departure,
|
||
);
|
||
if (anchor) {
|
||
// groupWindowFieldsFrom copies the anchor's live phase and
|
||
// deadlines, so a DONE train joining a live group re-enters the
|
||
// group's cycle directly — no extra reset needed.
|
||
return this.groupWindowFieldsFrom(anchor, departure);
|
||
}
|
||
if (!reopenFromDone) {
|
||
return {
|
||
windowOpensAt: times.windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
};
|
||
}
|
||
const now = new Date();
|
||
const windowOpensAt =
|
||
times.windowOpensAt < now ? now : times.windowOpensAt;
|
||
if (times.windowClosesAt.getTime() <= windowOpensAt.getTime()) {
|
||
return {}; // no window fits before the new departure — stay closed
|
||
}
|
||
return {
|
||
windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
windowPhase: 'PRE_WINDOW',
|
||
bookingWindowStatus: 'CLOSED',
|
||
docReviewCompletedAt: null,
|
||
docReviewEndsAt: null,
|
||
paymentPhaseEndsAt: null,
|
||
};
|
||
})()
|
||
: {};
|
||
|
||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||
scheduledDepartureDate: departure,
|
||
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
|
||
...windowFields,
|
||
});
|
||
|
||
// Everything aboard or targeted rides along: bookings linked on the train
|
||
// (schedule_bookings) plus reservations still pointing at it via
|
||
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
|
||
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
|
||
const targeted = await this.dataSource.getRepository(Booking).find({
|
||
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
|
||
relations: { company: true },
|
||
});
|
||
const aboard = targeted.filter(
|
||
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
|
||
);
|
||
if (aboard.length) {
|
||
await this.dataSource
|
||
.getRepository(Booking)
|
||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||
for (const booking of aboard) {
|
||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||
}
|
||
}
|
||
|
||
this.logger.log(
|
||
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
|
||
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
|
||
`${aboard.length} booking(s) moved with the train.`,
|
||
);
|
||
void this.emitWindowState(id);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||
return fresh ?? schedule;
|
||
}
|
||
|
||
/**
|
||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
|
||
* past their window are left untouched — customers may have booked against the
|
||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||
*/
|
||
async restampPendingWindows(): Promise<number> {
|
||
const cfg = await this.getWindowConfig();
|
||
const now = new Date();
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
|
||
],
|
||
});
|
||
|
||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||
let restamped = 0;
|
||
for (const s of schedules) {
|
||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||
// Hand-configured windows are not "pending the global rule" — staff picked
|
||
// these times deliberately, so a global-rules edit must leave them alone.
|
||
if (s.windowRuleCustom) continue;
|
||
const times =
|
||
s.direction === 'EXPORT'
|
||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
|
||
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
|
||
// snapshot alongside the re-stamped times — the board then draws the new
|
||
// window from this same rule.
|
||
await repo.update(s.id, {
|
||
windowOpensAt: times.windowOpensAt,
|
||
windowClosesAt: times.windowClosesAt,
|
||
...windowRuleSnapshot(cfg),
|
||
});
|
||
restamped += 1;
|
||
// New times take effect immediately on every card (the tick then opens
|
||
// the window within seconds if the re-derived open is already due).
|
||
void this.emitWindowState(s.id);
|
||
}
|
||
if (restamped > 0) {
|
||
this.logger.log(
|
||
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
|
||
);
|
||
}
|
||
return restamped;
|
||
}
|
||
|
||
/**
|
||
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
|
||
* Numeric columns come back from pg as strings — normalize every field.
|
||
*/
|
||
async getWindowConfig(): Promise<BookingWindowConfig> {
|
||
const row = await this.loadGlobalRulesRow();
|
||
const num = (v: unknown, fallback: number) => {
|
||
const n = v == null ? NaN : Number(v);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
};
|
||
// Offsets are optional: a missing/unset value means "no offset", not a
|
||
// numeric default — keep it null so bookingCloseCutoff falls back to
|
||
// departure. Zero and negatives are treated as "no offset" too.
|
||
const offset = (v: unknown): number | null => {
|
||
const n = v == null ? NaN : Number(v);
|
||
return Number.isFinite(n) && n > 0 ? n : null;
|
||
};
|
||
return {
|
||
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||
windowOpenHour: num(row?.windowOpenHour, 8),
|
||
windowCloseHour: num(row?.windowCloseHour, 17),
|
||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||
exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60),
|
||
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
|
||
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Limits for a preview aimed at an EXISTING schedule must be the schedule's
|
||
* own: its locomotive set and its built-consist wagon cap. Resolving from
|
||
* the dto alone re-derived the global wagon cap (53) and rejected a
|
||
* physically-coupled 54-wagon train the assign path would accept.
|
||
*/
|
||
private async resolvePreviewLimitConfig(dto: {
|
||
targetScheduleId?: string;
|
||
maxTrainWeightTons?: number;
|
||
maxTrainLengthMeters?: number;
|
||
maxWagonsPerTrain?: number;
|
||
}): Promise<Required<TrainLimitConfig>> {
|
||
const target = dto.targetScheduleId
|
||
? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId)
|
||
: null;
|
||
if (!target) return this.resolveTrainLimitConfig(dto);
|
||
return this.resolveTrainLimitConfig(
|
||
dto,
|
||
combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)),
|
||
target.maxWagons ?? undefined,
|
||
);
|
||
}
|
||
|
||
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
||
const limits = await this.resolvePreviewLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
||
const limits = await this.resolvePreviewLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
'CONTAINER',
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
|
||
const limits = await this.resolvePreviewLimitConfig(dto);
|
||
return this.buildPreviewResponse(
|
||
await this.validateBookingsForScheduling(
|
||
dto,
|
||
'BULK',
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
dto.targetScheduleId,
|
||
),
|
||
);
|
||
}
|
||
|
||
private buildPreviewResponse(validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>) {
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
return {
|
||
valid: validation.valid,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
summary: validation.summary,
|
||
fleetAvailability: validation.fleetAvailability,
|
||
deferredBookings: validation.deferredBookings,
|
||
bookingIds: validation.bookings.map((b) => b.id),
|
||
wagonPlan: validation.wagonPlan,
|
||
containerUnits: containerBookings.length
|
||
? expandBookingContainerUnits(containerBookings)
|
||
: [],
|
||
containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan),
|
||
};
|
||
}
|
||
|
||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||
const route = await this.getSchedulableRoute(dto.routeId);
|
||
|
||
const scheduleWarnings: string[] = [];
|
||
|
||
// The pulling set comes either from a built train (Train Builder) or from
|
||
// hand-picked locomotive ids (legacy path). A built train also links the
|
||
// schedule's train set back to it (`train_sets.train_id`) so its lifecycle
|
||
// and yard follow the schedule.
|
||
let builtTrain: Train | null = null;
|
||
let locomotiveIds: string[];
|
||
if (dto.trainId) {
|
||
builtTrain = await this.dataSource.getRepository(Train).findOne({
|
||
where: { id: dto.trainId },
|
||
relations: { locomotives: true },
|
||
order: { locomotives: { sequenceNo: 'ASC' } },
|
||
});
|
||
if (!builtTrain) {
|
||
throw new NotFoundException(`Train ${dto.trainId} not found`);
|
||
}
|
||
if (
|
||
builtTrain.status === Freight.TrainStatus.OutOfService ||
|
||
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
|
||
builtTrain.status === Freight.TrainStatus.Deactivated
|
||
) {
|
||
throw new ConflictException(
|
||
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
|
||
);
|
||
}
|
||
locomotiveIds = (builtTrain.locomotives ?? [])
|
||
.slice()
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((link) => link.locomotiveId);
|
||
if (locomotiveIds.length < 1) {
|
||
throw new BadRequestException(
|
||
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
|
||
);
|
||
}
|
||
if (builtTrain.currentYardId !== route.originYardId) {
|
||
scheduleWarnings.push(
|
||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||
);
|
||
}
|
||
const conflict = await this.findTrainRouteDayConflict(
|
||
builtTrain.id,
|
||
route.id,
|
||
new Date(dto.scheduleDate),
|
||
);
|
||
if (conflict) {
|
||
throw new ConflictException(
|
||
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
|
||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||
);
|
||
}
|
||
} else {
|
||
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
||
if (locomotiveIds.length < 1) {
|
||
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||
}
|
||
}
|
||
|
||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
|
||
// multiple future schedules and does not need to be at the origin yard yet — staff
|
||
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
|
||
// everything else surfaces as a warning.
|
||
const lockedLocomotives: Locomotive[] = [];
|
||
for (const locomotiveId of locomotiveIds) {
|
||
const locked = await manager.getRepository(Locomotive).findOne({
|
||
where: { id: locomotiveId },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!locked) {
|
||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||
}
|
||
if (locked.status === 'OUT_OF_SERVICE') {
|
||
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
|
||
}
|
||
if (locked.status !== 'AVAILABLE') {
|
||
scheduleWarnings.push(
|
||
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
|
||
);
|
||
}
|
||
if (locked.currentYardId !== route.originYardId) {
|
||
scheduleWarnings.push(
|
||
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
|
||
);
|
||
}
|
||
lockedLocomotives.push(locked);
|
||
}
|
||
|
||
// Frozen on the route at create/update from the yard-country enum;
|
||
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||
const direction = this.resolveRouteDirection(route);
|
||
|
||
// Direction-matched fixed number from the built train's typed pair.
|
||
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
|
||
// (assignTrainNumber is idempotent, so both paths compose).
|
||
const pairTrainNumber = builtTrain
|
||
? (direction === 'IMPORT'
|
||
? builtTrain.importTrainNumber
|
||
: builtTrain.exportTrainNumber) ?? null
|
||
: null;
|
||
if (builtTrain && !pairTrainNumber) {
|
||
scheduleWarnings.push(
|
||
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
|
||
);
|
||
}
|
||
|
||
const trainSet = await this.buildEmptyTrainSet(
|
||
manager,
|
||
lockedLocomotives,
|
||
builtTrain?.id ?? null,
|
||
);
|
||
// Effective capacity is capped by the weakest locomotive in the set.
|
||
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||
const departure = new Date(dto.scheduleDate);
|
||
// Every schedule starts with a CLOSED customer window; the window engine opens
|
||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||
const globalCfg = await this.getWindowConfig();
|
||
|
||
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||
// on this origin + destination + EAT departure day, this new train JOINS
|
||
// its group and adopts the group's shared window timeline (open/close +
|
||
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
|
||
// That keeps every train on the day advancing through the same open/
|
||
// doc-review/payment/close instants, so a booking on one train is never
|
||
// expired by a sibling train's payment window closing on a different clock.
|
||
// First train in the group falls through to the normal computation.
|
||
//
|
||
// EXPORT is excluded: an export window is a single FCFS window anchored to
|
||
// each train's OWN departure (windowClosesAt = departure) with no
|
||
// doc-review/payment phase — so there is no cross-expiry to fix, and two
|
||
// export trains departing the same day at different times must keep their
|
||
// own departure-anchored windows.
|
||
const groupAnchor =
|
||
direction === 'EXPORT'
|
||
? null
|
||
: await this.findGroupWindowAnchor(
|
||
manager,
|
||
route.originYardId,
|
||
route.destinationYardId,
|
||
departure,
|
||
);
|
||
|
||
// Per-schedule window rule chosen at creation. Refused for a train that
|
||
// JOINS an existing route+day group: the group shares ONE window timeline,
|
||
// so a joining train adopts the anchor's times verbatim and its own
|
||
// settings would be silently discarded. Staff edit the group's window
|
||
// instead (Booking window settings, which fans out to every sibling).
|
||
if (dto.windowRule && groupAnchor) {
|
||
throw new BadRequestException(
|
||
'This train joins an existing booking group (same route and departure day), ' +
|
||
'which shares one booking window across all its trains. Create it with the ' +
|
||
'group settings, then use Booking window settings to change the window for ' +
|
||
'the whole group.',
|
||
);
|
||
}
|
||
|
||
// The rule this schedule is born under: staff overrides on top of the live
|
||
// global config, so an omitted field still follows the global value.
|
||
const windowCfg: BookingWindowConfig = dto.windowRule
|
||
? {
|
||
...globalCfg,
|
||
...pickDefined({
|
||
windowOpenHour: dto.windowRule.windowOpenHour,
|
||
windowCloseHour: dto.windowRule.windowCloseHour,
|
||
windowDurationHours: dto.windowRule.windowDurationHours,
|
||
docReviewMinutes: dto.windowRule.docReviewMinutes,
|
||
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
|
||
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
|
||
}),
|
||
// One pay-window override drives both directions (only the one
|
||
// matching this schedule's direction is ever read).
|
||
...(dto.windowRule.paymentWindowMinutes !== undefined
|
||
? {
|
||
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||
}
|
||
: {}),
|
||
// Close offsets are nullable-by-intent: null/0 means "close at
|
||
// departure", which must override a non-null global, so these are
|
||
// merged on presence rather than on definedness.
|
||
...(dto.windowRule.importCloseOffsetMinutes !== undefined
|
||
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
|
||
: {}),
|
||
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
|
||
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
|
||
: {}),
|
||
}
|
||
: globalCfg;
|
||
|
||
// Short-notice trains are allowed: a departure inside the booking lead
|
||
// window is NOT rejected — the window just opens immediately (opensAt is
|
||
// clamped to `now` below) instead of waiting out a lead that has already
|
||
// passed. Only `updateScheduleDate` still enforces the lead floor.
|
||
|
||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||
// windows from it rather than the live config.
|
||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||
const computedTimes =
|
||
direction === 'EXPORT'
|
||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||
: {
|
||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||
...ruleSnapshot,
|
||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||
};
|
||
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
|
||
// in the past — clamp it to `now` so the window tick opens it immediately.
|
||
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
|
||
computedTimes.windowOpensAt = new Date();
|
||
}
|
||
if (
|
||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||
) {
|
||
throw new BadRequestException(
|
||
'These booking-window settings leave no window before departure — with the ' +
|
||
'desk hours and close offset applied, the window would only open once the ' +
|
||
'train has left.',
|
||
);
|
||
}
|
||
const windowFields = {
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PRE_WINDOW',
|
||
...(groupAnchor
|
||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||
: computedTimes),
|
||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||
// live global value for the direction), so an explicit staff override is
|
||
// persisted here — the same field the post-creation override writes.
|
||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||
: {}),
|
||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||
// global-rules edit would overwrite exactly what staff chose here.
|
||
windowRuleCustom: dto.windowRule != null,
|
||
};
|
||
// A built train's own consist is the schedule's capacity: full when all
|
||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||
// the configured limit.
|
||
const builtTrainWagonCount = builtTrain
|
||
? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } })
|
||
: 0;
|
||
const maxWagons =
|
||
builtTrainWagonCount > 0
|
||
? builtTrainWagonCount
|
||
: (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain;
|
||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||
// (the unique index rejects the loser; it re-reads the max and tries again).
|
||
const saved = await this.insertScheduleWithReference(manager, (reference) =>
|
||
manager.getRepository(TrainSchedule).create({
|
||
reference,
|
||
trainSetId: trainSet.id,
|
||
routeId: route.id,
|
||
originStationId: route.originYardId,
|
||
destinationStationId: route.destinationYardId,
|
||
scheduledDepartureDate: departure,
|
||
// Born SCHEDULED: there is no draft/finalize phase — a created train
|
||
// is immediately visible and bookable to customers.
|
||
status: TrainScheduleStatusEnum.Scheduled,
|
||
direction,
|
||
trainNumber: pairTrainNumber ?? undefined,
|
||
maxWagons,
|
||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||
...windowFields,
|
||
}),
|
||
);
|
||
// Locomotives stay in their current status until dispatch — advance scheduling
|
||
// must not block the locomotive from serving earlier trains.
|
||
if (builtTrain) {
|
||
await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id);
|
||
}
|
||
return saved.id;
|
||
});
|
||
|
||
const created = await this.getTrainScheduleById(createdScheduleId);
|
||
// New window announced — portal home / GL cards pick it up immediately.
|
||
void this.emitWindowState(createdScheduleId);
|
||
return { ...created, warnings: scheduleWarnings };
|
||
}
|
||
|
||
async assignBookingsToSchedule(
|
||
scheduleId: string,
|
||
dto: AssignBookingsDto,
|
||
freightType?: 'CONTAINER' | 'BULK',
|
||
) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||
);
|
||
}
|
||
if (!schedule.trainSet) {
|
||
throw new BadRequestException('Schedule has no train set');
|
||
}
|
||
|
||
// Batch parity: a schedule may only allocate bookings from its route-day POOL.
|
||
// Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
|
||
// a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
|
||
// pinned to whichever train in the day's group first held it. Every train
|
||
// sharing this origin + destination + EAT departure day draws from ONE shared
|
||
// pool (one shared booking window), so a booking is allocatable here when it is
|
||
// either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
|
||
// pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
|
||
// day/capacity fit is enforced downstream by validateBookingsForScheduling.
|
||
// EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
|
||
if (dto.bookingIds.length) {
|
||
const groupScheduleIds = new Set<string>([scheduleId]);
|
||
if (schedule.direction !== 'EXPORT') {
|
||
const siblings = await this.findGroupSiblings(
|
||
this.dataSource.manager,
|
||
schedule.originStationId,
|
||
schedule.destinationStationId,
|
||
schedule.scheduledDepartureDate,
|
||
scheduleId,
|
||
);
|
||
for (const sib of siblings) groupScheduleIds.add(sib.id);
|
||
}
|
||
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
|
||
const stray = targeted.filter(
|
||
(b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
|
||
);
|
||
if (stray.length) {
|
||
throw new BadRequestException(
|
||
`These bookings are pinned to a train on a different route or day: ${stray
|
||
.map((b) => b.reference ?? b.id)
|
||
.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// The rebuild below deletes EVERY schedule↔booking link row and recreates
|
||
// only what makes the new plan. Ride-along (intercity) bookings are linked
|
||
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
|
||
// workspace's picked ids, so planning from dto.bookingIds alone silently
|
||
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
|
||
// every list. Every (re)assignment therefore re-plans the WHOLE train:
|
||
// the requested ids plus everything currently linked.
|
||
const linkedRows =
|
||
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
|
||
const allBookingIds = [
|
||
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
|
||
];
|
||
|
||
const previewDto = {
|
||
bookingIds: allBookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
maxTrainWeightTons: dto.maxTrainWeightTons,
|
||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
|
||
// created — every (re)assignment rebuilds the plan under the same flag so the
|
||
// stored train order stays consistent no matter how bookings are added.
|
||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||
};
|
||
|
||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
previewDto,
|
||
limitLoco,
|
||
schedule.maxWagons ?? undefined,
|
||
);
|
||
|
||
// Callers that add bookings without hand-picking container slots (the
|
||
// workspace "Add from pool" button, re-adding a removed booking) send no
|
||
// containerPlacements. Auto-fill them the same way the batch engine does:
|
||
// preview the wagon plan first, then lay containers into the plan's slots.
|
||
// Without this the placement validator rejects container bookings outright
|
||
// ("Container placements are required for container bookings").
|
||
// Callers hand-pick placements only for the bookings they know about; the
|
||
// union above may have folded in linked ride-alongs those placements never
|
||
// covered. Auto-fill whatever units are missing (all of them when no
|
||
// placements were sent at all) so the placement validator doesn't reject
|
||
// container bookings the caller couldn't have placed.
|
||
let containerPlacements = dto.containerPlacements;
|
||
{
|
||
const preview = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
freightType ?? null,
|
||
dto.forceAssign,
|
||
[],
|
||
false,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
const containerBookings = preview.bookings.filter(
|
||
(b) => b.freightType === 'CONTAINER',
|
||
);
|
||
if (containerBookings.length) {
|
||
const units = expandBookingContainerUnits(containerBookings);
|
||
const providedKeys = new Set(
|
||
(containerPlacements ?? []).map(
|
||
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
||
),
|
||
);
|
||
const unplacedUnits = units.filter(
|
||
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||
);
|
||
if (unplacedUnits.length) {
|
||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||
const generated = autoFillPlacements(unplacedUnits, slots);
|
||
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||
if (missing.length) {
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${missing
|
||
.map((m) => m.issue)
|
||
.join('; ')}`,
|
||
violations: missing.map((m) => m.issue),
|
||
});
|
||
}
|
||
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
||
}
|
||
}
|
||
}
|
||
|
||
const validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
freightType ?? null,
|
||
dto.forceAssign,
|
||
containerPlacements,
|
||
true,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
|
||
if (!validation.valid) {
|
||
// Put the violation detail in the message itself — global exception
|
||
// filters flatten the body, and "Booking validation failed" alone tells
|
||
// staff nothing (e.g. which wagon type is missing at the yard).
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${validation.violations.join('; ')}`,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
});
|
||
}
|
||
|
||
if (!validation.bookings.length) {
|
||
const shortfall = validation.deferredBookings
|
||
.map((d) => `${d.reference}: ${d.reason}`)
|
||
.join('; ');
|
||
throw new BadRequestException({
|
||
message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`,
|
||
violations: ['Insufficient fleet wagons for the selected bookings'],
|
||
warnings: validation.warnings,
|
||
deferredBookings: validation.deferredBookings,
|
||
});
|
||
}
|
||
|
||
// Every REQUESTED booking must have made the plan. Silently dropping a
|
||
// deferred one let the workspace "Add from pool" report success while the
|
||
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
|
||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||
// override it either.
|
||
// Linked ride-alongs count as requested too: silently dropping one here is
|
||
// exactly the delete-and-recreate orphan this method must never produce.
|
||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
||
if (droppedRequested.length) {
|
||
const reasonById = new Map(
|
||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||
);
|
||
const details = droppedRequested.map(
|
||
(id) =>
|
||
reasonById.get(id) ??
|
||
`${id}: does not fit the train's wagon stock or capacity`,
|
||
);
|
||
throw new BadRequestException({
|
||
message: `Cannot allocate — ${details.join('; ')}`,
|
||
violations: details,
|
||
warnings: validation.warnings,
|
||
deferredBookings: validation.deferredBookings,
|
||
});
|
||
}
|
||
|
||
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
|
||
const totalWeightTons = validation.summary.totalWeightTons;
|
||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||
|
||
if (!limitLoco) {
|
||
throw new BadRequestException('Schedule train set has no locomotives');
|
||
}
|
||
// forceAssign lets staff overload the locomotive set knowingly — the
|
||
// validator has already surfaced it as a warning in that case. Each
|
||
// locomotive's overageToleranceTons/Meters extends the hard cap before that
|
||
// override is even needed (e.g. the fertilizer example's +90T deviation).
|
||
const weightCapWithOverage =
|
||
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
|
||
const lengthCapWithOverage =
|
||
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
|
||
// The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
|
||
// A built train hauls EVERY coupled wagon's tare — empty ones included —
|
||
// so train-bound schedules count the full consist, not just planned slots.
|
||
const consistWagons = schedule.trainSet?.trainId
|
||
? await this.dataSource.getRepository(Wagon).find({
|
||
where: { trainId: schedule.trainSet.trainId },
|
||
relations: { wagonType: true },
|
||
})
|
||
: null;
|
||
const planTareTons = roundTons(
|
||
wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
|
||
);
|
||
const consistTareTons = consistWagons
|
||
? roundTons(
|
||
consistWagons.reduce(
|
||
(sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
|
||
0,
|
||
),
|
||
)
|
||
: planTareTons;
|
||
// The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
|
||
// disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
|
||
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||
// so their tare rides on top of the binding edge.
|
||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||
const scheduleStops = await this.stopYardsForSchedule(schedule);
|
||
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
|
||
const stopLabels = await this.yardLabelMap(scheduleStops);
|
||
// Each edge is its own consist — name EVERY leg that breaks the limit,
|
||
// not just the heaviest figure, so staff see where along A→…→E it fails.
|
||
const legName = (edge: number) =>
|
||
scheduleStops.length > 2
|
||
? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]} → ${
|
||
stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1]
|
||
}`
|
||
: 'the route';
|
||
const overweightLegs = perEdge
|
||
.map((e) => ({
|
||
edge: e.edge,
|
||
grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons),
|
||
}))
|
||
.filter((e) => e.grossWeightTons > weightCapWithOverage);
|
||
if (!dto.forceAssign && overweightLegs.length) {
|
||
throw new BadRequestException(
|
||
`Train set locomotives cannot pull the gross weight on ${overweightLegs
|
||
.map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`)
|
||
.join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`,
|
||
);
|
||
}
|
||
const overlongLegs = perEdge
|
||
.map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) }))
|
||
.filter((e) => e.lengthMeters > lengthCapWithOverage);
|
||
if (!dto.forceAssign && overlongLegs.length) {
|
||
throw new BadRequestException(
|
||
`Train set locomotives cannot support the train length on ${overlongLegs
|
||
.map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`)
|
||
.join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`,
|
||
);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const trainSetId = schedule.trainSetId;
|
||
|
||
const deletedAllocationIds =
|
||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
||
|
||
if (deletedAllocationIds.length) {
|
||
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
|
||
deletedAllocationIds,
|
||
manager,
|
||
);
|
||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
|
||
deletedAllocationIds,
|
||
manager,
|
||
);
|
||
}
|
||
|
||
await manager.getRepository(TrainSetWagon).delete({ trainSetId });
|
||
await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });
|
||
|
||
await manager.getRepository(TrainSet).update(trainSetId, {
|
||
totalWeightTons,
|
||
totalLengthMeters,
|
||
wagonCount: wagonPlan.length,
|
||
status: 'ASSIGNED',
|
||
});
|
||
|
||
const savedWagons = await this.persistTrainSetWagons(
|
||
manager,
|
||
trainSetId,
|
||
wagonPlan,
|
||
);
|
||
|
||
const scheduleBookingRecords = bookings.map((booking) => ({
|
||
trainScheduleId: scheduleId,
|
||
bookingId: booking.id,
|
||
}));
|
||
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
|
||
|
||
await this.persistAllocationsAndLoads(
|
||
manager,
|
||
savedWagons,
|
||
wagonPlan,
|
||
bookings,
|
||
containerPlacements ?? [],
|
||
);
|
||
|
||
// The link above puts these bookings on the train: they are SCHEDULED, not
|
||
// ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next
|
||
// batch fill, which unlinks it and frees its wagons on the next window cycle.
|
||
const scheduledAt = new Date();
|
||
for (const booking of bookings) {
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
booking.id,
|
||
{
|
||
schedulingStatus: SchedulingStatus.Scheduled,
|
||
scheduledAt,
|
||
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
|
||
},
|
||
manager,
|
||
);
|
||
}
|
||
|
||
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Draft,
|
||
{},
|
||
manager,
|
||
);
|
||
}
|
||
|
||
await this.autoPinWagonsForSchedule(
|
||
manager,
|
||
scheduleId,
|
||
schedule.originStationId,
|
||
savedWagons,
|
||
schedule.reverseWagonOrder ?? false,
|
||
);
|
||
});
|
||
|
||
const detail = await this.getTrainScheduleById(scheduleId);
|
||
return { ...detail, warnings, deferredBookings };
|
||
}
|
||
|
||
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
|
||
}
|
||
|
||
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
|
||
if (!link) {
|
||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||
}
|
||
|
||
const booking = await this.bookingsRepository.findById(bookingId);
|
||
if (booking?.isGovernment) {
|
||
throw new BadRequestException(
|
||
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
|
||
);
|
||
}
|
||
const bookingReference = booking?.reference ?? null;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
||
.flatMap((w) => w.allocations ?? [])
|
||
.filter((a) => a.bookingId === bookingId)
|
||
.map((a) => a.id);
|
||
|
||
if (allocationIds.length) {
|
||
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
|
||
allocationIds,
|
||
manager,
|
||
);
|
||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
|
||
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||
scheduleId,
|
||
bookingId,
|
||
manager,
|
||
);
|
||
|
||
const booking = await this.bookingsRepository.findById(bookingId);
|
||
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
||
// Clear the schedule pointer too: unassign fully detaches the booking from
|
||
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
||
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
||
// assign-bookings parity guard would reject it from every OTHER schedule.
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
bookingId,
|
||
{ schedulingStatus, wagonsRequired: null, trainScheduleId: null },
|
||
manager,
|
||
);
|
||
|
||
// Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale
|
||
// (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as
|
||
// is, the booking becomes invisible: the eligible pool only admits PAID,
|
||
// so it can never be re-added to any train. Revert it to PAID.
|
||
if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) {
|
||
await manager
|
||
.getRepository(Booking)
|
||
.update(bookingId, { status: 'PAID', loadedAt: null } as never);
|
||
}
|
||
|
||
// Recompute the train-set composition from whatever survives this removal.
|
||
// The removed booking's allocations were already deleted above, so any slot
|
||
// left with zero allocations was ridden only by this booking — release it
|
||
// (frees its reserved wagon slot). Shared slots keep their surviving
|
||
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
|
||
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
|
||
// fully-empty train was reset).
|
||
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
|
||
where: { trainSetId: schedule.trainSetId },
|
||
relations: { allocations: true },
|
||
});
|
||
let recomputedWeightTons = 0;
|
||
let recomputedLengthMeters = 0;
|
||
let recomputedWagonCount = 0;
|
||
for (const slot of survivingSlots) {
|
||
const slotAllocations = slot.allocations ?? [];
|
||
if (slotAllocations.length === 0) {
|
||
await manager.getRepository(TrainSetWagon).delete(slot.id);
|
||
continue;
|
||
}
|
||
const slotWeight = slotAllocations.reduce(
|
||
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
|
||
0,
|
||
);
|
||
if (Number(slot.assignedWeightTons) !== slotWeight) {
|
||
await manager
|
||
.getRepository(TrainSetWagon)
|
||
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
|
||
}
|
||
recomputedWeightTons += slotWeight;
|
||
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
|
||
recomputedWagonCount += 1;
|
||
}
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
totalWeightTons: roundTons(recomputedWeightTons),
|
||
totalLengthMeters: roundTons(recomputedLengthMeters),
|
||
wagonCount: recomputedWagonCount,
|
||
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
|
||
// the current status (an object literal lets TypeORM's contextual typing
|
||
// accept the partial without pulling in relation fields).
|
||
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
|
||
});
|
||
});
|
||
|
||
// Freed wagons may un-full the train — re-derive the window status (this
|
||
// also revives a DONE window pre-departure so the freed space is bookable
|
||
// again for import/export).
|
||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||
|
||
await this.trainCompositionRemovalLogRepository.create({
|
||
scheduleId,
|
||
bookingId,
|
||
bookingReference,
|
||
removedByUserId: userId ?? null,
|
||
removedAt: new Date(),
|
||
});
|
||
|
||
// Ops decision, so the customer hears about it: SMS/email + inbox telling
|
||
// them to rebook or pick a new schedule (the removal log above is the record).
|
||
const removedBooking = await this.dataSource
|
||
.getRepository(Booking)
|
||
.findOne({ where: { id: bookingId }, relations: { company: true } });
|
||
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
|
||
this.logger.log(
|
||
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
|
||
);
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
private async runWarehouseArrivalAutomation(scheduleId: string) {
|
||
// Runs after the arrival transaction has committed — must never throw, or a
|
||
// successfully arrived train reports a 500 and looks stuck to the operator.
|
||
let direction: string | undefined;
|
||
try {
|
||
const [schedule]: Array<{
|
||
originCountry: string | null;
|
||
destinationCountry: string | null;
|
||
destinationCode: string | null;
|
||
destinationName: string | null;
|
||
}> = await this.dataSource.query(
|
||
`SELECT oy.country AS "originCountry",
|
||
dy.country AS "destinationCountry",
|
||
dy.code AS "destinationCode",
|
||
dy.label AS "destinationName"
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||
LIMIT 1`,
|
||
[scheduleId],
|
||
);
|
||
|
||
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
|
||
|
||
direction = deriveTradeDirection(
|
||
{ country: schedule.originCountry },
|
||
{ country: schedule.destinationCountry },
|
||
);
|
||
if (direction === 'IMPORT') {
|
||
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||
scheduleId,
|
||
'SYSTEM_TRAIN_ARRIVAL',
|
||
);
|
||
// Customer tracking: cargo is off the train at the destination yard.
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||
return {
|
||
direction,
|
||
action: 'IMPORT_AUTO_UNLOAD',
|
||
status: 'COMPLETED',
|
||
result,
|
||
};
|
||
}
|
||
|
||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||
scheduleId,
|
||
'SYSTEM_TRAIN_ARRIVAL',
|
||
);
|
||
// Customer tracking: cargo is off the train at the Djibouti port.
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||
return {
|
||
direction,
|
||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||
status: 'COMPLETED',
|
||
result,
|
||
};
|
||
}
|
||
|
||
return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
|
||
} catch (error) {
|
||
return {
|
||
direction,
|
||
status: 'FAILED',
|
||
reason: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
|
||
const normalized = (value ?? '').toUpperCase();
|
||
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
|
||
normalized.includes(token),
|
||
);
|
||
}
|
||
|
||
async getImportLoadingBookings(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const [scheduleBookings, allocations] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
]);
|
||
if (!scheduleBookings.length) {
|
||
return { count: 0, items: [] };
|
||
}
|
||
|
||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||
const statusByBookingId = new Map(
|
||
scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
|
||
);
|
||
const candidateIds = scheduleBookings
|
||
.map((sb) => sb.bookingId)
|
||
.filter((id) => allocatedBookingIds.has(id));
|
||
if (!candidateIds.length) {
|
||
return { count: 0, items: [] };
|
||
}
|
||
|
||
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
|
||
const tareDims = await this.loadWagonTareDims();
|
||
const items = bookings
|
||
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
|
||
.map((b) => ({
|
||
id: b.id,
|
||
reference: b.reference ?? null,
|
||
customer: b.company?.name ?? null,
|
||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||
weightTons: this.grossBookingWeightTons(b, tareDims),
|
||
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
|
||
}));
|
||
return { count: items.length, items };
|
||
}
|
||
|
||
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const [scheduleBookings, allocations, bookings] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
|
||
]);
|
||
|
||
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
|
||
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
|
||
const invalid: string[] = [];
|
||
for (const id of dto.bookingIds) {
|
||
const booking = bookingById.get(id);
|
||
if (
|
||
!scheduledIds.has(id) ||
|
||
!allocatedIds.has(id) ||
|
||
!booking ||
|
||
booking.tradeDirection !== 'IMPORT' ||
|
||
booking.paymentStatus !== 'PAID'
|
||
) {
|
||
invalid.push(id);
|
||
}
|
||
}
|
||
if (invalid.length) {
|
||
throw new BadRequestException(
|
||
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
dto.bookingIds,
|
||
dto.loadingStatus,
|
||
);
|
||
return this.getImportLoadingBookings(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
|
||
* direction (import/export/domestic). Distinct from unassign: the booking stays
|
||
* on its wagon; this only records whether cargo is physically loaded. Allowed
|
||
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
|
||
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
|
||
*/
|
||
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
|
||
);
|
||
}
|
||
|
||
const [scheduleBookings, allocations] = await Promise.all([
|
||
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
|
||
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
|
||
]);
|
||
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
|
||
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
|
||
|
||
// Only bookings that are on this train AND pinned to a wagon can be loaded —
|
||
// no direction/payment filter, staff load whatever is physically on the set.
|
||
const invalid = dto.bookingIds.filter(
|
||
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
|
||
);
|
||
if (invalid.length) {
|
||
throw new BadRequestException(
|
||
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
dto.bookingIds,
|
||
dto.loadingStatus,
|
||
);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
||
}
|
||
|
||
const slots = schedule.trainSet?.wagons ?? [];
|
||
const slotIds = new Set(slots.map((w) => w.id));
|
||
const slotById = new Map(slots.map((w) => [w.id, w]));
|
||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
|
||
// Occupancy is judged against THIS schedule's own slots only — a wagon
|
||
// pinned on another schedule (e.g. the same train's July 17 run) stays
|
||
// pinnable here.
|
||
const slotIdByPhysicalId = new Map(
|
||
slots
|
||
.filter((w) => w.physicalWagonId)
|
||
.map((w) => [w.physicalWagonId as string, w.id]),
|
||
);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
for (const assignment of dto.assignments) {
|
||
if (!slotIds.has(assignment.trainSetWagonId)) {
|
||
throw new BadRequestException(
|
||
`Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
|
||
);
|
||
}
|
||
|
||
const physicalWagon = await manager.getRepository(Wagon).findOne({
|
||
where: { id: assignment.physicalWagonId },
|
||
});
|
||
if (!physicalWagon) {
|
||
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
||
}
|
||
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
|
||
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
|
||
const occupyingSlot = slotById.get(occupyingSlotId);
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
|
||
);
|
||
}
|
||
if (builtTrainId) {
|
||
// Train-bound schedule: only the built train's own consist may be
|
||
// pinned — wherever the wagons currently sit, they travel with the
|
||
// train, so no yard/status gate applies.
|
||
if (physicalWagon.trainId !== builtTrainId) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
|
||
);
|
||
}
|
||
} else {
|
||
if (physicalWagon.trainId) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
|
||
);
|
||
}
|
||
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
|
||
);
|
||
}
|
||
if (
|
||
physicalWagon.currentTrainScheduleId &&
|
||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||
) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
|
||
);
|
||
}
|
||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||
throw new ConflictException(
|
||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||
// its status untouched so other schedules can still use the wagon.
|
||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||
physicalWagonId: assignment.physicalWagonId,
|
||
status: 'RESERVED',
|
||
});
|
||
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||
if (slotId === assignment.trainSetWagonId) {
|
||
slotIdByPhysicalId.delete(physicalId);
|
||
break;
|
||
}
|
||
}
|
||
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
|
||
}
|
||
});
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async finalizeSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
// Schedules are born SCHEDULED now — finalize is a no-op for them so the
|
||
// allocate wizard and the window auto-finalize keep working. The DRAFT
|
||
// branch below only still runs for legacy rows.
|
||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
|
||
throw new BadRequestException('Only DRAFT schedules can be finalized');
|
||
}
|
||
if (!schedule.scheduleBookings?.length) {
|
||
throw new BadRequestException('Cannot finalize a schedule with no bookings');
|
||
}
|
||
|
||
const now = new Date();
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Scheduled,
|
||
{},
|
||
manager,
|
||
);
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{ schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now },
|
||
manager,
|
||
);
|
||
}
|
||
});
|
||
|
||
// Finalized — push so portal/GL cards reflect the new state instantly.
|
||
void this.emitWindowState(scheduleId);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async dispatchSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||
}
|
||
await this.assertImportDjiboutiMayDepart(schedule);
|
||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||
// Same rule for wagons: many schedules may pin the same wagon, but it can
|
||
// only be OUT on one dispatched train at a time.
|
||
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||
.map((slot) => slot.physicalWagonId)
|
||
.filter((id): id is string => Boolean(id));
|
||
if (pinnedPhysicalIds.length) {
|
||
const rolling = await this.dataSource.getRepository(Wagon).find({
|
||
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
|
||
});
|
||
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
|
||
if (busy.length) {
|
||
throw new ConflictException(
|
||
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const now = new Date();
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||
if (setLocomotiveIds.length) {
|
||
await manager
|
||
.getRepository(Locomotive)
|
||
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
|
||
}
|
||
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Dispatched,
|
||
{
|
||
actualDepartureAt: now,
|
||
trainNumber,
|
||
// Freeze the wagon plan the moment the train leaves the editable phase.
|
||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||
schedule,
|
||
TrainScheduleStatusEnum.Dispatched,
|
||
now,
|
||
),
|
||
},
|
||
manager,
|
||
);
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
||
}
|
||
// A built train follows its schedule out: IN_SERVICE until arrival.
|
||
if (schedule.trainSet?.trainId) {
|
||
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
|
||
}
|
||
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
||
// stays pinned so no other schedule can pick it while it's rolling.
|
||
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||
.map((slot) => slot.physicalWagonId)
|
||
.filter((id): id is string => Boolean(id));
|
||
if (dispatchedPhysicalIds.length) {
|
||
await manager
|
||
.getRepository(Wagon)
|
||
.update(
|
||
{ id: In(dispatchedPhysicalIds) },
|
||
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||
);
|
||
}
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{ schedulingStatus: SchedulingStatus.Dispatched },
|
||
manager,
|
||
);
|
||
}
|
||
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
|
||
// that the operator didn't load individually are auto-loaded now — the
|
||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||
// operator loads them at their own yard.
|
||
await manager.query(
|
||
`UPDATE freight.bookings b
|
||
SET status = 'IN_TRANSIT',
|
||
loaded_at = COALESCE(b.loaded_at, $3)
|
||
FROM freight.train_schedule_bookings tsb
|
||
WHERE tsb.booking_id = b.id
|
||
AND tsb.train_schedule_id = $1
|
||
AND tsb.deleted_at IS NULL
|
||
AND b.deleted_at IS NULL
|
||
AND b.origin_yard_id = $2
|
||
AND b.loaded_at IS NULL
|
||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||
[scheduleId, schedule.originStationId, now],
|
||
);
|
||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||
await manager
|
||
.getRepository(TrainSchedule)
|
||
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
|
||
await manager
|
||
.getRepository(Booking)
|
||
.createQueryBuilder()
|
||
.update()
|
||
.set({
|
||
status: 'EXPIRED',
|
||
schedulingStatus: SchedulingStatus.Eligible,
|
||
paymentDeadline: null,
|
||
})
|
||
.where('train_schedule_id = :scheduleId', { scheduleId })
|
||
.andWhere(
|
||
`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
|
||
)
|
||
.execute();
|
||
});
|
||
|
||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
|
||
});
|
||
console.log(
|
||
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
|
||
);
|
||
}
|
||
|
||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||
void this.emitWindowState(scheduleId);
|
||
// Customer tracking: cargo is on the departing train — loading milestones
|
||
// plus the direction's "departed" handoff milestone. Restricted to bookings
|
||
// that BOARD at the train's origin; mid-corridor boarders get their loading
|
||
// milestones from their own operator load at their own yard.
|
||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||
void this.completeMilestonesForScheduleBookings(
|
||
scheduleId,
|
||
[
|
||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||
// doc-trigger path no-ops it for import bookings.
|
||
'CARGO_ARRIVED',
|
||
'READY_FOR_LOADING',
|
||
'LOADED',
|
||
schedule.direction === 'IMPORT'
|
||
? 'DEPARTED_FROM_DJIBOUTI'
|
||
: 'DEPARTED_TO_DJIBOUTI',
|
||
],
|
||
{ originYardId: schedule.originStationId },
|
||
);
|
||
}
|
||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||
|
||
const detail = await this.getTrainScheduleById(scheduleId);
|
||
// Surface a compact dispatch confirmation so the caller can toast the "train
|
||
// is out" info (train number, departure, wagons committed) without re-deriving it.
|
||
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
|
||
(slot) => slot.physicalWagonId,
|
||
).length;
|
||
return Object.assign(detail, {
|
||
dispatchInfo: {
|
||
// The real train number was assigned inside the txn — read it back off
|
||
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
|
||
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
|
||
departedAt: now.toISOString(),
|
||
wagonsDispatched: dispatchedWagonCount,
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
},
|
||
});
|
||
}
|
||
|
||
async getImportDjiboutiOperation(scheduleId: string) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||
}
|
||
|
||
async uploadImportDjiboutiDocument(
|
||
scheduleId: string,
|
||
dto: UploadImportDjiboutiDocumentDto,
|
||
) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const documents = {
|
||
...(operation.documents ?? {}),
|
||
[dto.documentType]: {
|
||
fileId: dto.fileId ?? null,
|
||
fileUrl: dto.fileUrl ?? null,
|
||
reference: dto.reference ?? null,
|
||
uploadedAt: new Date().toISOString(),
|
||
uploadedBy: dto.performedBy ?? null,
|
||
notes: dto.notes ?? null,
|
||
},
|
||
};
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
documents,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
|
||
const documents = { ...(operation.documents ?? {}) };
|
||
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
|
||
documents.GATE_PASS = {
|
||
fileId: dto.fileId ?? null,
|
||
fileUrl: dto.fileUrl ?? null,
|
||
reference: dto.reference ?? null,
|
||
uploadedAt: new Date().toISOString(),
|
||
uploadedBy: dto.performedBy ?? null,
|
||
notes: dto.notes ?? null,
|
||
};
|
||
}
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
documents,
|
||
gatepassGrantedAt: securedAt,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
|
||
|
||
console.log(
|
||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||
);
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
/**
|
||
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
|
||
* milestone for every customs booking on this schedule, so contract/booking
|
||
* clearance views still reading that milestone (older deployed builds) see
|
||
* the gate pass as done. Drop once every clearance-api deployment reads
|
||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||
*
|
||
* A booking only earns its gate pass once the customer has settled the freight
|
||
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
|
||
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
|
||
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
|
||
* train and its paid bookings proceed. Re-securing the gate pass after payment
|
||
* settles picks the booking up; so does any later call to this bridge.
|
||
*/
|
||
private async completeGatepassMilestoneForSchedule(
|
||
scheduleId: string,
|
||
securedAt: Date,
|
||
): Promise<void> {
|
||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
|
||
});
|
||
if (bookings.length === 0) return;
|
||
|
||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||
const bookingIds = bookings.map((b) => b.id);
|
||
const rows = await milestoneRepo.find({
|
||
where: {
|
||
bookingId: In(bookingIds),
|
||
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
|
||
},
|
||
});
|
||
|
||
const paidBookingIds = new Set(
|
||
rows
|
||
.filter(
|
||
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
|
||
)
|
||
.map((r) => r.bookingId),
|
||
);
|
||
// A booking whose payment settled through a path that never wrote the
|
||
// milestone still counts as paid — the clearance views self-heal the row on
|
||
// read, and the gate pass must not lag behind that.
|
||
for (const booking of bookings) {
|
||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||
paidBookingIds.add(booking.id);
|
||
}
|
||
}
|
||
|
||
const skipped: string[] = [];
|
||
for (const row of rows) {
|
||
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
|
||
if (row.status === 'COMPLETED') continue;
|
||
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
|
||
skipped.push(row.bookingId ?? '(unknown)');
|
||
continue;
|
||
}
|
||
row.status = 'COMPLETED';
|
||
row.triggeredAt = securedAt;
|
||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||
await milestoneRepo.save(row);
|
||
}
|
||
|
||
if (skipped.length > 0) {
|
||
this.logger.warn(
|
||
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
this.assertImportDjiboutiGatepassGranted(operation);
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
this.assertImportDjiboutiGatepassGranted(operation);
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
/**
|
||
* Confirm cargo is loaded on the train from the workspace, for any direction.
|
||
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
|
||
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
|
||
* For every other schedule there is no departure loading gate, so this is a
|
||
* success no-op and simply returns the current detail.
|
||
*/
|
||
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
|
||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||
await this.confirmImportLoadedOnTrain(scheduleId, dto);
|
||
}
|
||
// Confirming loading also marks every wagon-assigned booking LOADED, so the
|
||
// per-booking loading flag and the dispatch gate agree (otherwise the
|
||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
if (wagonAssignedIds.size) {
|
||
// Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading
|
||
// is confirmed — allocation time already enforces it, but a wagon swap or
|
||
// an edited allocation since then could have broken it unnoticed.
|
||
await this.assertWagonContainerCapacity(scheduleId);
|
||
// Export cargo must be received at the warehouse with a GRN before it can
|
||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||
if (this.isExportSchedule(schedule)) {
|
||
await this.assertExportBookingsReceived([...wagonAssignedIds]);
|
||
}
|
||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||
scheduleId,
|
||
[...wagonAssignedIds],
|
||
LoadingStatus.Loaded,
|
||
);
|
||
}
|
||
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
|
||
// is the export-side "cargo reached origin yard" step that precedes it).
|
||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||
'CARGO_ARRIVED',
|
||
'READY_FOR_LOADING',
|
||
'LOADED',
|
||
]);
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
this.assertImportDjiboutiGatepassGranted(operation);
|
||
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
|
||
|
||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||
await this.dispatchSchedule(schedule.id);
|
||
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
|
||
}
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
return this.getImportDjiboutiOperation(schedule.id);
|
||
}
|
||
|
||
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||
|
||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||
loadListGeneratedAt: generatedAt,
|
||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||
notes: dto.notes ?? operation.notes ?? null,
|
||
});
|
||
|
||
return {
|
||
generatedAt: generatedAt.toISOString(),
|
||
trainScheduleId: schedule.id,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||
// Every wagon on the train set, loaded or not, in consist order. An empty
|
||
// wagon has an empty `allocations` array — it is still part of the train
|
||
// and still belongs on the marshalling document.
|
||
wagons: [...(schedule.trainSet?.wagons ?? [])]
|
||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
|
||
.map((wagon) => ({
|
||
sequenceNo: wagon.sequenceNo,
|
||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.booking?.reference ?? null,
|
||
booking: allocation.booking,
|
||
loadType: allocation.loadType ?? null,
|
||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||
containerNumbers: (allocation.containerItems ?? [])
|
||
.map((item) => item.containerNumber)
|
||
.filter(Boolean),
|
||
containerItems: allocation.containerItems ?? [],
|
||
})),
|
||
})),
|
||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||
};
|
||
}
|
||
|
||
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||
performedBy: 'DOCUMENT_GENERATION',
|
||
});
|
||
const html = this.buildImportLoadListHtml(loadList);
|
||
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
|
||
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
|
||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
|
||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||
return {
|
||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||
buffer,
|
||
};
|
||
}
|
||
|
||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!this.isExportSchedule(schedule)) {
|
||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||
}
|
||
|
||
const html = this.buildExportLoadListHtml(schedule);
|
||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
|
||
const reference = schedule.trainNumber ?? schedule.id;
|
||
return {
|
||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||
buffer,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The train's composition as it stands right now — the source for the
|
||
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
||
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
||
* unloaded, PLANNED/RESERVED = not on board yet).
|
||
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||
* path is comparing the board yard against the latest checkpoint sequence.
|
||
*/
|
||
private intercityOnBoardView(schedule: TrainSchedule): {
|
||
wagons: TrainSetWagon[];
|
||
unassignedBookings: Booking[];
|
||
} {
|
||
const wagons = (schedule.trainSet?.wagons ?? [])
|
||
.filter((wagon) => {
|
||
if (wagon.status === 'DEPARTED') return false;
|
||
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||
return wagon.boardYardId == null || hasLoaded;
|
||
})
|
||
.map((wagon) => ({
|
||
...wagon,
|
||
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
|
||
})) as TrainSetWagon[];
|
||
|
||
const onBoardBookingIds = new Set(
|
||
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
|
||
);
|
||
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
|
||
// after dispatch (never wagon-pinned) and loads whose allocation was never
|
||
// confirmed LOADED. They are physically on the train, so they get a row.
|
||
const unassignedBookings = (schedule.scheduleBookings ?? [])
|
||
.map((link) => link.booking)
|
||
.filter((booking): booking is Booking => Boolean(booking))
|
||
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
|
||
|
||
return { wagons, unassignedBookings };
|
||
}
|
||
|
||
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
|
||
throw new BadRequestException(
|
||
'Intercity marshalling document applies only to dispatched or arrived trains',
|
||
);
|
||
}
|
||
|
||
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||
const last = checkpoints[checkpoints.length - 1];
|
||
const positionLabel = last
|
||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||
|
||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||
const html = this.buildExportLoadListHtml(schedule, {
|
||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||
positionLabel,
|
||
wagons,
|
||
unassignedBookings,
|
||
});
|
||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||
const reference = schedule.trainNumber ?? schedule.id;
|
||
return {
|
||
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||
buffer,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
||
* tally. Two independent sources, since only one is populated depending on
|
||
* how the item was created:
|
||
* - `item.containerType` — the item's own container_type_id FK, set for
|
||
* manually-entered items (no booking-container line behind them).
|
||
* - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the
|
||
* booking-line fallback for items generated from an allocation.
|
||
* (`findByIdWithFullGraph` must load both relations or every item here
|
||
* silently resolves to null and the tally stays zero.)
|
||
*/
|
||
private resolveContainerItemSize(item: {
|
||
containerType?: { sizeFt?: number | null } | null;
|
||
bookingContainer?: {
|
||
containerSize?: string | null;
|
||
containerType?: { sizeFt?: number | null } | null;
|
||
} | null;
|
||
}): number | null {
|
||
const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt;
|
||
if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt;
|
||
const label = item.bookingContainer?.containerSize;
|
||
if (label?.includes('40')) return 40;
|
||
if (label?.includes('20')) return 20;
|
||
return null;
|
||
}
|
||
|
||
private buildExportLoadListHtml(
|
||
schedule: TrainSchedule,
|
||
opts?: {
|
||
title?: string;
|
||
positionLabel?: string;
|
||
wagons?: TrainSetWagon[];
|
||
unassignedBookings?: Booking[];
|
||
},
|
||
): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||
// The document is checked against the physical train, so it has to run in
|
||
// consist order — the relation comes back unordered.
|
||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||
);
|
||
const rows = wagons
|
||
.flatMap((wagon) => {
|
||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
||
const allocations = wagon.allocations ?? [];
|
||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||
// check this document against the physical train — a wagon with no row
|
||
// reads as a wagon that is not there, and the count stops matching.
|
||
if (allocations.length === 0) {
|
||
return [
|
||
`<tr class="empty">
|
||
${wagonCells}
|
||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||
</tr>`,
|
||
];
|
||
}
|
||
return allocations.map((allocation) => {
|
||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||
const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||
const containerItems = allocation.containerItems ?? [];
|
||
const firstContainer = containerItems[0];
|
||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||
return `<tr>
|
||
${wagonCells}
|
||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||
<td>${esc(companyName)}</td>
|
||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||
<td>${esc(chassisNumbers)}</td>
|
||
<td>${esc(sealNumbers)}</td>
|
||
</tr>`;
|
||
});
|
||
})
|
||
.join('');
|
||
// Intercity riders accepted after dispatch have no wagon slot recorded —
|
||
// they are still physically on the train, so they get rows of their own.
|
||
const unassigned = opts?.unassignedBookings ?? [];
|
||
const unassignedRows = unassigned.length
|
||
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||
unassigned
|
||
.map((booking) => {
|
||
const containerNumbers = (booking.bookingContainers ?? [])
|
||
.map((container) => container.containerNumber)
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||
return `<tr>
|
||
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
||
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||
<td>${esc(booking.company?.name)}</td>
|
||
<td>${esc(containerNumbers)}</td>
|
||
<td>-</td>
|
||
<td>-</td>
|
||
</tr>`;
|
||
})
|
||
.join('')
|
||
: '';
|
||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||
const totalWeight = wagons.reduce(
|
||
(sum, wagon) =>
|
||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||
0,
|
||
);
|
||
|
||
// Container count summary (40ft, 20ft)
|
||
let count40ft = 0, count20ft = 0;
|
||
wagons.forEach((wagon) => {
|
||
(wagon.allocations ?? []).forEach((allocation) => {
|
||
(allocation.containerItems ?? []).forEach((item) => {
|
||
const size = this.resolveContainerItemSize(item);
|
||
if (size === 40) count40ft++;
|
||
else if (size === 20) count20ft++;
|
||
});
|
||
});
|
||
});
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
|
||
<style>
|
||
@page { size: A4 landscape; margin: 10mm; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||
.tile strong { font-size: 11px; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||
.num { text-align: right; }
|
||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top">
|
||
<div>
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
|
||
</div>
|
||
<div class="meta">
|
||
Train / Schedule
|
||
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary">
|
||
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Seq</th>
|
||
<th>Wagon No</th>
|
||
<th>Wagon Type</th>
|
||
<th class="num">Equated Length</th>
|
||
<th class="num">Tare Weight</th>
|
||
<th class="num">Load Capacity</th>
|
||
<th>Cargo Type</th>
|
||
<th>Company</th>
|
||
<th>Container No</th>
|
||
<th>Chassis No</th>
|
||
<th>Seal No</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||
${unassignedRows}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="notice">
|
||
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||
cargo type, and customer booking against the physical consist before departure.
|
||
</div>
|
||
|
||
<div class="signatures">
|
||
<div class="line">Prepared person / date</div>
|
||
<div class="line">Check person / date</div>
|
||
<div class="line">Operations authorization / date</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private isExportSchedule(schedule: TrainSchedule): boolean {
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
return direction === 'EXPORT';
|
||
}
|
||
|
||
/**
|
||
* Every export booking being confirmed loaded must already be received at the
|
||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||
* is the check that the cargo is physically in the yard before we call it loaded.
|
||
*
|
||
* Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded —
|
||
* that cargo is manually loaded from the customer's truck straight onto the
|
||
* wagon, never sees the warehouse, and is never GRN'd. Its custody is attested
|
||
* by the carriage acceptance sheet instead (same carve-out as the shared
|
||
* assertExportReceivedWithGrn gate — see common/export-received-gate.ts).
|
||
*/
|
||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||
if (!bookingIds.length) return;
|
||
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
|
||
`SELECT b.reference
|
||
FROM freight.bookings b
|
||
WHERE b.id = ANY($1)
|
||
AND b.deleted_at IS NULL
|
||
AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN'
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM freight.warehouse_inventory inv
|
||
WHERE inv.booking_id = b.id
|
||
AND inv.deleted_at IS NULL
|
||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
|
||
AND COALESCE(
|
||
NULLIF(TRIM(inv.grn_number), ''),
|
||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||
) IS NOT NULL
|
||
)`,
|
||
[bookingIds],
|
||
);
|
||
if (rows.length) {
|
||
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
|
||
throw new BadRequestException(
|
||
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation
|
||
* time. `validateContainerPlacements` already enforces this the moment a
|
||
* booking is allocated to a wagon, but nothing re-checks it afterwards — a
|
||
* wagon swap, an edited allocation, or a container item added out-of-band
|
||
* between allocation and loading could still leave a wagon over its 2-TEU
|
||
* capacity undetected until the train is already loaded. This closes that gap
|
||
* by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU,
|
||
* same size resolution as the marshalling document) right before loading is
|
||
* confirmed.
|
||
*/
|
||
private async assertWagonContainerCapacity(scheduleId: string): Promise<void> {
|
||
const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> =
|
||
await this.dataSource.query(
|
||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||
pw.wagon_number AS "wagonNumber",
|
||
SUM(
|
||
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||
WHEN 40 THEN 2
|
||
WHEN 20 THEN 1
|
||
ELSE
|
||
CASE
|
||
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||
ELSE 0
|
||
END
|
||
END
|
||
) AS "teuUsed"
|
||
FROM freight.train_set_wagons tsw
|
||
JOIN freight.wagon_booking_allocations wba
|
||
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||
JOIN freight.wagon_allocation_container_items wci
|
||
ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL
|
||
LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id
|
||
LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id
|
||
LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id
|
||
LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id
|
||
WHERE tsw.train_set_id = (
|
||
SELECT train_set_id FROM freight.train_schedules WHERE id = $1
|
||
)
|
||
AND tsw.deleted_at IS NULL
|
||
GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number
|
||
HAVING SUM(
|
||
CASE COALESCE(ct.size_ft, bct.size_ft)
|
||
WHEN 40 THEN 2
|
||
WHEN 20 THEN 1
|
||
ELSE
|
||
CASE
|
||
WHEN bc.container_size ILIKE '%40%' THEN 2
|
||
WHEN bc.container_size ILIKE '%20%' THEN 1
|
||
ELSE 0
|
||
END
|
||
END
|
||
) > $2`,
|
||
[scheduleId, MAX_TEU_SLOTS_PER_WAGON],
|
||
);
|
||
if (rows.length) {
|
||
const labels = rows
|
||
.map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`)
|
||
.join(', ');
|
||
throw new BadRequestException(
|
||
`These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||
const esc = (value: unknown) =>
|
||
String(value ?? '-')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||
const status = loadList.operation.status;
|
||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||
const totalWeight = loadList.wagons.reduce(
|
||
(sum, wagon) =>
|
||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||
0,
|
||
);
|
||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||
|
||
// Container count summary (40ft, 20ft)
|
||
let count40ft = 0, count20ft = 0;
|
||
loadList.wagons.forEach((wagon) => {
|
||
wagon.allocations.forEach((allocation) => {
|
||
(allocation.containerItems ?? []).forEach((item) => {
|
||
const size = this.resolveContainerItemSize(item);
|
||
if (size === 40) count40ft++;
|
||
else if (size === 20) count20ft++;
|
||
});
|
||
});
|
||
});
|
||
|
||
const allocationRows = loadList.wagons
|
||
.flatMap((wagon) => {
|
||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||
<td>${esc(wagon.wagonNumber)}</td>`;
|
||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||
// buildExportLoadListHtml.
|
||
if (wagon.allocations.length === 0) {
|
||
return [
|
||
`<tr class="empty">
|
||
${wagonCells}
|
||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||
</tr>`,
|
||
];
|
||
}
|
||
return wagon.allocations.map(
|
||
(allocation) => {
|
||
const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-';
|
||
return `<tr>
|
||
${wagonCells}
|
||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||
<td>${esc(companyName)}</td>
|
||
<td>${esc(allocation.loadType)}</td>
|
||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||
</tr>`;
|
||
},
|
||
);
|
||
})
|
||
.join('');
|
||
|
||
return `<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Import Load List / Marshalling Document</title>
|
||
<style>
|
||
@page { size: A4; margin: 14mm; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||
.doc { min-height: 100vh; position: relative; }
|
||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||
.tile strong { font-size: 13px; }
|
||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||
.pending { background: #f8fafc; color: #64748b; }
|
||
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||
.num { text-align: right; }
|
||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="doc">
|
||
<div class="top">
|
||
<div>
|
||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||
<h1>Import Load List /<br />Marshalling Document</h1>
|
||
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||
</div>
|
||
<div class="meta">
|
||
Train / Schedule
|
||
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
|
||
Generated: ${esc(date(loadList.generatedAt))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary">
|
||
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
|
||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||
</div>
|
||
|
||
<div class="status">
|
||
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||
</div>
|
||
|
||
<h2>Wagon Marshalling Allocation</h2>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Seq</th>
|
||
<th>Wagon</th>
|
||
<th>Booking</th>
|
||
<th>Company</th>
|
||
<th>Load</th>
|
||
<th>Container numbers</th>
|
||
<th class="num">Weight T</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="notice">
|
||
Gate and loading staff must verify this document against the granted gatepass,
|
||
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
|
||
</div>
|
||
|
||
<div class="signatures">
|
||
<div class="line">Prepared by Djibouti operations</div>
|
||
<div class="line">Train loading supervisor</div>
|
||
<div class="line">EDR operations authorization</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
}
|
||
|
||
private safeDocumentName(value: string): string {
|
||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||
}
|
||
|
||
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
|
||
if (!this.isImportDjiboutiSchedule(schedule)) return;
|
||
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
|
||
where: { trainScheduleId: schedule.id },
|
||
});
|
||
this.assertImportDjiboutiGatepassGranted(operation);
|
||
// Loading confirmation does NOT gate dispatch. Per-booking loading is
|
||
// tracking only and the loaded-on-train step is optional — a scheduled train
|
||
// dispatches without waiting on loading.
|
||
}
|
||
|
||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
|
||
}
|
||
return schedule;
|
||
}
|
||
|
||
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!this.isDjiboutiGatepassSchedule(schedule)) {
|
||
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
|
||
}
|
||
return schedule;
|
||
}
|
||
|
||
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
|
||
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
|
||
}
|
||
|
||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
return (
|
||
direction === 'IMPORT' &&
|
||
this.isDjiboutiPortDestination(
|
||
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
|
||
)
|
||
);
|
||
}
|
||
|
||
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
return (
|
||
direction === 'EXPORT' &&
|
||
this.isDjiboutiPortDestination(
|
||
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
|
||
)
|
||
);
|
||
}
|
||
|
||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||
if (existing) return existing;
|
||
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
|
||
}
|
||
|
||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||
void operation;
|
||
return [];
|
||
}
|
||
|
||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||
if (!operation?.gatepassGrantedAt) {
|
||
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
|
||
}
|
||
}
|
||
|
||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
|
||
return {
|
||
trainScheduleId: schedule.id,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
direction: schedule.direction ?? null,
|
||
status: {
|
||
documentsComplete: missingDocuments.length === 0,
|
||
missingDocuments,
|
||
gatepassStatus,
|
||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
|
||
loadListGenerated: Boolean(operation.loadListGeneratedAt),
|
||
},
|
||
documents: operation.documents ?? {},
|
||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
|
||
gatepassStatus,
|
||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
|
||
performedBy: operation.performedBy ?? null,
|
||
notes: operation.notes ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
|
||
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
|
||
* concurrent dispatches can't grab the same number. Throws when the pool is
|
||
* exhausted. Idempotent: returns the existing number if already assigned.
|
||
*/
|
||
private async assignTrainNumber(
|
||
manager: EntityManager,
|
||
schedule: TrainSchedule,
|
||
): Promise<string> {
|
||
if (schedule.trainNumber) {
|
||
// Creation-assigned pair number: two live runs may never share a number,
|
||
// so block dispatch while another DISPATCHED schedule still carries it.
|
||
const clash = await manager
|
||
.getRepository(TrainSchedule)
|
||
.createQueryBuilder('s')
|
||
.where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||
.andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
|
||
.andWhere('s.id != :id', { id: schedule.id })
|
||
.getOne();
|
||
if (clash) {
|
||
throw new ConflictException(
|
||
`Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
|
||
);
|
||
}
|
||
return schedule.trainNumber;
|
||
}
|
||
|
||
// Count container vs bulk wagons from the planned allocations.
|
||
let containerWagons = 0;
|
||
let bulkWagons = 0;
|
||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
|
||
if (isBulk) bulkWagons += 1;
|
||
else containerWagons += 1;
|
||
}
|
||
|
||
const direction =
|
||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||
(schedule.originStation && schedule.destinationStation
|
||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||
: null);
|
||
|
||
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
|
||
|
||
// Lock the set of currently-active numbered schedules so two concurrent
|
||
// dispatches serialize and can't both claim the same lowest-free number.
|
||
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
|
||
// creation and must be invisible to pool picks.
|
||
const activeNumbered = await manager
|
||
.getRepository(TrainSchedule)
|
||
.createQueryBuilder('schedule')
|
||
.setLock('pessimistic_write')
|
||
.where('schedule.status IN (:...statuses)', {
|
||
statuses: [
|
||
TrainScheduleStatusEnum.Draft,
|
||
TrainScheduleStatusEnum.Scheduled,
|
||
TrainScheduleStatusEnum.Dispatched,
|
||
],
|
||
})
|
||
.andWhere('schedule.train_number IS NOT NULL')
|
||
.getMany();
|
||
|
||
// Every typed train pair is reserved for its train — the pool may never
|
||
// hand one out, even when that train has no active schedule right now.
|
||
const pairRows: { n: string }[] = await manager.query(
|
||
`SELECT import_train_number AS n FROM freight.trains
|
||
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
|
||
UNION
|
||
SELECT export_train_number FROM freight.trains
|
||
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
|
||
);
|
||
|
||
const usedNumbers = [
|
||
...activeNumbered
|
||
.map((s) => s.trainNumber)
|
||
.filter((n): n is string => Boolean(n)),
|
||
...pairRows.map((row) => row.n),
|
||
];
|
||
|
||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||
if (!number) {
|
||
throw new ConflictException(
|
||
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
|
||
);
|
||
}
|
||
return number;
|
||
}
|
||
|
||
/** Open or close a schedule's booking window (staff override). */
|
||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||
if (status === 'OPEN') {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (schedule?.bookingWindowStatus === 'FULL') {
|
||
throw new ConflictException('Train is full — the booking window cannot be reopened');
|
||
}
|
||
}
|
||
await this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.update(scheduleId, { bookingWindowStatus: status });
|
||
void this.emitWindowState(scheduleId);
|
||
}
|
||
|
||
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
||
private async buildScheduleStations(schedule: TrainSchedule) {
|
||
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
|
||
const stations: Station[] = [];
|
||
|
||
const route = schedule.routeId
|
||
? await this.dataSource.getRepository(Route).findOne({
|
||
where: { id: schedule.routeId },
|
||
relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||
})
|
||
: null;
|
||
|
||
if (route) {
|
||
// `route.milestones` is the complete ordered corridor and already includes
|
||
// the origin (first) and destination (last) yards — `route.originYardId`
|
||
// and `route.destinationYardId` are derived from them. Use the milestones
|
||
// directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
|
||
const milestones = [...(route.milestones ?? [])].sort(
|
||
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
|
||
);
|
||
|
||
if (milestones.length > 0) {
|
||
milestones.forEach((m, i) =>
|
||
stations.push({
|
||
sequenceNo: i,
|
||
yardId: m.yardId,
|
||
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
|
||
code: m.yard?.code ?? '',
|
||
}),
|
||
);
|
||
return stations;
|
||
}
|
||
|
||
// Route with no milestones recorded — fall back to its origin/destination.
|
||
const origin = route.originYard;
|
||
const destination = route.destinationYard;
|
||
stations.push({
|
||
sequenceNo: 0,
|
||
yardId: route.originYardId,
|
||
label: origin?.label ?? origin?.code ?? 'Origin',
|
||
code: origin?.code ?? '',
|
||
});
|
||
stations.push({
|
||
sequenceNo: 1,
|
||
yardId: route.destinationYardId,
|
||
label: destination?.label ?? destination?.code ?? 'Destination',
|
||
code: destination?.code ?? '',
|
||
});
|
||
return stations;
|
||
}
|
||
|
||
// Fallback: no route milestones — just origin → destination from the schedule stations.
|
||
stations.push({
|
||
sequenceNo: 0,
|
||
yardId: schedule.originStationId,
|
||
label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
|
||
code: schedule.originStation?.code ?? '',
|
||
});
|
||
stations.push({
|
||
sequenceNo: 1,
|
||
yardId: schedule.destinationStationId,
|
||
label:
|
||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
|
||
code: schedule.destinationStation?.code ?? '',
|
||
});
|
||
return stations;
|
||
}
|
||
|
||
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
|
||
async getScheduleCheckpoints(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const stations = await this.buildScheduleStations(schedule);
|
||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||
|
||
// Resolve each checkpoint's position by its yard against the canonical
|
||
// corridor rather than the stored sequenceNo, so legacy checkpoints logged
|
||
// under an older station numbering still line up with the current stations.
|
||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||
const resolvedSeq = (e: TrainCheckpointEvent) =>
|
||
seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||
|
||
const currentSequenceNo = events.length
|
||
? Math.max(...events.map(resolvedSeq))
|
||
: -1;
|
||
|
||
return {
|
||
scheduleId,
|
||
status: schedule.status,
|
||
direction: schedule.direction ?? null,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
actualDepartureAt: schedule.actualDepartureAt
|
||
? schedule.actualDepartureAt.toISOString()
|
||
: null,
|
||
actualArrivalAt: schedule.actualArrivalAt
|
||
? schedule.actualArrivalAt.toISOString()
|
||
: null,
|
||
scheduledDepartureAt: schedule.scheduledDepartureDate
|
||
? schedule.scheduledDepartureDate.toISOString()
|
||
: null,
|
||
scheduledArrivalAt: schedule.scheduledArrivalDate
|
||
? schedule.scheduledArrivalDate.toISOString()
|
||
: null,
|
||
origin: stations[0]?.label ?? null,
|
||
destination: stations[stations.length - 1]?.label ?? null,
|
||
stations,
|
||
currentSequenceNo,
|
||
checkpoints: events.map((e) => ({
|
||
id: e.id,
|
||
sequenceNo: resolvedSeq(e),
|
||
yardId: e.yardId,
|
||
label: e.yard?.label ?? e.yard?.code ?? null,
|
||
kind: e.kind,
|
||
occurredAt: e.occurredAt.toISOString(),
|
||
note: e.note ?? null,
|
||
})),
|
||
};
|
||
}
|
||
|
||
/** Log the train passing a station. Logging the destination station triggers arrival. */
|
||
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||
throw new BadRequestException('Only DISPATCHED trains can be tracked');
|
||
}
|
||
|
||
const stations = await this.buildScheduleStations(schedule);
|
||
const finalSeq = stations[stations.length - 1].sequenceNo;
|
||
const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
|
||
if (!station) {
|
||
throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
|
||
}
|
||
|
||
const kind =
|
||
dto.kind ??
|
||
(dto.sequenceNo === 0
|
||
? TrainCheckpointKind.Departed
|
||
: dto.sequenceNo === finalSeq
|
||
? TrainCheckpointKind.Arrived
|
||
: TrainCheckpointKind.Passed);
|
||
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
||
|
||
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
||
});
|
||
if (existing) {
|
||
await this.trainCheckpointEventsRepository.update(existing.id, {
|
||
kind,
|
||
occurredAt,
|
||
note: dto.note ?? null,
|
||
yardId: station.yardId,
|
||
});
|
||
} else {
|
||
await this.trainCheckpointEventsRepository.create({
|
||
trainScheduleId: scheduleId,
|
||
yardId: station.yardId,
|
||
sequenceNo: dto.sequenceNo,
|
||
kind,
|
||
occurredAt,
|
||
note: dto.note ?? null,
|
||
});
|
||
}
|
||
|
||
if (dto.sequenceNo === finalSeq) {
|
||
await this.arriveSchedule(scheduleId);
|
||
} else {
|
||
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
||
// moment the train is recorded here — the yard operator no longer has to
|
||
// unload each one by hand. The final station is covered by
|
||
// arriveSchedule's bulk fallback above.
|
||
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
|
||
// A pass is also a position fix: the locomotives, every wagon still
|
||
// aboard, and the built train are physically AT this yard now — not at
|
||
// the origin they departed from. Wagons released at earlier stops no
|
||
// longer carry this schedule id and stay where they alighted; the final
|
||
// arrival settle still writes the wagon-movement ledger rows.
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
if (locoIds.length) {
|
||
await manager
|
||
.getRepository(Locomotive)
|
||
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
|
||
}
|
||
await manager
|
||
.getRepository(Wagon)
|
||
.update(
|
||
{ currentTrainScheduleId: scheduleId },
|
||
{ currentYardId: station.yardId },
|
||
);
|
||
if (schedule.trainSet?.trainId) {
|
||
await manager
|
||
.getRepository(Train)
|
||
.update(schedule.trainSet.trainId, { currentYardId: station.yardId });
|
||
}
|
||
});
|
||
}
|
||
|
||
return this.getScheduleCheckpoints(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||
* and wagons to the destination yard, and free the assets for re-use.
|
||
*/
|
||
async arriveSchedule(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||
}
|
||
|
||
const now = new Date();
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
scheduleId,
|
||
TrainScheduleStatusEnum.Arrived,
|
||
{
|
||
actualArrivalAt: now,
|
||
// Freeze the plan before the wagons below are released to their yards.
|
||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||
schedule,
|
||
TrainScheduleStatusEnum.Arrived,
|
||
now,
|
||
),
|
||
},
|
||
manager,
|
||
);
|
||
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
status: 'COMPLETED',
|
||
});
|
||
}
|
||
// A built train arrives with its schedule: settle it at the destination
|
||
// yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait).
|
||
if (schedule.trainSet?.trainId) {
|
||
await this.syncBuiltTrainAfterScheduleChange(
|
||
manager,
|
||
schedule.trainSet.trainId,
|
||
schedule.destinationStationId,
|
||
);
|
||
}
|
||
|
||
// Per-booking journey: bookings destined for the FINAL yard that the
|
||
// operator didn't unload individually get their arrival stamped now as a
|
||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||
// their own unload (possibly already done while the train kept rolling).
|
||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||
|
||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||
// to the destination yard where it physically arrived.
|
||
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
if (arrivedLocoIds.length) {
|
||
await manager.getRepository(Locomotive).update(
|
||
{ id: In(arrivedLocoIds) },
|
||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||
);
|
||
}
|
||
|
||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||
if (!slot.physicalWagonId) continue;
|
||
const wagon = await manager
|
||
.getRepository(Wagon)
|
||
.findOne({ where: { id: slot.physicalWagonId } });
|
||
if (!wagon) continue;
|
||
// A wagon that already alighted mid-route (unload released it, possibly
|
||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
||
// blanket at the train's destination.
|
||
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||
await manager.getRepository(Wagon).update(wagon.id, {
|
||
currentTrainScheduleId: null,
|
||
trainSetWagonId: null,
|
||
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
|
||
// only loose wagons return to the open AVAILABLE pool.
|
||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||
currentYardId: settleYardId,
|
||
});
|
||
// Ledger: the wagon rode this schedule to its settle yard.
|
||
const slotAllocations = slot.allocations ?? [];
|
||
await manager.getRepository(WagonMovement).save(
|
||
manager.getRepository(WagonMovement).create({
|
||
wagonId: wagon.id,
|
||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||
toYardId: settleYardId,
|
||
trainScheduleId: scheduleId,
|
||
bookingId: slotAllocations[0]?.bookingId ?? null,
|
||
kind: slotAllocations.length
|
||
? WagonMovementKind.Loaded
|
||
: WagonMovementKind.EmptyReposition,
|
||
occurredAt: now,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||
const stations = await this.buildScheduleStations(schedule);
|
||
const finalStation = stations[stations.length - 1];
|
||
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
|
||
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
|
||
});
|
||
if (!existingFinal) {
|
||
await manager.getRepository(TrainCheckpointEvent).save(
|
||
manager.getRepository(TrainCheckpointEvent).create({
|
||
trainScheduleId: scheduleId,
|
||
yardId: finalStation.yardId,
|
||
sequenceNo: finalStation.sequenceNo,
|
||
kind: TrainCheckpointKind.Arrived,
|
||
occurredAt: now,
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
|
||
// Customer tracking: the train reached the corridor's far end. Restricted
|
||
// to bookings destined for the FINAL yard — mid-corridor bookings get their
|
||
// arrival milestone from their own operator unload at their own yard.
|
||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||
void this.completeMilestonesForScheduleBookings(
|
||
scheduleId,
|
||
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||
{ destinationYardId: schedule.destinationStationId },
|
||
);
|
||
}
|
||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||
|
||
const detail = await this.getTrainScheduleById(scheduleId);
|
||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||
return Object.assign(detail, { warehouseAutomation });
|
||
}
|
||
|
||
async getContainerTrainSchedules(
|
||
query: ListTrainSchedulesQueryDto = {},
|
||
allowedDirections?: string[],
|
||
) {
|
||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||
|
||
// Per-user trade-direction scope: schedules carry a `direction` column.
|
||
if (allowedDirections && allowedDirections.length === 0) {
|
||
return {
|
||
items: [],
|
||
meta: buildPaginationMeta(0, page, pageSize),
|
||
};
|
||
}
|
||
|
||
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||
// the bookings aboard — no column to match — so it rides on `id` as an
|
||
// EXISTS fragment instead.
|
||
const base: FindOptionsWhere<TrainSchedule> = {};
|
||
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||
if (query.status) base.status = query.status;
|
||
if (query.originStationId) base.originStationId = query.originStationId;
|
||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
|
||
|
||
// Search fans out across every human-recognizable label; each OR variant
|
||
// repeats the base filters so the search never widens them.
|
||
const term = query.search?.trim();
|
||
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
|
||
base;
|
||
if (term) {
|
||
const like = ILike(`%${term}%`);
|
||
where = [
|
||
{ ...base, reference: like as never },
|
||
{ ...base, trainNumber: like as never },
|
||
{ ...base, originStation: { label: like } },
|
||
{ ...base, destinationStation: { label: like } },
|
||
{ ...base, route: { originYard: { label: like } } },
|
||
{ ...base, route: { destinationYard: { label: like } } },
|
||
{ ...base, trainSet: { locomotive: { code: like } } },
|
||
] as FindOptionsWhere<TrainSchedule>[];
|
||
}
|
||
|
||
// Newest-created first (the client can re-sort; this is the default order).
|
||
const sortBy = query.sortBy ?? 'createdAt';
|
||
const sortOrder = query.sortOrder ?? 'DESC';
|
||
|
||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||
where,
|
||
relations: {
|
||
trainSet: {
|
||
locomotive: true,
|
||
locomotives: { locomotive: true },
|
||
train: true,
|
||
// Slot allocations back the list's "used wagons" figure — without
|
||
// them the row can only report the coupled consist size, which is
|
||
// what made the list disagree with the detail page's wagon plan.
|
||
wagons: { allocations: true },
|
||
},
|
||
// Yards carry the route's display name used by mapScheduleListItem;
|
||
// milestones (with yards) let it show the full corridor path.
|
||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||
originStation: true,
|
||
destinationStation: true,
|
||
scheduleBookings: { booking: true },
|
||
},
|
||
order: { [sortBy]: sortOrder } as never,
|
||
skip,
|
||
take,
|
||
});
|
||
return {
|
||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
||
meta: buildPaginationMeta(total, page, pageSize),
|
||
};
|
||
}
|
||
|
||
async getContainerTrainScheduleById(id: string) {
|
||
return this.getTrainScheduleById(id);
|
||
}
|
||
|
||
async cancelTrainSchedule(id: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
if (
|
||
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||
schedule.status !== TrainScheduleStatusEnum.Scheduled
|
||
) {
|
||
throw new BadRequestException(
|
||
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainSchedulesRepository.updateStatus(
|
||
id,
|
||
TrainScheduleStatusEnum.Cancelled,
|
||
{
|
||
// Retire the booking window so a canceled schedule never lingers as an
|
||
// "open window" in booking-window lists or the legacy batch fill.
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'DONE',
|
||
// Freeze the plan before the wagons below are released back to the yard.
|
||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||
schedule,
|
||
TrainScheduleStatusEnum.Cancelled,
|
||
now,
|
||
),
|
||
},
|
||
manager,
|
||
);
|
||
if (schedule.trainSetId) {
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||
}
|
||
// Cancelled run: the built train never left — re-derive its status
|
||
// (back to AVAILABLE unless other runs still reference it).
|
||
if (schedule.trainSet?.trainId) {
|
||
await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
|
||
}
|
||
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
|
||
// but never stomp a locomotive that is currently pulling another dispatched train.
|
||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||
if (cancelledLocoIds.length) {
|
||
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
|
||
cancelledLocoIds,
|
||
id,
|
||
manager,
|
||
);
|
||
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
|
||
if (releasable.length) {
|
||
await manager
|
||
.getRepository(Locomotive)
|
||
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
|
||
}
|
||
}
|
||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||
if (wagon.physicalWagonId) {
|
||
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
|
||
currentTrainScheduleId: null,
|
||
trainSetWagonId: null,
|
||
// Built-train wagons stay coupled to their train (ASSIGNED); loose
|
||
// wagons return to the open AVAILABLE pool.
|
||
status: wagon.physicalWagon?.trainId
|
||
? WagonStatus.Assigned
|
||
: WagonStatus.Available,
|
||
// A cancelled train never left — its wagons stay/return at the origin
|
||
// yard, free to be re-pinned onto another schedule from there.
|
||
currentYardId: schedule.originStationId,
|
||
});
|
||
}
|
||
}
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
const booking = await this.bookingsRepository.findById(sb.bookingId);
|
||
// Detach from the cancelled schedule — clear the pointer so the freed
|
||
// booking can be assigned to another train. Leaving it set orphans the
|
||
// booking against a schedule that is about to be gone.
|
||
await this.bookingsRepository.updateSchedulingFields(
|
||
sb.bookingId,
|
||
{
|
||
schedulingStatus: this.resolvePostUnassignStatus(booking),
|
||
trainScheduleId: null,
|
||
},
|
||
manager,
|
||
);
|
||
}
|
||
});
|
||
|
||
// Best-effort customer notice (SMS + email + in-app) — the cancel itself has
|
||
// already committed, so a notification failure must never fail the cancel.
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
const booking = await this.bookingsRepository
|
||
.findByIdWithFiles(sb.bookingId)
|
||
.catch(() => null);
|
||
if (booking) this.bookingNotifier.scheduleCancelled(booking);
|
||
}
|
||
|
||
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||
void this.emitWindowState(id);
|
||
return this.getTrainScheduleById(id);
|
||
}
|
||
|
||
private async getTrainScheduleById(id: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||
}
|
||
return this.mapScheduleDetail(schedule);
|
||
}
|
||
|
||
private async validateBookingsForScheduling(
|
||
dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
|
||
freightType: 'CONTAINER' | 'BULK' | null,
|
||
forceAssign = false,
|
||
containerPlacements: ContainerPlacementInput[] = [],
|
||
requireContainerPlacements = false,
|
||
trainLimits: Required<TrainLimitConfig>,
|
||
targetScheduleId?: string,
|
||
) {
|
||
const bookingIds = [...new Set(dto.bookingIds)];
|
||
if (!bookingIds.length) {
|
||
throw new BadRequestException('At least one booking is required');
|
||
}
|
||
|
||
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
|
||
const violations: string[] = [];
|
||
const warnings: string[] = [];
|
||
|
||
if (bookings.length !== bookingIds.length) {
|
||
const foundIds = new Set(bookings.map((b) => b.id));
|
||
violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`);
|
||
}
|
||
|
||
const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds);
|
||
const conflictingLinks = targetScheduleId
|
||
? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId)
|
||
: scheduledLinks;
|
||
if (conflictingLinks.length > 0) {
|
||
violations.push('One or more selected bookings are already assigned to a train schedule');
|
||
}
|
||
|
||
const bookingTypes = new Set(bookings.map((b) => b.freightType));
|
||
const isMixed = bookingTypes.size > 1;
|
||
const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' =
|
||
freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK'));
|
||
|
||
if (freightType === 'CONTAINER' || freightType === 'BULK') {
|
||
const wrongType = bookings.filter((b) => b.freightType !== freightType);
|
||
if (wrongType.length) {
|
||
violations.push(`Only ${freightType} bookings are supported`);
|
||
}
|
||
}
|
||
|
||
const invalidStatus = bookings.filter(
|
||
(b) =>
|
||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
|
||
!b.isGovernment,
|
||
);
|
||
if (invalidStatus.length) {
|
||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||
violations.push(
|
||
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
// Corridor-aware: a booking belongs on this train when its origin and
|
||
// destination lie on the schedule's stop list in order — sub-corridor
|
||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
|
||
// stop list is also what makes the wagon plan leg-aware below.
|
||
let stops = [dto.originStationId, dto.destinationStationId];
|
||
if (targetScheduleId) {
|
||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||
if (target) stops = await this.stopYardsForSchedule(target);
|
||
}
|
||
if (
|
||
bookings.some((b) => {
|
||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||
return false;
|
||
}
|
||
const fromIdx = stops.indexOf(b.originYardId);
|
||
const toIdx = stops.indexOf(b.destinationYardId);
|
||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||
})
|
||
) {
|
||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||
}
|
||
|
||
// Day-match: a booking scheduled for a specific EAT day must board a train
|
||
// departing that same day. forceAssign downgrades a mismatch to a warning
|
||
// so staff can knowingly move a booking onto an adjacent-day train.
|
||
const scheduleDay = eatDay(new Date(dto.scheduleDate));
|
||
const dayMismatched = bookings.filter(
|
||
(b) =>
|
||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||
b.scheduledDate != null &&
|
||
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
|
||
);
|
||
if (dayMismatched.length) {
|
||
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
|
||
.map((b) => b.reference ?? b.id)
|
||
.join(', ')}`;
|
||
if (forceAssign) {
|
||
warnings.push(message);
|
||
} else {
|
||
violations.push(message);
|
||
}
|
||
}
|
||
|
||
if (!forceAssign) {
|
||
for (const booking of bookings) {
|
||
if (this.isHoldActive(booking)) {
|
||
warnings.push(
|
||
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
|
||
);
|
||
}
|
||
// Overweight is the soft threshold (maxVgmTons): the customer already
|
||
// paid the overweight surcharge at booking. The hard ceiling
|
||
// (maxCapacityTons) blocks booking creation, so anything reaching
|
||
// scheduling is shippable — warn the planner, never block allocation.
|
||
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
|
||
if (overweightLines.length) {
|
||
warnings.push(
|
||
`Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wagon-type resolution is list-based: each container/cargo type carries
|
||
// the wagon types that can haul it, and the plan mixes wagon types within
|
||
// one consist. A schedule created from a built train (Train Builder) plans
|
||
// against ONLY that train's own wagons — full when every consist wagon is
|
||
// allocated; legacy schedules plan against the boarding yards' pool.
|
||
const allowed = await this.loadAllowedWagonTypes(bookings);
|
||
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
|
||
|
||
const originYardId = dto.originStationId;
|
||
const stock: WagonStock = await this.wagonStockForSchedule(
|
||
targetScheduleId,
|
||
originYardId,
|
||
bookings.map((b) => b.originYardId),
|
||
builtTrainId,
|
||
);
|
||
|
||
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||
const legByBookingId = new Map(
|
||
bookings.flatMap((b) => {
|
||
const from = stops.indexOf(b.originYardId);
|
||
const to = stops.indexOf(b.destinationYardId);
|
||
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||
}),
|
||
);
|
||
const planned = planWagonsWithStock({
|
||
bookings,
|
||
allowed,
|
||
stock,
|
||
legs: legByBookingId,
|
||
edgeCount: Math.max(1, stops.length - 1),
|
||
});
|
||
violations.push(...planned.configIssues);
|
||
const fittingBookings = planned.fitting;
|
||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
|
||
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
|
||
// the stored train order, allocations and snapshot all carry the reversed
|
||
// order together. No-op unless the schedule set the flag.
|
||
const wagonPlan = applyWagonOrderReversal(
|
||
planned.plan,
|
||
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
|
||
);
|
||
|
||
// Availability rows come from the BOUNDED plan — the one that actually
|
||
// mixes wagon types against real stock. The old unbounded "pure demand"
|
||
// plan had infinite stock of every allowed type, so its tie-break parked a
|
||
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
|
||
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
|
||
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
|
||
// the deferred bookings' own shortage rows.
|
||
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
|
||
planned.plan,
|
||
stock.remainingByTypeId,
|
||
stock.codesByTypeId,
|
||
);
|
||
warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
|
||
this.stampSlotLegs(
|
||
wagonPlan,
|
||
fittingBookings,
|
||
dto.originStationId,
|
||
dto.destinationStationId,
|
||
stops,
|
||
);
|
||
|
||
violations.push(
|
||
...(await this.validatePhysicalFleetForPlan(
|
||
wagonPlan,
|
||
originYardId,
|
||
targetScheduleId,
|
||
)),
|
||
);
|
||
|
||
const placementRules = {
|
||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||
};
|
||
|
||
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||
// locomotive capability) become warnings — staff owns the override. Physical
|
||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||
// wrong status) can never be forced and stay violations.
|
||
const pushLimit = (issues: string[]) =>
|
||
forceAssign ? warnings.push(...issues) : violations.push(...issues);
|
||
|
||
// The plan can mix wagon types, so limit math always runs against the
|
||
// distinct types actually planned (shortest length drives the wagon-count
|
||
// fallback — validateMixedTrainLimits generalizes the single-type check).
|
||
const plannedWagonTypes = [
|
||
...new Map(
|
||
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
|
||
).values(),
|
||
];
|
||
const stopLabelMap =
|
||
stops.length > 2 ? await this.yardLabelMap(stops) : new Map<string, string>();
|
||
const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId);
|
||
pushLimit(
|
||
validateMixedTrainLimitsPerEdge(
|
||
wagonPlan,
|
||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||
trainLimits,
|
||
stops,
|
||
stopLabels,
|
||
),
|
||
);
|
||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||
violations.push(
|
||
...validateContainerPlacements(
|
||
containerBookings,
|
||
wagonPlan,
|
||
containerPlacements,
|
||
placementRules,
|
||
legByBookingId,
|
||
Math.max(1, stops.length - 1),
|
||
),
|
||
);
|
||
violations.push(
|
||
...(await this.validateFleetContainers(containerPlacements, containerBookings)),
|
||
);
|
||
}
|
||
|
||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||
const totalTareTons = roundTons(
|
||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||
);
|
||
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
|
||
const totalLengthMeters = roundTons(
|
||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||
);
|
||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||
// above — the whole-route totals here are informational (summary) only. The
|
||
// locomotive checks below also compare per edge: a train is never heavier
|
||
// than its heaviest leg, so disjoint legs must not be summed.
|
||
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
|
||
const maxEdgeGrossTons = roundTons(
|
||
Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)),
|
||
);
|
||
const maxEdgeLengthMeters = roundTons(
|
||
Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)),
|
||
);
|
||
const legName = (edge: number) =>
|
||
stops.length > 2 ? `${stopLabels[edge]} → ${stopLabels[edge + 1]}` : 'the route';
|
||
|
||
let assignedLocomotives: Locomotive[] = [];
|
||
if (targetScheduleId) {
|
||
const targetSchedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
|
||
}
|
||
|
||
if (assignedLocomotives.length) {
|
||
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
|
||
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
||
// is a hard violation.
|
||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
|
||
if (offYard) {
|
||
warnings.push(
|
||
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||
);
|
||
}
|
||
if (setLimits) {
|
||
// Name every leg the set cannot pull — staff must see WHERE along the
|
||
// corridor the train is too heavy/long, not just that it is somewhere.
|
||
const weightCap =
|
||
setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0);
|
||
const lengthCap =
|
||
setLimits.maxTrainLengthMeters +
|
||
(Number(setLimits.overageToleranceMeters) || 0);
|
||
const legIssues = perEdgeUsage.flatMap((e) => {
|
||
const issues: string[] = [];
|
||
if (roundTons(e.grossWeightTons) > weightCap) {
|
||
issues.push(
|
||
`Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`,
|
||
);
|
||
}
|
||
if (roundTons(e.lengthMeters) > lengthCap) {
|
||
issues.push(
|
||
`Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`,
|
||
);
|
||
}
|
||
return issues;
|
||
});
|
||
if (legIssues.length) pushLimit(legIssues);
|
||
}
|
||
} else {
|
||
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||
});
|
||
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
|
||
warnings.push(
|
||
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
|
||
);
|
||
}
|
||
if (
|
||
!inServiceLocomotives.some(
|
||
(l) =>
|
||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||
maxEdgeGrossTons &&
|
||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||
maxEdgeLengthMeters,
|
||
)
|
||
) {
|
||
pushLimit(['No locomotive can support the total train weight and length']);
|
||
}
|
||
}
|
||
|
||
const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))];
|
||
|
||
return {
|
||
valid: violations.length === 0,
|
||
violations,
|
||
warnings,
|
||
bookings: fittingBookings,
|
||
wagonPlan,
|
||
fleetAvailability,
|
||
deferredBookings,
|
||
summary: {
|
||
totalBookings: fittingBookings.length,
|
||
totalWeightTons,
|
||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||
grossWeightTons,
|
||
totalTareTons,
|
||
// Human-readable wagon type(s) of the plan — mixed consists list all.
|
||
wagonType: plannedTypeCodes.join('/') || 'NONE',
|
||
wagonsNeeded: wagonPlan.length,
|
||
totalLengthMeters,
|
||
freightMode: resolvedMode,
|
||
},
|
||
};
|
||
}
|
||
|
||
private async loadGlobalRulesRow(): Promise<TrainSchedulingGlobalRules | null> {
|
||
try {
|
||
const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({
|
||
order: { createdAt: 'ASC' },
|
||
take: 1,
|
||
});
|
||
return rows[0] ?? null;
|
||
} catch (err) {
|
||
// A read failure here silently downgrades every booking window to the
|
||
// hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings
|
||
// UI keeps showing the saved row — a maddening mismatch. The usual cause
|
||
// is a missing column (migrations not run on this database). Scream.
|
||
this.logger.error(
|
||
`Failed to read train-scheduling global rules — booking windows are ` +
|
||
`running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` +
|
||
`Cause: ${(err as Error).message}`,
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async resolveTrainLimitConfig(
|
||
dto?: {
|
||
maxTrainWeightTons?: number;
|
||
maxTrainLengthMeters?: number;
|
||
maxWagonsPerTrain?: number;
|
||
},
|
||
locomotive?: LocomotiveLimits | null,
|
||
builtWagonCount?: number,
|
||
): Promise<Required<TrainLimitConfig>> {
|
||
const row = await this.loadGlobalRulesRow();
|
||
const configured = this.configService?.get<{
|
||
maxTrainWeightTons?: number;
|
||
maxTrainLengthMeters?: number;
|
||
maxWagonsPerTrain?: number;
|
||
}>('app.trainScheduling');
|
||
|
||
const ruleWeightCap =
|
||
dto?.maxTrainWeightTons ??
|
||
(row?.maxTrainWeightTons != null
|
||
? Number(row.maxTrainWeightTons)
|
||
: configured?.maxTrainWeightTons);
|
||
const ruleLengthCap =
|
||
dto?.maxTrainLengthMeters ??
|
||
(row?.maxTrainLengthMeters != null
|
||
? Number(row.maxTrainLengthMeters)
|
||
: configured?.maxTrainLengthMeters);
|
||
|
||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||
|
||
if (locomotive) {
|
||
// With a locomotive assigned its own limits are the single source of
|
||
// truth — global-rules / env caps do not floor them (a mis-set global
|
||
// row once capped every train at 14m). Only an explicit per-request dto
|
||
// override still applies.
|
||
const derived = deriveTrainCapacityFromLocomotive(
|
||
{
|
||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||
},
|
||
wagonTypes,
|
||
{
|
||
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||
},
|
||
);
|
||
return {
|
||
maxWeightTons: derived.maxWeightTons,
|
||
maxLengthMeters: derived.maxLengthMeters,
|
||
// A built train's own consist is the real capacity — the length-derived
|
||
// slot count is only an estimate for trains with no wagons coupled yet.
|
||
// Without this override, validation re-derives a DIFFERENT wagon cap
|
||
// than the one the train was actually built with (e.g. a 54-wagon
|
||
// consist rejected against a re-derived 53-slot cap that never matched
|
||
// what staff physically coupled).
|
||
maxWagonsPerTrain:
|
||
dto?.maxWagonsPerTrain != null
|
||
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
||
: builtWagonCount && builtWagonCount > 0
|
||
? builtWagonCount
|
||
: derived.maxWagonSlots,
|
||
max20ftContainerWeightTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftContainerWeightTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||
),
|
||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftPairWeightDiffTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||
),
|
||
};
|
||
}
|
||
|
||
const maxWeightTons = this.positiveNumber(
|
||
dto?.maxTrainWeightTons,
|
||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
||
);
|
||
const maxLengthMeters = this.positiveNumber(
|
||
dto?.maxTrainLengthMeters,
|
||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
||
);
|
||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
||
wagonTypes,
|
||
);
|
||
|
||
return {
|
||
maxWeightTons,
|
||
maxLengthMeters,
|
||
maxWagonsPerTrain: Math.floor(
|
||
this.positiveNumber(
|
||
dto?.maxWagonsPerTrain,
|
||
row?.maxWagonsPerTrain != null
|
||
? Number(row.maxWagonsPerTrain)
|
||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
||
),
|
||
),
|
||
max20ftContainerWeightTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
||
),
|
||
max20ftPairWeightDiffTons: this.positiveNumber(
|
||
undefined,
|
||
Number(row?.max20ftPairWeightDiffTons) ||
|
||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Every active wagon type: the slot count derives from the shortest wagon the
|
||
* fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
|
||
* 12.228m) and under-report how many wagons the train length allows.
|
||
*/
|
||
private async loadSchedulingWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
|
||
const types = await this.dataSource
|
||
.getRepository(WagonType)
|
||
.find({ where: { isActive: true } });
|
||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||
return [
|
||
{
|
||
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||
capacityTons: 70,
|
||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||
},
|
||
{
|
||
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||
capacityTons: 60,
|
||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Built train (Train Builder) behind a schedule's train set, if the schedule
|
||
* was created by picking a train instead of loose locomotives.
|
||
*/
|
||
private async builtTrainIdOfSchedule(
|
||
scheduleId: string | undefined,
|
||
manager?: EntityManager,
|
||
): Promise<string | null> {
|
||
if (!scheduleId) return null;
|
||
const runner = manager ?? this.dataSource;
|
||
const rows: { train_id: string | null }[] = await runner.query(
|
||
`SELECT tset.train_id
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
WHERE ts.id = $1`,
|
||
[scheduleId],
|
||
);
|
||
return rows[0]?.train_id ?? null;
|
||
}
|
||
|
||
private async countFleetAvailability(
|
||
originYardId: string,
|
||
targetScheduleId?: string,
|
||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
||
this.dataSource.getRepository(Wagon).find(),
|
||
this.dataSource.getRepository(WagonType).find(),
|
||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||
]);
|
||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||
const counts = new Map<string, { code: string; available: number }>();
|
||
|
||
for (const wagon of wagons) {
|
||
// Train-bound schedule: the built train's own consist IS the fleet — only
|
||
// its wagons count (wherever they currently sit; they travel with the
|
||
// train), and loose yard wagons never do.
|
||
if (builtTrainId) {
|
||
if (wagon.trainId !== builtTrainId) continue;
|
||
} else {
|
||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||
// consume a wagon here — the same physical wagon may serve the July 17
|
||
// and the July 20 run. A wagon is unusable only when it is coupled to a
|
||
// built train's consist, physically blocked, or out on a dispatched
|
||
// train right now.
|
||
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
|
||
if (wagon.trainId) continue;
|
||
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
|
||
if (
|
||
wagon.currentTrainScheduleId &&
|
||
wagon.currentTrainScheduleId !== targetScheduleId
|
||
) {
|
||
continue;
|
||
}
|
||
if (wagon.currentYardId !== originYardId) continue;
|
||
}
|
||
|
||
const typeId = wagon.wagonTypeId;
|
||
const code = typeCodeById.get(typeId) ?? typeId;
|
||
const existing = counts.get(typeId) ?? { code, available: 0 };
|
||
existing.available += 1;
|
||
counts.set(typeId, existing);
|
||
}
|
||
|
||
return [...counts.entries()].map(([wagonTypeId, value]) => ({
|
||
wagonTypeId,
|
||
wagonTypeCode: value.code,
|
||
available: value.available,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
|
||
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
|
||
* physical wagons, so the historical allocation survives those wagons being
|
||
* re-pinned onto later trains. `capturedStatus` is the status being applied.
|
||
*/
|
||
private buildWagonAllocationSnapshot(
|
||
schedule: TrainSchedule,
|
||
capturedStatus: TrainScheduleStatusEnum,
|
||
capturedAt: Date,
|
||
): WagonAllocationSnapshot {
|
||
const slots = [...(schedule.trainSet?.wagons ?? [])]
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((wagon) => ({
|
||
sequenceNo: wagon.sequenceNo,
|
||
trainSetWagonId: wagon.id,
|
||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||
wagonTypeId: wagon.wagonTypeId ?? null,
|
||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||
slotStatus: wagon.status ?? null,
|
||
boardYardId: wagon.boardYardId ?? null,
|
||
alightYardId: wagon.alightYardId ?? null,
|
||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.booking?.reference ?? null,
|
||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||
loadType: allocation.loadType ?? null,
|
||
containerNumbers: (allocation.containerItems ?? [])
|
||
.map((item) => item.containerNumber)
|
||
.filter((n): n is string => Boolean(n)),
|
||
})),
|
||
}));
|
||
|
||
return {
|
||
capturedStatus,
|
||
capturedAt: capturedAt.toISOString(),
|
||
slots,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A wagon in a blocked physical state can never be planned or pinned.
|
||
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
|
||
* train or stamped by a live run — schedule-level occupancy is tracked on
|
||
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
|
||
*/
|
||
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
|
||
return (
|
||
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Physical wagons already pinned to THIS schedule's slots. Availability is
|
||
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
|
||
* pins held by other schedules of the same train are irrelevant.
|
||
*/
|
||
private async pinnedPhysicalWagonIdsForSchedule(
|
||
scheduleId: string | undefined,
|
||
manager?: EntityManager,
|
||
): Promise<Set<string>> {
|
||
if (!scheduleId) return new Set();
|
||
const runner = manager ?? this.dataSource;
|
||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||
`SELECT tsw.physical_wagon_id
|
||
FROM freight.train_set_wagons tsw
|
||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||
WHERE ts.id = $1
|
||
AND ts.deleted_at IS NULL
|
||
AND tsw.deleted_at IS NULL
|
||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||
[scheduleId],
|
||
);
|
||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||
}
|
||
|
||
/**
|
||
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
|
||
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
||
* schedule-occupancy state anymore.
|
||
*/
|
||
/**
|
||
* Physical wagons pinned to any live run's slot. `excludeTrainId` drops the
|
||
* pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g.
|
||
* the paired return leg): a consist edit is an edit of the TRAIN, sibling
|
||
* runs ride whatever it is composed of and their pins are re-pointed by the
|
||
* edit itself. Only pins held by live schedules of OTHER trains block it.
|
||
*/
|
||
private async wagonIdsPinnedToLiveSchedules(
|
||
manager?: EntityManager,
|
||
excludeTrainId?: string,
|
||
): Promise<Set<string>> {
|
||
const runner = manager ?? this.dataSource;
|
||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||
`SELECT DISTINCT tsw.physical_wagon_id
|
||
FROM freight.train_set_wagons tsw
|
||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||
JOIN freight.train_sets tset ON tset.id = tsw.train_set_id
|
||
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||
AND ts.deleted_at IS NULL
|
||
AND tsw.deleted_at IS NULL
|
||
AND tsw.physical_wagon_id IS NOT NULL
|
||
AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`,
|
||
[excludeTrainId ?? null],
|
||
);
|
||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||
}
|
||
|
||
private async autoPinWagonsForSchedule(
|
||
manager: EntityManager,
|
||
scheduleId: string,
|
||
originYardId: string,
|
||
slots: TrainSetWagon[],
|
||
reverseWagonOrder = false,
|
||
) {
|
||
const wagons = await manager.getRepository(Wagon).find();
|
||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||
scheduleId,
|
||
manager,
|
||
);
|
||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||
|
||
const planSlots = [...slots]
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((slot) => ({
|
||
sequenceNo: slot.sequenceNo,
|
||
wagonTypeId: slot.wagonTypeId,
|
||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||
trainSetWagonId: slot.id,
|
||
boardYardId: slot.boardYardId ?? null,
|
||
alightYardId: slot.alightYardId ?? null,
|
||
}));
|
||
|
||
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
|
||
|
||
const unpinnable = this.findUnpinnableWagonSlots(
|
||
planSlots,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
builtTrainId,
|
||
pinnedToScheduleIds,
|
||
stops,
|
||
);
|
||
if (unpinnable.length) {
|
||
throw new BadRequestException({
|
||
message: 'Insufficient physical wagons to pin all train slots',
|
||
violations: unpinnable,
|
||
});
|
||
}
|
||
|
||
const occupiedSpans = new Map<string, Array<[number, number]>>();
|
||
for (const slot of planSlots) {
|
||
const span = this.slotSpanOf(slot, stops);
|
||
const physical = this.pickPhysicalWagonForSlot(
|
||
slot,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
occupiedSpans,
|
||
span,
|
||
builtTrainId,
|
||
pinnedToScheduleIds,
|
||
reverseWagonOrder,
|
||
);
|
||
if (!physical) continue;
|
||
|
||
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
|
||
// touched here, so the same physical wagon stays free for every other
|
||
// schedule (it gets stamped at dispatch, when it physically leaves).
|
||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||
physicalWagonId: physical.id,
|
||
status: 'RESERVED',
|
||
});
|
||
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
|
||
pinnedSpans.push(span);
|
||
occupiedSpans.set(physical.id, pinnedSpans);
|
||
}
|
||
}
|
||
|
||
/** Pre-assign check: every planned slot must have a matching physical wagon. */
|
||
private async validatePhysicalFleetForPlan(
|
||
wagonPlan: WagonPlanSlot[],
|
||
originYardId: string,
|
||
targetScheduleId?: string,
|
||
): Promise<string[]> {
|
||
if (!wagonPlan.length) return [];
|
||
|
||
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||
this.dataSource.getRepository(Wagon).find(),
|
||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||
]);
|
||
const targetSchedule = targetScheduleId
|
||
? await this.trainSchedulesRepository.findById(targetScheduleId)
|
||
: null;
|
||
const stops = targetSchedule
|
||
? await this.stopYardsForSchedule(targetSchedule)
|
||
: [];
|
||
return this.findUnpinnableWagonSlots(
|
||
wagonPlan.map((slot) => ({
|
||
sequenceNo: slot.sequenceNo,
|
||
wagonTypeId: slot.wagonTypeId,
|
||
wagonTypeCode: slot.wagonTypeCode,
|
||
boardYardId: slot.boardYardId ?? null,
|
||
alightYardId: slot.alightYardId ?? null,
|
||
})),
|
||
wagons,
|
||
targetScheduleId,
|
||
originYardId,
|
||
builtTrainId,
|
||
pinnedToScheduleIds,
|
||
stops,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Stop-index span [board, alight) a slot occupies along the route. Slots with
|
||
* unknown/missing yards conservatively span the whole route (never share).
|
||
*/
|
||
private slotSpanOf(
|
||
slot: { boardYardId?: string | null; alightYardId?: string | null },
|
||
stops: string[],
|
||
): [number, number] {
|
||
const last = Math.max(1, stops.length - 1);
|
||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last;
|
||
if (from < 0 || to < 0 || from >= to) return [0, last];
|
||
return [from, to];
|
||
}
|
||
|
||
private findUnpinnableWagonSlots(
|
||
slots: Array<{
|
||
sequenceNo: number;
|
||
wagonTypeId: string;
|
||
wagonTypeCode: string;
|
||
boardYardId?: string | null;
|
||
alightYardId?: string | null;
|
||
}>,
|
||
wagons: Wagon[],
|
||
scheduleId: string | undefined,
|
||
originYardId: string,
|
||
builtTrainId: string | null = null,
|
||
pinnedToScheduleIds: Set<string> = new Set(),
|
||
stops: string[] = [],
|
||
): string[] {
|
||
const violations: string[] = [];
|
||
// One physical wagon may serve several slots whose leg spans don't overlap
|
||
// (freed at its alight yard, reloaded downstream) — track occupied spans
|
||
// per wagon, not a flat taken-set.
|
||
const occupiedSpans = new Map<string, Array<[number, number]>>();
|
||
|
||
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
|
||
const span = this.slotSpanOf(slot, stops);
|
||
const physical = this.pickPhysicalWagonForSlot(
|
||
slot,
|
||
wagons,
|
||
scheduleId,
|
||
originYardId,
|
||
occupiedSpans,
|
||
span,
|
||
builtTrainId,
|
||
pinnedToScheduleIds,
|
||
);
|
||
if (!physical) {
|
||
violations.push(
|
||
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
|
||
);
|
||
continue;
|
||
}
|
||
const spans = occupiedSpans.get(physical.id) ?? [];
|
||
spans.push(span);
|
||
occupiedSpans.set(physical.id, spans);
|
||
}
|
||
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Dynamic consist: a slot's wagon may either ride from the train's origin
|
||
* yard (attaching there, possibly empty until the slot's board yard) or
|
||
* already sit AT the slot's board yard and hook on when the train arrives.
|
||
*/
|
||
private pickPhysicalWagonForSlot(
|
||
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||
wagons: Wagon[],
|
||
scheduleId: string | undefined,
|
||
originYardId: string,
|
||
occupiedSpans: Map<string, Array<[number, number]>>,
|
||
span: [number, number],
|
||
builtTrainId: string | null = null,
|
||
pinnedToScheduleIds: Set<string> = new Set(),
|
||
reverseWagonOrder = false,
|
||
): Wagon | undefined {
|
||
// Free for this slot = no already-assigned span on this wagon overlaps the
|
||
// slot's own leg. Disjoint legs (alight before board) share the wagon.
|
||
const spanFree = (wagonId: string): boolean =>
|
||
(occupiedSpans.get(wagonId) ?? []).every(
|
||
([from, to]) => to <= span[0] || span[1] <= from,
|
||
);
|
||
const usable = (wagon: Wagon): boolean => {
|
||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||
if (!spanFree(wagon.id)) return false;
|
||
// Loose pool never lends a wagon coupled to a built train's consist.
|
||
if (wagon.trainId) return false;
|
||
// Out on a dispatched train right now — physically gone.
|
||
if (
|
||
wagon.currentTrainScheduleId &&
|
||
wagon.currentTrainScheduleId !== scheduleId
|
||
) {
|
||
return false;
|
||
}
|
||
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
|
||
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
|
||
};
|
||
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
|
||
// wherever they currently sit (they travel with the train), never a loose
|
||
// yard wagon.
|
||
if (builtTrainId) {
|
||
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
|
||
// consist views draw the schedule exactly like the train builder; a schedule
|
||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||
const candidates = wagons
|
||
.filter(
|
||
(w) =>
|
||
w.trainId === builtTrainId &&
|
||
w.wagonTypeId === slot.wagonTypeId &&
|
||
spanFree(w.id),
|
||
)
|
||
.sort((a, b) => {
|
||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
|
||
}
|
||
return reverseWagonOrder
|
||
? b.sequenceNumber - a.sequenceNumber
|
||
: a.sequenceNumber - b.sequenceNumber;
|
||
});
|
||
return candidates[0];
|
||
}
|
||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||
// fall back to one riding from the train's origin.
|
||
if (slot.boardYardId) {
|
||
const atBoardYard = wagons.find(
|
||
(w) => usable(w) && w.currentYardId === slot.boardYardId,
|
||
);
|
||
if (atBoardYard) return atBoardYard;
|
||
}
|
||
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
|
||
}
|
||
|
||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
||
}
|
||
|
||
private async validateFleetContainers(
|
||
placements: ContainerPlacementInput[],
|
||
containerBookings: Booking[],
|
||
): Promise<string[]> {
|
||
const violations: string[] = [];
|
||
const inventoryIds = [
|
||
...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))),
|
||
];
|
||
if (!inventoryIds.length) return violations;
|
||
|
||
const lineById = new Map(
|
||
containerBookings.flatMap((b) =>
|
||
(b.bookingContainers ?? []).map((line) => [line.id, line] as const),
|
||
),
|
||
);
|
||
|
||
const containers = await this.dataSource.getRepository(Container).find({
|
||
where: { id: In(inventoryIds) },
|
||
});
|
||
const containerById = new Map(containers.map((c) => [c.id, c]));
|
||
|
||
for (const placement of placements) {
|
||
if (!placement.containerId) continue;
|
||
const fleet = containerById.get(placement.containerId);
|
||
if (!fleet) {
|
||
violations.push(`Fleet container ${placement.containerId} not found`);
|
||
continue;
|
||
}
|
||
if (fleet.status !== 'AVAILABLE') {
|
||
violations.push(`Container ${fleet.containerNumber} is not available`);
|
||
}
|
||
const line = lineById.get(placement.bookingContainerId);
|
||
if (line && fleet.containerTypeId !== line.containerTypeId) {
|
||
violations.push(
|
||
`Container ${fleet.containerNumber} type does not match booking line`,
|
||
);
|
||
}
|
||
if (
|
||
placement.containerNumber &&
|
||
fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase()
|
||
) {
|
||
violations.push(
|
||
`Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Resolve the wagon type for a batch through the cargo-type / container-type
|
||
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
|
||
* when the relevant type has no wagon type configured — scheduling is blocked
|
||
* until an admin assigns one on the cargo-type / container-type config screen.
|
||
*/
|
||
/**
|
||
* Wagon types allowed to carry each container/cargo type on these bookings.
|
||
* The many-to-many configuration lists (active wagon types only), keyed by
|
||
* type id — the flexible planner mixes wagon types within one consist.
|
||
* Bookings must arrive from findByIdsForScheduling so the `wagonTypes`
|
||
* relations are loaded.
|
||
*/
|
||
private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap {
|
||
const byContainerTypeId = new Map<string, WagonType[]>();
|
||
const byCargoTypeId = new Map<string, WagonType[]>();
|
||
const active = (list?: WagonType[] | null) =>
|
||
(list ?? []).filter((wt) => wt.isActive !== false);
|
||
|
||
for (const booking of bookings) {
|
||
for (const line of booking.bookingContainers ?? []) {
|
||
const containerType = line.containerType;
|
||
if (containerType && !byContainerTypeId.has(containerType.id)) {
|
||
byContainerTypeId.set(containerType.id, active(containerType.wagonTypes));
|
||
}
|
||
}
|
||
const cargoType = booking.cargoType;
|
||
if (cargoType && !byCargoTypeId.has(cargoType.id)) {
|
||
byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes));
|
||
}
|
||
}
|
||
return { byContainerTypeId, byCargoTypeId };
|
||
}
|
||
|
||
/**
|
||
* TRAIN-mode wagon stock: the built train's own consist, grouped by wagon
|
||
* type. This is the whole plannable pool for its schedules — the plan is
|
||
* full when every consist wagon is allocated.
|
||
*/
|
||
/**
|
||
* The physical wagons a schedule can actually plan against, by wagon type.
|
||
*
|
||
* A schedule built from a Train Builder train plans against ONLY that train's
|
||
* own consist. A legacy/dynamic-consist schedule plans against the boarding
|
||
* yards' loose pool: a slot's wagon may ride from the train's origin OR
|
||
* already sit at the booking's own boarding yard and attach there, so the
|
||
* usable fleet is the union across the origin and every boarding yard.
|
||
*
|
||
* Public because batch fill needs the SAME stock the allocator will later
|
||
* validate against — selecting a booking the allocator cannot place is how
|
||
* customers ended up paying for wagons that were never there.
|
||
*/
|
||
async wagonStockForSchedule(
|
||
scheduleId: string | undefined,
|
||
originYardId: string,
|
||
boardingYardIds: Array<string | null | undefined> = [],
|
||
preloadedBuiltTrainId?: string | null,
|
||
): Promise<WagonStock> {
|
||
const builtTrainId =
|
||
preloadedBuiltTrainId !== undefined
|
||
? preloadedBuiltTrainId
|
||
: await this.builtTrainIdOfSchedule(scheduleId);
|
||
if (builtTrainId) return this.builtTrainStock(builtTrainId);
|
||
|
||
const boardYardIds = [
|
||
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
|
||
];
|
||
const fleetCountsByYard = await Promise.all(
|
||
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
|
||
);
|
||
const remainingByTypeId = new Map<string, number>();
|
||
const codesByTypeId = new Map<string, string>();
|
||
for (const rows of fleetCountsByYard) {
|
||
for (const row of rows) {
|
||
remainingByTypeId.set(
|
||
row.wagonTypeId,
|
||
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
|
||
);
|
||
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
|
||
}
|
||
}
|
||
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||
}
|
||
|
||
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
|
||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||
where: { trainId: builtTrainId },
|
||
relations: { wagonType: true },
|
||
});
|
||
const remainingByTypeId = new Map<string, number>();
|
||
const codesByTypeId = new Map<string, string>();
|
||
for (const wagon of wagons) {
|
||
remainingByTypeId.set(
|
||
wagon.wagonTypeId,
|
||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||
);
|
||
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
||
}
|
||
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
|
||
}
|
||
|
||
/**
|
||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||
* boarding/alighting yards of the bookings it carries. Null means the
|
||
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
|
||
* carrying bookings with mixed corridors stays whole-route (conservative).
|
||
*/
|
||
private stampSlotLegs(
|
||
wagonPlan: WagonPlanSlot[],
|
||
bookings: Booking[],
|
||
scheduleOriginYardId: string,
|
||
scheduleDestinationYardId: string,
|
||
stops: string[],
|
||
): void {
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
for (const slot of wagonPlan) {
|
||
const slotBookings = [
|
||
...new Set(slot.allocations.map((a) => a.bookingId)),
|
||
]
|
||
.map((id) => bookingById.get(id))
|
||
.filter((b): b is Booking => Boolean(b));
|
||
if (!slotBookings.length) continue;
|
||
const [first] = slotBookings;
|
||
const sameCorridor = slotBookings.every(
|
||
(b) =>
|
||
b.originYardId === first.originYardId &&
|
||
b.destinationYardId === first.destinationYardId,
|
||
);
|
||
if (sameCorridor) {
|
||
slot.boardYardId =
|
||
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||
slot.alightYardId =
|
||
first.destinationYardId === scheduleDestinationYardId
|
||
? null
|
||
: first.destinationYardId;
|
||
continue;
|
||
}
|
||
// Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides
|
||
// the UNION of its cargo legs. A yard missing from the stop list keeps
|
||
// the slot on the whole route so capacity is never under-occupied.
|
||
let from = Number.POSITIVE_INFINITY;
|
||
let to = Number.NEGATIVE_INFINITY;
|
||
for (const b of slotBookings) {
|
||
const f = stops.indexOf(b.originYardId);
|
||
const t = stops.indexOf(b.destinationYardId);
|
||
if (f < 0 || t <= f) {
|
||
from = Number.POSITIVE_INFINITY;
|
||
break;
|
||
}
|
||
from = Math.min(from, f);
|
||
to = Math.max(to, t);
|
||
}
|
||
if (!Number.isFinite(from) || to <= from) continue;
|
||
slot.boardYardId = from === 0 ? null : stops[from];
|
||
slot.alightYardId = to === stops.length - 1 ? null : stops[to];
|
||
}
|
||
}
|
||
|
||
private async persistTrainSetWagons(
|
||
manager: EntityManager,
|
||
trainSetId: string,
|
||
wagonPlan: WagonPlanSlot[],
|
||
) {
|
||
const wagons = wagonPlan.map((slot) =>
|
||
manager.getRepository(TrainSetWagon).create({
|
||
trainSetId,
|
||
wagonTypeId: slot.wagonTypeId,
|
||
sequenceNo: slot.sequenceNo,
|
||
capacityTons: slot.capacityTons,
|
||
lengthMeters: slot.lengthMeters,
|
||
assignedWeightTons: slot.assignedWeightTons,
|
||
status: 'PLANNED',
|
||
boardYardId: slot.boardYardId ?? null,
|
||
alightYardId: slot.alightYardId ?? null,
|
||
}),
|
||
);
|
||
return manager.getRepository(TrainSetWagon).save(wagons);
|
||
}
|
||
|
||
private async persistAllocationsAndLoads(
|
||
manager: EntityManager,
|
||
savedWagons: TrainSetWagon[],
|
||
wagonPlan: WagonPlanSlot[],
|
||
bookings: Booking[],
|
||
containerPlacements: ContainerPlacementInput[] = [],
|
||
) {
|
||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||
const lineById = new Map(
|
||
bookings.flatMap((b) =>
|
||
(b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
|
||
),
|
||
);
|
||
const allocationBySlotBooking = new Map<string, string>();
|
||
|
||
const containerItems: Array<{
|
||
wagonBookingAllocationId: string;
|
||
bookingContainerId: string;
|
||
containerTypeId: string | null;
|
||
grossWeightTons: number;
|
||
positionOnWagon: number | null;
|
||
containerId?: string | null;
|
||
containerNumber?: string | null;
|
||
sealNumber?: string | null;
|
||
}> = [];
|
||
const bulkLoads: Array<{
|
||
wagonBookingAllocationId: string;
|
||
bookingId: string;
|
||
cargoTypeId: string | null;
|
||
cargoDescription: string | null;
|
||
weightTons: number;
|
||
quantity: number;
|
||
}> = [];
|
||
|
||
for (let i = 0; i < savedWagons.length; i += 1) {
|
||
const slot = wagonPlan[i];
|
||
const trainSetWagon = savedWagons[i];
|
||
if (!slot || !trainSetWagon) continue;
|
||
|
||
for (const alloc of slot.allocations) {
|
||
const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
|
||
manager.getRepository(WagonBookingAllocation).create({
|
||
trainSetWagonId: trainSetWagon.id,
|
||
bookingId: alloc.bookingId,
|
||
allocatedWeightTons: alloc.allocatedWeightTons,
|
||
loadType: alloc.loadType,
|
||
status: 'PLANNED',
|
||
}),
|
||
);
|
||
|
||
allocationBySlotBooking.set(
|
||
`${slot.sequenceNo}:${alloc.bookingId}`,
|
||
savedAllocation.id,
|
||
);
|
||
|
||
const booking = bookingById.get(alloc.bookingId);
|
||
if (!booking) continue;
|
||
|
||
if (alloc.loadType === AllocationLoadType.Bulk) {
|
||
bulkLoads.push({
|
||
wagonBookingAllocationId: savedAllocation.id,
|
||
bookingId: booking.id,
|
||
cargoTypeId: booking.cargoTypeId ?? null,
|
||
cargoDescription: booking.cargoFreeText ?? null,
|
||
weightTons: alloc.allocatedWeightTons,
|
||
quantity: 1,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const placement of containerPlacements) {
|
||
const lineEntry = lineById.get(placement.bookingContainerId);
|
||
if (!lineEntry) continue;
|
||
|
||
// Durably persist the container number on the booking container line first, so it
|
||
// survives a refresh regardless of whether a wagon allocation slot can be matched
|
||
// below. booking_container is the source of truth re-read into the preview units.
|
||
if (placement.containerNumber && placement.containerNumber.trim()) {
|
||
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
|
||
containerNumber: placement.containerNumber.trim(),
|
||
});
|
||
}
|
||
|
||
const allocationId = allocationBySlotBooking.get(
|
||
`${placement.sequenceNo}:${lineEntry.bookingId}`,
|
||
);
|
||
if (!allocationId) continue;
|
||
|
||
const { line } = lineEntry;
|
||
containerItems.push({
|
||
wagonBookingAllocationId: allocationId,
|
||
bookingContainerId: placement.bookingContainerId,
|
||
containerTypeId: line.containerTypeId ?? null,
|
||
grossWeightTons: Number(line.vgmPerUnitTons),
|
||
positionOnWagon: placement.unitIndex + 1,
|
||
containerId: placement.containerId ?? null,
|
||
containerNumber: placement.containerNumber?.trim() ?? null,
|
||
sealNumber: placement.sealNumber ?? null,
|
||
});
|
||
|
||
if (placement.containerId) {
|
||
await manager.getRepository(Container).update(placement.containerId, {
|
||
status: 'LOADED',
|
||
bookingId: lineEntry.bookingId,
|
||
wagonBookingAllocationId: allocationId,
|
||
bookingContainerId: placement.bookingContainerId,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (containerItems.length) {
|
||
await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager);
|
||
}
|
||
if (bulkLoads.length) {
|
||
await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* All locomotives attached to a loaded train set. Prefers the `locomotives`
|
||
* link rows; falls back to the legacy single `locomotive` for train sets
|
||
* created before multi-loco support.
|
||
*/
|
||
private locomotivesOfTrainSet(
|
||
trainSet: TrainSet | null | undefined,
|
||
): Locomotive[] {
|
||
if (!trainSet) return [];
|
||
const linked = (trainSet.locomotives ?? [])
|
||
.map((link) => link.locomotive)
|
||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||
if (linked.length) return linked;
|
||
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
||
}
|
||
|
||
/**
|
||
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
|
||
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
|
||
* legacy single-locomotive column on the train set.
|
||
*/
|
||
private async findLocomotiveIdsDispatchedElsewhere(
|
||
locomotiveIds: string[],
|
||
excludeScheduleId: string,
|
||
manager?: EntityManager,
|
||
): Promise<Set<string>> {
|
||
if (!locomotiveIds.length) return new Set();
|
||
const runner = manager ?? this.dataSource;
|
||
const rows: { locomotive_id: string }[] = await runner.query(
|
||
`SELECT DISTINCT loco.locomotive_id
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
JOIN (
|
||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||
FROM freight.train_set_locomotives tsl
|
||
WHERE tsl.deleted_at IS NULL
|
||
UNION
|
||
SELECT t.id AS train_set_id, t.locomotive_id
|
||
FROM freight.train_sets t
|
||
WHERE t.locomotive_id IS NOT NULL
|
||
) loco ON loco.train_set_id = tset.id
|
||
WHERE ts.status = 'DISPATCHED'
|
||
AND ts.deleted_at IS NULL
|
||
AND ts.id <> $1
|
||
AND loco.locomotive_id = ANY($2)`,
|
||
[excludeScheduleId, locomotiveIds],
|
||
);
|
||
return new Set(rows.map((r) => r.locomotive_id));
|
||
}
|
||
|
||
private async assertLocomotivesNotDispatchedElsewhere(
|
||
locomotiveIds: string[],
|
||
excludeScheduleId: string,
|
||
): Promise<void> {
|
||
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
|
||
locomotiveIds,
|
||
excludeScheduleId,
|
||
);
|
||
if (!busy.size) return;
|
||
const locos = await this.dataSource
|
||
.getRepository(Locomotive)
|
||
.find({ where: { id: In([...busy]) } });
|
||
const codes = locos.map((l) => l.code).join(', ');
|
||
throw new ConflictException(
|
||
`Locomotive(s) ${codes} are currently out on another dispatched train`,
|
||
);
|
||
}
|
||
|
||
async selectOrValidateLocomotive(
|
||
locomotiveId: string,
|
||
totalWeightTons: number,
|
||
totalLengthMeters: number,
|
||
) {
|
||
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||
if (!locomotive) {
|
||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||
}
|
||
if (locomotive.status !== 'AVAILABLE') {
|
||
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
|
||
}
|
||
if (
|
||
Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
|
||
totalWeightTons
|
||
) {
|
||
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
|
||
}
|
||
if (
|
||
Number(locomotive.maxTrainLengthMeters) +
|
||
(Number(locomotive.overageToleranceMeters) || 0) <
|
||
totalLengthMeters
|
||
) {
|
||
throw new BadRequestException(
|
||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||
);
|
||
}
|
||
return locomotive;
|
||
}
|
||
|
||
private async buildEmptyTrainSet(
|
||
manager: EntityManager,
|
||
locomotives: Locomotive[],
|
||
builtTrainId: string | null = null,
|
||
) {
|
||
const [primary] = locomotives;
|
||
const trainSet = manager.getRepository(TrainSet).create({
|
||
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
|
||
locomotiveId: primary.id,
|
||
// Built fleet train this set was formed from (Train Builder), if any.
|
||
trainId: builtTrainId,
|
||
totalWeightTons: 0,
|
||
totalLengthMeters: 0,
|
||
wagonCount: 0,
|
||
status: 'DRAFT',
|
||
});
|
||
const saved = await manager.getRepository(TrainSet).save(trainSet);
|
||
|
||
const links = locomotives.map((loco, index) =>
|
||
manager.getRepository(TrainSetLocomotive).create({
|
||
trainSetId: saved.id,
|
||
locomotiveId: loco.id,
|
||
sequenceNo: index,
|
||
}),
|
||
);
|
||
await manager.getRepository(TrainSetLocomotive).save(links);
|
||
|
||
return saved;
|
||
}
|
||
|
||
private async getSchedulableRoute(routeId: string) {
|
||
const route = await this.dataSource.getRepository(Route).findOne({
|
||
where: { id: routeId },
|
||
relations: { originYard: true, destinationYard: true },
|
||
});
|
||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||
if (route.status !== 'AVAILABLE') {
|
||
throw new BadRequestException(
|
||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||
);
|
||
}
|
||
// Intercity (same-country) service is not offered yet — only import/export
|
||
// trains can be scheduled.
|
||
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
|
||
throw new BadRequestException(
|
||
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
|
||
);
|
||
}
|
||
return route;
|
||
}
|
||
|
||
/** Stored route direction, deriving from yard countries for pre-migration rows. */
|
||
private resolveRouteDirection(route: Route) {
|
||
return (
|
||
route.direction ??
|
||
deriveScheduleDirection(
|
||
route.originYard ?? { country: null },
|
||
route.destinationYard ?? { country: null },
|
||
)
|
||
);
|
||
}
|
||
|
||
private mapEligibleBooking(
|
||
booking: Booking,
|
||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||
) {
|
||
return {
|
||
id: booking.id,
|
||
reference: booking.reference,
|
||
freightType: booking.freightType,
|
||
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
|
||
priorityScore: booking.priorityScore,
|
||
schedulingStatus: booking.schedulingStatus,
|
||
containerType:
|
||
booking.bookingContainers
|
||
?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
|
||
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
|
||
quantity:
|
||
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
|
||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||
weightTons: this.grossBookingWeightTons(booking, tareDims),
|
||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||
destination:
|
||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||
status: booking.status,
|
||
isGovernment: Boolean(booking.isGovernment),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
|
||
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
|
||
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
|
||
* Schedules with no bookings (type null) match nothing. Applied to `id` so
|
||
* the list query stays on findAndCount instead of a query-builder rewrite.
|
||
*/
|
||
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
|
||
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
|
||
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
|
||
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
|
||
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
|
||
`AND b.freight_type ${cmp} :${param})`;
|
||
if (freightType === 'MIXED') {
|
||
return Raw(
|
||
(alias) =>
|
||
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
|
||
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
|
||
);
|
||
}
|
||
return Raw(
|
||
(alias) =>
|
||
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
|
||
{ ftIs: freightType, ftNot: freightType },
|
||
);
|
||
}
|
||
|
||
private resolveScheduleFreightType(
|
||
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||
const types = new Set(
|
||
(schedule.scheduleBookings ?? [])
|
||
.map((sb) => sb.booking?.freightType)
|
||
.filter((t): t is string => Boolean(t)),
|
||
);
|
||
if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
|
||
if (types.size > 1) return 'MIXED';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||
* past a concurrent insert that grabbed the same sequence (the unique index
|
||
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
|
||
* runs inside the caller's transaction manager so the row joins the same commit.
|
||
*/
|
||
private async insertScheduleWithReference(
|
||
manager: EntityManager,
|
||
build: (reference: string) => TrainSchedule,
|
||
): Promise<TrainSchedule> {
|
||
const year = new Date().getFullYear();
|
||
const repo = manager.getRepository(TrainSchedule);
|
||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
|
||
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||
try {
|
||
return await repo.save(build(reference));
|
||
} catch (err) {
|
||
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
|
||
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
|
||
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
|
||
continue;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
// Unreachable — the loop either returns or throws — but satisfies the compiler.
|
||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||
}
|
||
|
||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||
computeScheduleWagonUsage({
|
||
wagonSlots: schedule.trainSet?.wagons,
|
||
storedWagonCount: schedule.trainSet?.wagonCount,
|
||
scheduleBookings: schedule.scheduleBookings,
|
||
maxWagons: schedule.maxWagons,
|
||
});
|
||
|
||
return {
|
||
id: schedule.id,
|
||
reference: schedule.reference ?? null,
|
||
createdAt: schedule.createdAt ?? null,
|
||
scheduleDate: schedule.scheduledDepartureDate,
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
voyageNumber: schedule.voyageNumber ?? null,
|
||
direction: schedule.direction ?? null,
|
||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||
destination:
|
||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||
// Built train (Train Builder) behind this departure, when scheduled by train.
|
||
train: schedule.trainSet?.train
|
||
? {
|
||
id: schedule.trainSet.train.id,
|
||
code: schedule.trainSet.train.code,
|
||
trainName: schedule.trainSet.train.trainName ?? null,
|
||
}
|
||
: null,
|
||
locomotive: schedule.trainSet?.locomotive
|
||
? {
|
||
id: schedule.trainSet.locomotive.id,
|
||
code: schedule.trainSet.locomotive.code,
|
||
name: schedule.trainSet.locomotive.name ?? null,
|
||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||
}
|
||
: null,
|
||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||
id: loco.id,
|
||
code: loco.code,
|
||
name: loco.name ?? null,
|
||
currentYardId: loco.currentYardId ?? null,
|
||
})),
|
||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||
/** Coupled slots carrying a booking allocation — matches the wagon plan. */
|
||
wagonsUsed,
|
||
/** Coupled consist size; the denominator of "used". */
|
||
wagonsTotal,
|
||
/** Claimed by bookings (incl. unpaid) — not bookable. */
|
||
wagonsReserved,
|
||
/** Consist minus what bookings have claimed; what is still bookable. */
|
||
wagonsRemaining,
|
||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
||
freightType: this.resolveScheduleFreightType(schedule),
|
||
status: schedule.status,
|
||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
||
maxWagons: schedule.maxWagons ?? 0,
|
||
remainingWagons: Math.max(
|
||
0,
|
||
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
|
||
),
|
||
};
|
||
}
|
||
|
||
/** AVAILABLE locomotives at the route's origin yard. */
|
||
/**
|
||
* All in-service locomotives, annotated for the schedule-creation picker.
|
||
* Advance scheduling means nothing is filtered out — staff see status, whether the
|
||
* locomotive is at the origin yard yet, and how many future schedules it already has.
|
||
*/
|
||
async getAvailableLocomotivesForRoute(routeId: string) {
|
||
const route = await this.getSchedulableRoute(routeId);
|
||
|
||
const locomotives = await this.locomotivesRepository.findAll({
|
||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||
order: { code: 'ASC' },
|
||
});
|
||
|
||
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
|
||
? await this.dataSource.query(
|
||
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
JOIN (
|
||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||
FROM freight.train_set_locomotives tsl
|
||
WHERE tsl.deleted_at IS NULL
|
||
UNION
|
||
SELECT t.id AS train_set_id, t.locomotive_id
|
||
FROM freight.train_sets t
|
||
WHERE t.locomotive_id IS NOT NULL
|
||
) loco ON loco.train_set_id = tset.id
|
||
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.deleted_at IS NULL
|
||
AND loco.locomotive_id = ANY($1)
|
||
GROUP BY loco.locomotive_id`,
|
||
[locomotives.map((l) => l.id)],
|
||
)
|
||
: [];
|
||
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
|
||
|
||
return locomotives.map((loco) => ({
|
||
...loco,
|
||
atOriginYard: loco.currentYardId === route.originYardId,
|
||
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* All schedulable built trains (Train Builder), annotated for the
|
||
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
|
||
* philosophy: nothing serviceable is filtered out — staff see the status,
|
||
* whether the train sits at the origin yard yet, and its future schedules.
|
||
* Trains with no locomotive at all are omitted (never schedulable).
|
||
*/
|
||
async getAvailableTrainsForRoute(routeId: string) {
|
||
const route = await this.getSchedulableRoute(routeId);
|
||
|
||
const trains = await this.dataSource.getRepository(Train).find({
|
||
where: {
|
||
status: Not(
|
||
In([
|
||
Freight.TrainStatus.OutOfService,
|
||
Freight.TrainStatus.UnderMaintenance,
|
||
Freight.TrainStatus.Deactivated,
|
||
]),
|
||
),
|
||
},
|
||
relations: {
|
||
currentYard: true,
|
||
locomotives: { locomotive: true },
|
||
wagons: { wagonType: true },
|
||
},
|
||
order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } },
|
||
});
|
||
|
||
const counts: { train_id: string; future_count: string }[] = trains.length
|
||
? await this.dataSource.query(
|
||
`SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.deleted_at IS NULL
|
||
AND tset.train_id = ANY($1)
|
||
GROUP BY tset.train_id`,
|
||
[trains.map((t) => t.id)],
|
||
)
|
||
: [];
|
||
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
|
||
|
||
return trains
|
||
.filter((train) => (train.locomotives ?? []).length >= 1)
|
||
.map((train) => {
|
||
const wagons = train.wagons ?? [];
|
||
return {
|
||
id: train.id,
|
||
code: train.code,
|
||
trainName: train.trainName ?? null,
|
||
status: train.status,
|
||
importTrainNumber: train.importTrainNumber ?? null,
|
||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||
currentYardId: train.currentYardId ?? null,
|
||
currentYard: train.currentYard
|
||
? {
|
||
id: train.currentYard.id,
|
||
code: train.currentYard.code,
|
||
label: train.currentYard.label,
|
||
}
|
||
: null,
|
||
locomotives: (train.locomotives ?? [])
|
||
.filter((link) => link.locomotive)
|
||
.map((link) => ({
|
||
id: link.locomotive!.id,
|
||
code: link.locomotive!.code,
|
||
name: link.locomotive!.name ?? null,
|
||
})),
|
||
wagonCount: wagons.length,
|
||
totalTareTons: roundTons(
|
||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||
),
|
||
totalLengthMeters: roundTons(
|
||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||
),
|
||
// Live from the coupled set — `capacity_tons` still holds the old
|
||
// single-locomotive figure on trains built before pull weight summed.
|
||
maxPullWeightTons: roundTons(
|
||
combinedLocomotiveLimits(
|
||
(train.locomotives ?? [])
|
||
.map((link) => link.locomotive)
|
||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||
)?.maxPullWeightTons ?? Number(train.capacityTons),
|
||
),
|
||
atOriginYard: train.currentYardId === route.originYardId,
|
||
futureScheduleCount: futureCounts.get(train.id) ?? 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Where consist work can physically happen right now. Before departure it is
|
||
* the built train's own yard. After dispatch it is the route stop the train
|
||
* is STANDING AT per its latest checkpoint — null while rolling between
|
||
* stops or when the last checkpoint is off-route, and consist work is closed
|
||
* there. Arrived/cancelled schedules always return null (history only).
|
||
*/
|
||
private async currentConsistYardId(
|
||
schedule: TrainSchedule,
|
||
): Promise<string | null> {
|
||
if (
|
||
schedule.status === TrainScheduleStatusEnum.Draft ||
|
||
schedule.status === TrainScheduleStatusEnum.Scheduled
|
||
) {
|
||
return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId;
|
||
}
|
||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null;
|
||
const rows: Array<{ yard_id: string | null }> = await this.dataSource.query(
|
||
`SELECT yard_id
|
||
FROM freight.train_checkpoint_events
|
||
WHERE train_schedule_id = $1
|
||
ORDER BY occurred_at DESC, created_at DESC
|
||
LIMIT 1`,
|
||
[schedule.id],
|
||
);
|
||
const yardId = rows[0]?.yard_id ?? null;
|
||
if (!yardId) return null;
|
||
return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId)
|
||
? yardId
|
||
: null;
|
||
}
|
||
|
||
/**
|
||
* Physical wagons whose cargo still RIDES beyond the given stop: any
|
||
* allocation whose booking alights strictly after it. Cargo whose
|
||
* destination is this stop (or an earlier one) has been offloaded here and
|
||
* no longer blocks its wagon — that wagon may be trimmed or switched away.
|
||
* Before departure the stop is the origin, so every allocated wagon counts
|
||
* as aboard — one rule covers both phases. Unknown destinations and
|
||
* off-route stops stay conservative (aboard).
|
||
*/
|
||
// ponytail: trusts booking.destinationYardId, not a physical unload
|
||
// confirmation — if staff trim before actually unloading, the cargo strands.
|
||
// Wire the journey unload flag in if that ever bites.
|
||
private wagonIdsWithCargoBeyond(
|
||
schedule: TrainSchedule,
|
||
atYardId: string | null,
|
||
): Set<string> {
|
||
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
|
||
const atIdx = atYardId ? stops.indexOf(atYardId) : -1;
|
||
const aboard = new Set<string>();
|
||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||
if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue;
|
||
const ridesOn = (slot.allocations ?? []).some((allocation) => {
|
||
const destination = allocation.booking?.destinationYardId;
|
||
const destIdx = destination ? stops.indexOf(destination) : -1;
|
||
if (destIdx < 0 || atIdx < 0) return true;
|
||
return destIdx > atIdx;
|
||
});
|
||
if (ridesOn) aboard.add(slot.physicalWagonId);
|
||
}
|
||
return aboard;
|
||
}
|
||
|
||
/**
|
||
* Consist snapshot for the adjust-consist UI: the built train's wagons with
|
||
* loaded/removable flags, gross weight (cargo + FULL consist tare) and length
|
||
* against the locomotive limits incl. overage tolerance, addable yard wagons,
|
||
* and the adjustment history.
|
||
*/
|
||
async getScheduleConsist(scheduleId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
const builtTrain = schedule.trainSet?.train;
|
||
if (!builtTrain) {
|
||
throw new BadRequestException(
|
||
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||
);
|
||
}
|
||
|
||
// Where the train stands right now — the origin yard before departure, the
|
||
// checkpoint stop after it. Null = rolling; the consist is view-only then.
|
||
const currentYardId = await this.currentConsistYardId(schedule);
|
||
|
||
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||
where: { trainId: builtTrain.id },
|
||
relations: { wagonType: true },
|
||
order: { sequenceNumber: 'ASC' },
|
||
});
|
||
const addableWagons = currentYardId
|
||
? await this.dataSource.getRepository(Wagon).find({
|
||
where: {
|
||
trainId: IsNull(),
|
||
status: WagonStatus.Available,
|
||
currentYardId,
|
||
},
|
||
relations: { wagonType: true },
|
||
order: { wagonNumber: 'ASC' },
|
||
})
|
||
: [];
|
||
const adjustments = await this.dataSource
|
||
.getRepository(ScheduleWagonAdjustmentLog)
|
||
.find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });
|
||
|
||
// Slots whose cargo still rides beyond the current stop — those wagons
|
||
// cannot be trimmed, only switched. Cargo offloaded at this stop (or
|
||
// earlier) has released its wagon.
|
||
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
|
||
// Only OTHER trains' pins block edits here — this train's own schedules
|
||
// (incl. the paired return run) have their pins managed by the edit itself
|
||
// (removal clears, switch re-points).
|
||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
|
||
undefined,
|
||
builtTrain.id,
|
||
);
|
||
|
||
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
|
||
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
|
||
const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);
|
||
|
||
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||
const consistTareTons = roundTons(
|
||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
|
||
);
|
||
const consistLengthMeters = roundTons(
|
||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||
);
|
||
|
||
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
|
||
// capacity, so trimming/coupling wagons moves the FULL line live.
|
||
const wagonUsage =
|
||
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
|
||
|
||
const mapWagon = (wagon: Wagon) => ({
|
||
id: wagon.id,
|
||
wagonNumber: wagon.wagonNumber,
|
||
sequenceNumber: wagon.sequenceNumber,
|
||
wagonType: wagon.wagonType
|
||
? {
|
||
id: wagon.wagonType.id,
|
||
code: wagon.wagonType.code,
|
||
tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
|
||
capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
|
||
lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
|
||
}
|
||
: null,
|
||
});
|
||
|
||
return {
|
||
schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
|
||
train: {
|
||
id: builtTrain.id,
|
||
code: builtTrain.code,
|
||
trainName: builtTrain.trainName ?? null,
|
||
currentYardId: builtTrain.currentYardId ?? null,
|
||
},
|
||
limits: {
|
||
maxPullWeightTons,
|
||
overageToleranceTons,
|
||
pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
|
||
maxTrainLengthMeters,
|
||
overageToleranceMeters,
|
||
lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
|
||
},
|
||
totals: {
|
||
wagonCount: wagons.length,
|
||
cargoTons,
|
||
consistTareTons,
|
||
grossTons: roundTons(cargoTons + consistTareTons),
|
||
consistLengthMeters,
|
||
},
|
||
scheduleCapacity: wagonUsage
|
||
? {
|
||
...wagonUsage,
|
||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||
}
|
||
: null,
|
||
wagons: wagons.map((wagon) => {
|
||
const loaded = loadedWagonIds.has(wagon.id);
|
||
const pinnedElsewhere = pinnedToLiveIds.has(wagon.id);
|
||
return {
|
||
...mapWagon(wagon),
|
||
loaded,
|
||
removable: !pinnedElsewhere && !loaded,
|
||
// A loaded wagon can't leave, but its SLOT can change wagon: switch
|
||
// moves the cargo allocations onto a same-type replacement.
|
||
switchable: !pinnedElsewhere,
|
||
blockReason: pinnedElsewhere
|
||
? 'Pinned by another live schedule'
|
||
: loaded
|
||
? 'Cargo aboard rides beyond this stop — switch it instead'
|
||
: null,
|
||
};
|
||
}),
|
||
addableWagons: addableWagons.map(mapWagon),
|
||
adjustments: adjustments.map((log) => ({
|
||
id: log.id,
|
||
action: log.action,
|
||
wagonId: log.wagonId,
|
||
wagonNumber: log.wagonNumber,
|
||
adjustedByUserId: log.adjustedByUserId,
|
||
yardId: log.yardId ?? null,
|
||
occurredAt: log.occurredAt,
|
||
})),
|
||
// Editable before departure, and after it whenever the train is standing
|
||
// at a route stop (mid-route wagon work at station B); frozen while
|
||
// rolling and once arrived/cancelled.
|
||
editable:
|
||
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
|
||
(schedule.status === TrainScheduleStatusEnum.Dispatched &&
|
||
currentYardId != null),
|
||
currentStop: currentYardId
|
||
? {
|
||
yardId: currentYardId,
|
||
label:
|
||
this.mapScheduleStops(schedule).find(
|
||
(s) => s.yardId === currentYardId,
|
||
)?.label ?? currentYardId,
|
||
isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Permanently adjust the built train's consist from a schedule: trim free
|
||
* wagons (their tare no longer rides — the usual fix when gross weight beats
|
||
* the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
|
||
* length headroom remain (limits incl. overage tolerance). The built train
|
||
* updates in place, the schedule's wagon cap follows, and every change is
|
||
* logged for the schedule's history.
|
||
*/
|
||
async adjustScheduleConsist(
|
||
scheduleId: string,
|
||
dto: AdjustScheduleConsistDto,
|
||
userId?: string | null,
|
||
) {
|
||
const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
|
||
const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
|
||
const switches = dto.switches ?? [];
|
||
if (!addWagonIds.length && !removeWagonIds.length && !switches.length) {
|
||
throw new BadRequestException(
|
||
'Nothing to adjust — pass wagons to add, remove and/or switch',
|
||
);
|
||
}
|
||
const switchFromIds = switches.map((s) => s.fromWagonId);
|
||
const switchToIds = switches.map((s) => s.toWagonId);
|
||
const touched = new Map<string, number>();
|
||
for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) {
|
||
touched.set(id, (touched.get(id) ?? 0) + 1);
|
||
}
|
||
if ([...touched.values()].some((count) => count > 1)) {
|
||
throw new BadRequestException(
|
||
'Each wagon may appear once per adjustment — not in two lists or two switches',
|
||
);
|
||
}
|
||
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
// Consist edits are open before departure, and after it whenever the train
|
||
// is STANDING AT a route stop (checkpointed): that is exactly the "switch
|
||
// wagons at station B" window. Rolling between stops → frozen.
|
||
const currentYardId = await this.currentConsistYardId(schedule);
|
||
const editableStatus =
|
||
['DRAFT', 'SCHEDULED'].includes(schedule.status) ||
|
||
schedule.status === TrainScheduleStatusEnum.Dispatched;
|
||
if (!editableStatus || !currentYardId) {
|
||
throw new BadRequestException(
|
||
schedule.status === TrainScheduleStatusEnum.Dispatched
|
||
? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)'
|
||
: 'The consist can no longer be adjusted — the run is over',
|
||
);
|
||
}
|
||
const builtTrainRef = schedule.trainSet?.train;
|
||
if (!builtTrainRef) {
|
||
throw new BadRequestException(
|
||
'This schedule was not created from a built train — its consist cannot be adjusted here',
|
||
);
|
||
}
|
||
// Wagons whose cargo still rides beyond the current stop: never removable,
|
||
// but switchable — the replacement inherits the slot, cargo included.
|
||
const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId);
|
||
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||
const pullCapTons = roundTons(
|
||
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
|
||
);
|
||
const lengthCapMeters = roundTons(
|
||
Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
|
||
);
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const train = await manager.getRepository(Train).findOne({
|
||
where: { id: builtTrainRef.id },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);
|
||
|
||
const consist = await manager.getRepository(Wagon).find({
|
||
where: { trainId: train.id },
|
||
relations: { wagonType: true },
|
||
order: { sequenceNumber: 'ASC' },
|
||
});
|
||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||
|
||
// --- validate removals: coupled, cargo offloaded, no foreign pin ---
|
||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(
|
||
manager,
|
||
train.id,
|
||
);
|
||
// Every live train set of THIS built train (this run + siblings, e.g.
|
||
// the paired return leg) — their pins follow the consist edit.
|
||
const ownSetIds = (
|
||
await manager.getRepository(TrainSet).find({
|
||
where: { trainId: train.id },
|
||
select: { id: true },
|
||
})
|
||
).map((set) => set.id);
|
||
const removed: Wagon[] = [];
|
||
for (const wagonId of removeWagonIds) {
|
||
const wagon = consistById.get(wagonId);
|
||
if (!wagon) {
|
||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||
}
|
||
if (loadedWagonIds.has(wagon.id)) {
|
||
throw new ConflictException(
|
||
`Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`,
|
||
);
|
||
}
|
||
if (pinnedToLiveIds.has(wagon.id)) {
|
||
throw new ConflictException(
|
||
`Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`,
|
||
);
|
||
}
|
||
removed.push(wagon);
|
||
}
|
||
|
||
// Shared gate for every incoming wagon (couple or switch replacement):
|
||
// AVAILABLE, loose, and standing where the train stands right now.
|
||
const lockIncomingWagon = async (wagonId: string): Promise<Wagon> => {
|
||
// No `relations` on this query: Postgres refuses FOR UPDATE through the
|
||
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
|
||
// applied to the nullable side of an outer join"). Lock the row alone,
|
||
// then attach its type with a separate unlocked lookup.
|
||
const wagon = await manager.getRepository(Wagon).findOne({
|
||
where: { id: wagonId },
|
||
lock: { mode: 'pessimistic_write' },
|
||
});
|
||
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||
if (wagon.trainId) {
|
||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
|
||
}
|
||
if (wagon.status !== WagonStatus.Available) {
|
||
throw new ConflictException(
|
||
`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
|
||
);
|
||
}
|
||
if (wagon.currentYardId !== currentYardId) {
|
||
throw new BadRequestException(
|
||
`Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`,
|
||
);
|
||
}
|
||
wagon.wagonType =
|
||
(await manager
|
||
.getRepository(WagonType)
|
||
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
|
||
return wagon;
|
||
};
|
||
|
||
const added: Wagon[] = [];
|
||
for (const wagonId of addWagonIds) {
|
||
added.push(await lockIncomingWagon(wagonId));
|
||
}
|
||
|
||
// --- validate switches: outgoing coupled + not foreign-pinned; the
|
||
// replacement passes the incoming gate AND matches the wagon type, so
|
||
// the slot's cargo (weight, TEU geometry) rides it unchanged ---
|
||
const switchPairs: Array<{ from: Wagon; to: Wagon }> = [];
|
||
for (const { fromWagonId, toWagonId } of switches) {
|
||
const from = consistById.get(fromWagonId);
|
||
if (!from) {
|
||
throw new NotFoundException(
|
||
`Wagon ${fromWagonId} is not coupled to train ${train.code}`,
|
||
);
|
||
}
|
||
if (pinnedToLiveIds.has(from.id)) {
|
||
throw new ConflictException(
|
||
`Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`,
|
||
);
|
||
}
|
||
const to = await lockIncomingWagon(toWagonId);
|
||
if (to.wagonTypeId !== from.wagonTypeId) {
|
||
throw new BadRequestException(
|
||
`Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`,
|
||
);
|
||
}
|
||
switchPairs.push({ from, to });
|
||
}
|
||
|
||
// --- headroom check (only additions can push the train over a cap;
|
||
// switches are same-type and cancel out, but are computed honestly) ---
|
||
const removedIds = new Set(removed.map((w) => w.id));
|
||
const switchedFromIds = new Set(switchPairs.map((p) => p.from.id));
|
||
const finalConsist = [
|
||
...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)),
|
||
...added,
|
||
...switchPairs.map((p) => p.to),
|
||
];
|
||
const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
|
||
const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
|
||
const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
|
||
const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
|
||
const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
|
||
const finalGrossTons = roundTons(cargoTons + finalTareTons);
|
||
if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
|
||
throw new BadRequestException(
|
||
`Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
|
||
);
|
||
}
|
||
if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
|
||
throw new BadRequestException(
|
||
`Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
|
||
);
|
||
}
|
||
|
||
// --- apply: detach trims, couple additions, swap switches, compact ---
|
||
// A wagon leaving the train stands wherever the train stands — stamping
|
||
// the stop yard is what makes it findable (and re-couplable) at B.
|
||
const detachPatch = {
|
||
trainId: null,
|
||
sequenceNumber: null,
|
||
status: WagonStatus.Available,
|
||
trainSetWagonId: null,
|
||
currentTrainScheduleId: null,
|
||
currentYardId,
|
||
};
|
||
for (const wagon of removed) {
|
||
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||
}
|
||
if (removed.length && ownSetIds.length) {
|
||
// This train's own pins (all its runs) on trimmed wagons are stale —
|
||
// clear them so the freed wagon isn't still claimed by slots it left.
|
||
await manager
|
||
.getRepository(TrainSetWagon)
|
||
.update(
|
||
{ trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) },
|
||
{ physicalWagonId: null },
|
||
);
|
||
}
|
||
|
||
// Switches: the replacement takes the outgoing wagon's position AND its
|
||
// slot pins, so every cargo allocation now rides the new wagon. The
|
||
// outgoing wagon is left standing at the stop.
|
||
for (const { from, to } of switchPairs) {
|
||
const slots = ownSetIds.length
|
||
? await manager.getRepository(TrainSetWagon).find({
|
||
where: { trainSetId: In(ownSetIds), physicalWagonId: from.id },
|
||
})
|
||
: [];
|
||
for (const slot of slots) {
|
||
await manager
|
||
.getRepository(TrainSetWagon)
|
||
.update(slot.id, { physicalWagonId: to.id });
|
||
}
|
||
const ownSlot =
|
||
slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0];
|
||
await manager.getRepository(Wagon).update(to.id, {
|
||
trainId: train.id,
|
||
sequenceNumber: from.sequenceNumber,
|
||
status: WagonStatus.Assigned,
|
||
trainSetWagonId: ownSlot?.id ?? null,
|
||
currentTrainScheduleId: from.currentTrainScheduleId ?? null,
|
||
});
|
||
// Mirror on the in-memory row — the compaction below sorts by it.
|
||
to.sequenceNumber = from.sequenceNumber;
|
||
await manager.getRepository(Wagon).update(from.id, detachPatch);
|
||
}
|
||
|
||
const remaining = consist.filter(
|
||
(w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id),
|
||
);
|
||
const switchedIn = switchPairs.map((p) => p.to);
|
||
const compacted = [...remaining, ...switchedIn].sort(
|
||
(a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0),
|
||
);
|
||
for (let i = 0; i < compacted.length; i++) {
|
||
if (compacted[i].sequenceNumber !== i + 1) {
|
||
await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 });
|
||
}
|
||
}
|
||
let sequence = compacted.length;
|
||
for (const wagon of added) {
|
||
sequence += 1;
|
||
await manager.getRepository(Wagon).update(wagon.id, {
|
||
trainId: train.id,
|
||
sequenceNumber: sequence,
|
||
status: WagonStatus.Assigned,
|
||
});
|
||
}
|
||
|
||
// The schedule is full when every consist wagon is allocated.
|
||
await manager
|
||
.getRepository(TrainSchedule)
|
||
.update(scheduleId, { maxWagons: finalConsist.length });
|
||
|
||
const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
|
||
const now = new Date();
|
||
await logRepo.save(
|
||
[
|
||
...removed.map((wagon) => ({
|
||
action: 'REMOVE' as const,
|
||
wagonId: wagon.id,
|
||
wagonNumber: wagon.wagonNumber,
|
||
})),
|
||
...added.map((wagon) => ({
|
||
action: 'ADD' as const,
|
||
wagonId: wagon.id,
|
||
wagonNumber: wagon.wagonNumber,
|
||
})),
|
||
...switchPairs.map(({ from, to }) => ({
|
||
action: 'SWITCH' as const,
|
||
wagonId: to.id,
|
||
// varchar(50) — two long wagon numbers could overflow the column.
|
||
wagonNumber: `${from.wagonNumber} → ${to.wagonNumber}`.slice(0, 50),
|
||
})),
|
||
].map((entry) =>
|
||
logRepo.create({
|
||
trainScheduleId: scheduleId,
|
||
trainId: train.id,
|
||
action: entry.action,
|
||
wagonId: entry.wagonId,
|
||
wagonNumber: entry.wagonNumber,
|
||
adjustedByUserId: userId ?? null,
|
||
yardId: currentYardId,
|
||
occurredAt: now,
|
||
}),
|
||
),
|
||
);
|
||
});
|
||
|
||
// The consist IS the schedule's booking capacity, so an edit moves the
|
||
// FULL line: freeing slots on a FULL schedule reopens its window, taking
|
||
// the last slot closes it. Staff may shrink below what is already
|
||
// committed — allowed, but reported back as a warning (never silently).
|
||
const warnings: string[] = [];
|
||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
|
||
if (usage) {
|
||
const nowFull = usage.remainingSlots <= 0;
|
||
if (usage.overAllocatedBy > 0) {
|
||
warnings.push(
|
||
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
|
||
`${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
|
||
'Couple more wagons or free bookings before departure.',
|
||
);
|
||
}
|
||
if (wasFull && !nowFull) {
|
||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||
warnings.push(
|
||
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
|
||
'so it is no longer FULL and can take bookings again.',
|
||
);
|
||
} else if (!wasFull && nowFull) {
|
||
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
|
||
warnings.push(
|
||
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
|
||
);
|
||
}
|
||
}
|
||
|
||
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||
}
|
||
|
||
/**
|
||
* Unified change history for the schedule detail "History" tab: wagon
|
||
* consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened
|
||
* at) merged with booking composition removals, newest first. Actor resolves
|
||
* through iam.users; rows survive wagon/train deletion (log tables carry
|
||
* plain columns, no FKs).
|
||
*/
|
||
async getScheduleHistory(scheduleId: string) {
|
||
type HistoryRow = {
|
||
id: string;
|
||
kind: 'WAGON' | 'BOOKING';
|
||
action: string;
|
||
subject: string | null;
|
||
yardLabel: string | null;
|
||
actor: string | null;
|
||
note: string | null;
|
||
occurredAt: Date;
|
||
};
|
||
const wagonRows: HistoryRow[] = (
|
||
await this.dataSource.query(
|
||
`SELECT l.id,
|
||
l.action,
|
||
l.wagon_number AS "subject",
|
||
COALESCE(y.label, y.code) AS "yardLabel",
|
||
COALESCE(u.username, u.email) AS "actor",
|
||
l.occurred_at AS "occurredAt"
|
||
FROM freight.schedule_wagon_adjustment_logs l
|
||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||
WHERE l.train_schedule_id = $1
|
||
AND l.deleted_at IS NULL
|
||
ORDER BY l.occurred_at DESC
|
||
LIMIT 200`,
|
||
[scheduleId],
|
||
)
|
||
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
|
||
...r,
|
||
kind: 'WAGON' as const,
|
||
note: null,
|
||
}));
|
||
const bookingRows: HistoryRow[] = (
|
||
await this.dataSource.query(
|
||
`SELECT r.id,
|
||
r.booking_reference AS "subject",
|
||
r.notes AS "note",
|
||
COALESCE(u.username, u.email) AS "actor",
|
||
r.removed_at AS "occurredAt"
|
||
FROM freight.train_composition_removal_logs r
|
||
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
||
WHERE r.schedule_id = $1
|
||
AND r.deleted_at IS NULL
|
||
ORDER BY r.removed_at DESC
|
||
LIMIT 200`,
|
||
[scheduleId],
|
||
)
|
||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
|
||
...r,
|
||
kind: 'BOOKING' as const,
|
||
action: 'BOOKING_REMOVED',
|
||
yardLabel: null,
|
||
}));
|
||
// Per-booking journey events (load at boarding yard / unload at alighting
|
||
// yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a
|
||
// multi-stop train's disjoint legs (a→b loads then unloads at b while a→c
|
||
// rides through) each show as their own row. Append-only: these columns are
|
||
// only ever set once per booking, never cleared, so rows never disappear.
|
||
const journeyRows: HistoryRow[] = (
|
||
await this.dataSource.query(
|
||
`SELECT b.id,
|
||
b.reference AS "subject",
|
||
COALESCE(oy.label, oy.code) AS "yardLabel",
|
||
COALESCE(u.username, u.email) AS "actor",
|
||
b.loaded_at AS "occurredAt"
|
||
FROM freight.bookings b
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
|
||
WHERE b.loaded_at IS NOT NULL
|
||
AND b.deleted_at IS NULL
|
||
ORDER BY b.loaded_at DESC
|
||
LIMIT 200`,
|
||
[scheduleId],
|
||
)
|
||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
||
...r,
|
||
kind: 'BOOKING' as const,
|
||
action: 'BOOKING_LOADED',
|
||
note: null,
|
||
}));
|
||
const unloadRows: HistoryRow[] = (
|
||
await this.dataSource.query(
|
||
`SELECT b.id,
|
||
b.reference AS "subject",
|
||
COALESCE(dy.label, dy.code) AS "yardLabel",
|
||
COALESCE(u.username, u.email) AS "actor",
|
||
b.arrived_at AS "occurredAt"
|
||
FROM freight.bookings b
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
|
||
WHERE b.arrived_at IS NOT NULL
|
||
AND b.deleted_at IS NULL
|
||
ORDER BY b.arrived_at DESC
|
||
LIMIT 200`,
|
||
[scheduleId],
|
||
)
|
||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
||
...r,
|
||
kind: 'BOOKING' as const,
|
||
action: 'BOOKING_UNLOADED',
|
||
note: null,
|
||
}));
|
||
return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort(
|
||
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Re-derive a built train's lifecycle status from its schedules after one of
|
||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
|
||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
|
||
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
|
||
*/
|
||
private async syncBuiltTrainAfterScheduleChange(
|
||
manager: EntityManager,
|
||
trainId: string,
|
||
moveToYardId?: string | null,
|
||
): Promise<void> {
|
||
const train = await manager.getRepository(Train).findOne({ where: { id: trainId } });
|
||
if (!train) return;
|
||
|
||
const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {};
|
||
const managed = [
|
||
Freight.TrainStatus.Available,
|
||
Freight.TrainStatus.Scheduled,
|
||
Freight.TrainStatus.InService,
|
||
];
|
||
if (!managed.includes(train.status)) {
|
||
if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch);
|
||
return;
|
||
}
|
||
|
||
const rows: { status: string }[] = await manager.query(
|
||
`SELECT DISTINCT ts.status
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||
WHERE tset.train_id = $1
|
||
AND ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||
[trainId],
|
||
);
|
||
const statuses = new Set(rows.map((r) => r.status));
|
||
const next = statuses.has('DISPATCHED')
|
||
? Freight.TrainStatus.InService
|
||
: statuses.size
|
||
? Freight.TrainStatus.Scheduled
|
||
: Freight.TrainStatus.Available;
|
||
await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch });
|
||
}
|
||
|
||
/**
|
||
* A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a
|
||
* FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s
|
||
* milestones with the destination stop AFTER the origin stop — OR (routes with
|
||
* no milestones recorded) the pair equals the schedule's own endpoints. This
|
||
* mirrors the sub-leg matching the create-booking path already does
|
||
* (`getBookableScheduleEntities`), so a through train (Djibouti → Kality →
|
||
* Dire) is announced and bookable for every leg it actually serves
|
||
* (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two
|
||
* endpoints. Static SQL fragment (no user input) interpolated into the window
|
||
* queries below; `ts` and `cr` must be the schedule and contract_route aliases.
|
||
*/
|
||
private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `(
|
||
EXISTS (
|
||
SELECT 1
|
||
FROM freight.route_milestones mo
|
||
JOIN freight.route_milestones md
|
||
ON md.route_id = mo.route_id
|
||
AND md.sequence_no > mo.sequence_no
|
||
WHERE mo.route_id = ts.route_id
|
||
AND mo.yard_id = cr.origin_yard_id
|
||
AND md.yard_id = cr.destination_yard_id
|
||
)
|
||
OR (cr.origin_yard_id = ts.origin_station_id
|
||
AND cr.destination_yard_id = ts.destination_station_id)
|
||
)`;
|
||
|
||
/**
|
||
* Upcoming/open booking windows announced on the portal home "booking
|
||
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
|
||
* are listed so every customer sees what is opening — not just those on their
|
||
* contract lanes; DOMESTIC trains are always open and need no announcement.
|
||
*
|
||
* When `companyId` is given, a matching active contract on the lane is
|
||
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
|
||
* "Book now"); customers with no covering contract still see the window with a
|
||
* null contract, and the portal routes them to the contract list to get one.
|
||
* A contract covering any FORWARD sub-leg of the corridor counts as covering
|
||
* the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`).
|
||
*/
|
||
async getBookingWindowsForCompany(companyId: string | null) {
|
||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||
`SELECT DISTINCT ON (ts.id)
|
||
ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
ts.train_number,
|
||
cr.contract_id AS contract_id,
|
||
c.contract_kind AS contract_kind,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code,
|
||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||
FROM freight.route_milestones rm
|
||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.contract_routes cr
|
||
ON cr.deleted_at IS NULL
|
||
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
|
||
LEFT JOIN freight.contracts c
|
||
ON c.id = cr.contract_id
|
||
AND c.company_id = $1
|
||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||
AND c.deleted_at IS NULL
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
|
||
[companyId],
|
||
);
|
||
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
|
||
// per-row ordering, so re-sort the mapped rows by departure for the client.
|
||
return rows
|
||
.map((r) => this.mapBookingWindowRow(r))
|
||
.sort((a, b) => {
|
||
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||
return ta - tb;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Upcoming/open booking windows on a single contract's routes. Used to gate the
|
||
* booking form for the customer AND Ethiopian GL (who books on the customer's
|
||
* behalf): no window row with isOpenNow=true → booking entry is hidden.
|
||
*/
|
||
async getBookingWindowsForContract(contractId: string) {
|
||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||
`SELECT DISTINCT ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
ts.train_number,
|
||
cr.contract_id AS contract_id,
|
||
c.contract_kind AS contract_kind,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code,
|
||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||
FROM freight.route_milestones rm
|
||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.contract_routes cr
|
||
ON cr.contract_id = $1
|
||
AND cr.deleted_at IS NULL
|
||
AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
|
||
JOIN freight.contracts c
|
||
ON c.id = cr.contract_id
|
||
AND c.deleted_at IS NULL
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||
[contractId],
|
||
);
|
||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||
}
|
||
|
||
/**
|
||
* All announced booking windows across every lane — import window cycles AND
|
||
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
|
||
* phase filter as the customer-facing lists, no contract scoping.
|
||
*/
|
||
async listAllBookingWindows() {
|
||
const rows: Array<
|
||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
|
||
> = await this.dataSource.query(
|
||
`SELECT ts.id AS schedule_id,
|
||
ts.reference AS reference,
|
||
ts.train_number,
|
||
ts.direction,
|
||
ts.window_phase,
|
||
ts.window_opens_at,
|
||
ts.window_closes_at,
|
||
ts.doc_review_ends_at,
|
||
ts.payment_phase_ends_at,
|
||
ts.booking_window_status,
|
||
ts.booking_cycle_no,
|
||
ts.scheduled_departure_date,
|
||
oy.label AS origin_label, oy.code AS origin_code,
|
||
dy.label AS destination_label, dy.code AS destination_code,
|
||
(SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
|
||
FROM freight.route_milestones rm
|
||
JOIN freight.yards rmy ON rmy.id = rm.yard_id
|
||
WHERE rm.route_id = ts.route_id) AS route_stations
|
||
FROM freight.train_schedules ts
|
||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||
WHERE ts.deleted_at IS NULL
|
||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||
AND ts.window_phase IS NOT NULL
|
||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||
AND ts.scheduled_departure_date >= now()
|
||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||
);
|
||
return rows.map((r) =>
|
||
this.mapBookingWindowRow({
|
||
...r,
|
||
contract_id: null,
|
||
contract_kind: null,
|
||
}),
|
||
);
|
||
}
|
||
|
||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||
const origin = r.origin_label ?? r.origin_code ?? null;
|
||
const destination = r.destination_label ?? r.destination_code ?? null;
|
||
// Full corridor from the route's milestones (origin → stops → destination).
|
||
// Falls back to the schedule's origin/destination when no milestones exist.
|
||
const milestoneStops = (r.route_stations ?? []).filter(
|
||
(s): s is string => Boolean(s),
|
||
);
|
||
const routeStations =
|
||
milestoneStops.length >= 2
|
||
? milestoneStops
|
||
: [origin, destination].filter((s): s is string => Boolean(s));
|
||
return {
|
||
scheduleId: r.schedule_id,
|
||
reference: r.reference ?? null,
|
||
trainNumber: r.train_number ?? null,
|
||
contractId: r.contract_id,
|
||
contractKind: r.contract_kind,
|
||
direction: r.direction,
|
||
windowPhase: r.window_phase,
|
||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||
windowOpensAt: r.window_opens_at,
|
||
windowClosesAt: r.window_closes_at,
|
||
docReviewEndsAt: r.doc_review_ends_at,
|
||
paymentPhaseEndsAt: r.payment_phase_ends_at,
|
||
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
|
||
bookingWindowStatus: r.booking_window_status,
|
||
bookingCycleNo: r.booking_cycle_no,
|
||
departureDate: r.scheduled_departure_date,
|
||
origin,
|
||
destination,
|
||
routeStations,
|
||
};
|
||
}
|
||
|
||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
|
||
* returns schedules whose route passes through both yards in the correct order.
|
||
*/
|
||
/**
|
||
* Raw OPEN same-route schedule entities a new booking may target (with the
|
||
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
|
||
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
|
||
* originStationId / scheduledDepartureDate / trainSet).
|
||
*/
|
||
private async getBookableScheduleEntities(
|
||
originYardId?: string,
|
||
destinationYardId?: string,
|
||
): Promise<
|
||
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||
> {
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
where: {
|
||
bookingWindowStatus: 'OPEN',
|
||
},
|
||
relations: {
|
||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||
route: { milestones: true },
|
||
originStation: true,
|
||
destinationStation: true,
|
||
// Cargo relations feed effectiveWagonsRequired for legacy links whose
|
||
// stored wagonsRequired is NULL — without them such a booking counts
|
||
// as 1 wagon and per-leg occupancy under-reports.
|
||
scheduleBookings: {
|
||
booking: {
|
||
bookingContainers: { containerType: true },
|
||
cargoType: { wagonTypes: true },
|
||
},
|
||
},
|
||
},
|
||
order: { scheduledDepartureDate: 'ASC' },
|
||
});
|
||
|
||
// A train that has already departed can never be booked, even if the window
|
||
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
|
||
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
|
||
// past-departure schedule never leaks into the portal day pool, the schedule
|
||
// calendar, or the ET GL create-booking gate.
|
||
const now = new Date();
|
||
return schedules
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
s.scheduledDepartureDate > now,
|
||
)
|
||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||
.filter((s) => {
|
||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||
const milestones = s.route?.milestones ?? [];
|
||
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
|
||
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
|
||
|
||
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
|
||
const uniqueStopYardIds: string[] = [];
|
||
for (const yardId of stopYardIds) {
|
||
if (!uniqueStopYardIds.includes(yardId)) {
|
||
uniqueStopYardIds.push(yardId);
|
||
}
|
||
}
|
||
|
||
// Check origin yard filter
|
||
if (originYardId) {
|
||
if (!uniqueStopYardIds.includes(originYardId)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Check destination yard filter
|
||
if (destinationYardId) {
|
||
if (!uniqueStopYardIds.includes(destinationYardId)) {
|
||
return false;
|
||
}
|
||
// Ensure destination comes after origin (if both are specified)
|
||
if (originYardId) {
|
||
const originIndex = uniqueStopYardIds.indexOf(originYardId);
|
||
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
|
||
if (destIndex <= originIndex) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Wagon slots still free for a leg of the schedule's corridor, per edge:
|
||
* capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a
|
||
* booking alighting at a mid-stop frees its slots for the edges past it, so a
|
||
* train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length
|
||
* (a→b→…→h). No leg given → the most open edge (can anything board at all?).
|
||
*/
|
||
private remainingWagonsForLeg(
|
||
schedule: TrainSchedule,
|
||
originYardId?: string,
|
||
destinationYardId?: string,
|
||
): number {
|
||
const stops = this.mapScheduleStops(schedule).map((s) => s.yardId);
|
||
const budget = new CorridorBudget(stops, {
|
||
wagons: Number(schedule.maxWagons ?? 0),
|
||
weightTons: Number.POSITIVE_INFINITY,
|
||
lengthMeters: Number.POSITIVE_INFINITY,
|
||
});
|
||
for (const sb of schedule.scheduleBookings ?? []) {
|
||
if (!sb.booking) continue;
|
||
budget.subtract(
|
||
{
|
||
wagons: this.effectiveWagonsRequired(sb.booking),
|
||
weightTons: 0,
|
||
lengthMeters: 0,
|
||
},
|
||
budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId),
|
||
);
|
||
}
|
||
const leg =
|
||
originYardId && destinationYardId
|
||
? budget.legOf(originYardId, destinationYardId)
|
||
: null;
|
||
const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining();
|
||
return Math.max(0, remaining.wagons);
|
||
}
|
||
|
||
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||
const schedules = await this.getBookableScheduleEntities(
|
||
originYardId,
|
||
destinationYardId,
|
||
);
|
||
return schedules.map((s) => ({
|
||
...this.mapScheduleListItem(s),
|
||
// Leg-aware: the list item's own remainingWagons is consist-based
|
||
// (maxWagons − coupled wagons) and reads 0 on any fully-consisted train.
|
||
remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||
* only the day strings, no capacity, counts or train info.
|
||
*/
|
||
async getAvailableDays(
|
||
originYardId?: string,
|
||
destinationYardId?: string,
|
||
): Promise<{ days: string[] }> {
|
||
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||
const days = new Set<string>();
|
||
for (const s of schedules) {
|
||
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||
}
|
||
return { days: [...days].sort() };
|
||
}
|
||
|
||
/**
|
||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||
* train capacity (not fully allocated) AND its wagon stock can physically carry
|
||
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
|
||
* NOT gated — a booking bigger than the free capacity is accepted and the batch
|
||
* engine offers a partial split later. No counts are exposed: same
|
||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||
* not a train.
|
||
*/
|
||
async getAvailableDaysForCargo(input: {
|
||
originYardId?: string;
|
||
destinationYardId?: string;
|
||
freightType: 'CONTAINER' | 'BULK';
|
||
cargoTypeId?: string | null;
|
||
cargoTypeCode?: string | null;
|
||
totalWeightTons?: number;
|
||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||
containerTypeIds?: string[];
|
||
}): Promise<{ days: string[] }> {
|
||
const schedules = await this.getBookableScheduleEntities(
|
||
input.originYardId,
|
||
input.destinationYardId,
|
||
);
|
||
if (schedules.length === 0) return { days: [] };
|
||
|
||
// Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the
|
||
// REQUESTED leg's free slots, not on how many wagons are coupled to the
|
||
// consist (a fully-consisted train read 0 remaining and hid its days).
|
||
const withCapacity = schedules.filter(
|
||
(s) =>
|
||
this.remainingWagonsForLeg(
|
||
s,
|
||
input.originYardId,
|
||
input.destinationYardId,
|
||
) > 0,
|
||
);
|
||
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||
|
||
const days = new Set<string>();
|
||
for (const s of compatible) {
|
||
if (s.scheduledDepartureDate)
|
||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||
}
|
||
return { days: [...days].sort() };
|
||
}
|
||
|
||
/**
|
||
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
|
||
* whose wagon stock can physically carry the selected cargo — every container
|
||
* line (or the bulk cargo type) must map to at least one wagon type the
|
||
* schedule's stock actually has. Stock = the built train's own consist, or the
|
||
* origin yard's loose pool for schedules assembled from loose locomotives.
|
||
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
|
||
* a partial split offer from the batch engine later.
|
||
*/
|
||
private async filterCargoCompatibleSchedules(
|
||
schedules: TrainSchedule[],
|
||
cargo: {
|
||
freightType: 'CONTAINER' | 'BULK';
|
||
cargoTypeId?: string | null;
|
||
cargoTypeCode?: string | null;
|
||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||
containerTypeIds?: string[];
|
||
},
|
||
): Promise<TrainSchedule[]> {
|
||
if (!schedules.length) return schedules;
|
||
const required = await this.requiredWagonTypeSets(cargo);
|
||
// No cargo identity supplied — nothing to gate on (legacy callers).
|
||
if (required === null) return schedules;
|
||
|
||
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
|
||
return schedules.filter((s) => {
|
||
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
|
||
return required.every((set) => {
|
||
for (const typeId of set) if (stock.has(typeId)) return true;
|
||
return false;
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* One Set of allowed wagon-type ids per required cargo dimension: per
|
||
* container line's type (or per container size when only sizes are known),
|
||
* or a single set for the bulk cargo type. `null` = no cargo identity given,
|
||
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
|
||
* configured) — the gate then blocks every schedule, mirroring the hard
|
||
* config violation scheduling raises for the same state.
|
||
*/
|
||
private async requiredWagonTypeSets(cargo: {
|
||
freightType: 'CONTAINER' | 'BULK';
|
||
cargoTypeId?: string | null;
|
||
cargoTypeCode?: string | null;
|
||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||
containerTypeIds?: string[];
|
||
}): Promise<Set<string>[] | null> {
|
||
if (cargo.freightType === 'CONTAINER') {
|
||
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
|
||
if (typeIds.length) {
|
||
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
|
||
await this.dataSource.query(
|
||
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
|
||
FROM freight.container_types ct
|
||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||
LEFT JOIN freight.wagon_types wt
|
||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
|
||
[typeIds],
|
||
);
|
||
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
|
||
for (const row of rows) {
|
||
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
|
||
}
|
||
return [...byType.values()];
|
||
}
|
||
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
|
||
// ANY active container type of that size has a matching wagon type.
|
||
const sizes = [
|
||
...new Set(
|
||
(cargo.containers ?? [])
|
||
.map((line) => parseInt(String(line.containerSize), 10))
|
||
.filter((n) => Number.isFinite(n) && n > 0),
|
||
),
|
||
];
|
||
if (!sizes.length) return null;
|
||
const rows: { size_ft: number; wagon_type_id: string | null }[] =
|
||
await this.dataSource.query(
|
||
`SELECT ct.size_ft, wt.id AS wagon_type_id
|
||
FROM freight.container_types ct
|
||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||
LEFT JOIN freight.wagon_types wt
|
||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
|
||
AND (ct.is_active IS DISTINCT FROM false)`,
|
||
[sizes],
|
||
);
|
||
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
|
||
for (const row of rows) {
|
||
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
|
||
}
|
||
return [...bySize.values()];
|
||
}
|
||
|
||
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
|
||
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
|
||
`SELECT wt.id AS wagon_type_id
|
||
FROM freight.cargo_types c
|
||
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
|
||
LEFT JOIN freight.wagon_types wt
|
||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||
WHERE c.deleted_at IS NULL
|
||
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
|
||
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
|
||
);
|
||
const set = new Set<string>();
|
||
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
|
||
return [set];
|
||
}
|
||
|
||
/**
|
||
* Wagon-type ids each schedule's stock can offer: the built train's own
|
||
* consist for train-bound schedules, the origin yard's loose usable pool
|
||
* otherwise. Batched — two queries for the whole schedule list.
|
||
*/
|
||
private async scheduleWagonTypeStock(
|
||
schedules: TrainSchedule[],
|
||
): Promise<Map<string, Set<string>>> {
|
||
const builtTrainIds = [
|
||
...new Set(
|
||
schedules
|
||
.map((s) => s.trainSet?.trainId)
|
||
.filter((id): id is string => Boolean(id)),
|
||
),
|
||
];
|
||
const looseOriginYardIds = [
|
||
...new Set(
|
||
schedules
|
||
.filter((s) => !s.trainSet?.trainId)
|
||
.map((s) => s.originStationId)
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
|
||
const [trainRows, yardRows] = await Promise.all([
|
||
builtTrainIds.length
|
||
? (this.dataSource.query(
|
||
`SELECT train_id, wagon_type_id
|
||
FROM freight.wagons
|
||
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||
GROUP BY train_id, wagon_type_id`,
|
||
[builtTrainIds],
|
||
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
|
||
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
|
||
looseOriginYardIds.length
|
||
? (this.dataSource.query(
|
||
`SELECT current_yard_id, wagon_type_id
|
||
FROM freight.wagons
|
||
WHERE train_id IS NULL AND deleted_at IS NULL
|
||
AND status IN ('AVAILABLE', 'ASSIGNED')
|
||
AND current_yard_id = ANY($1::uuid[])
|
||
GROUP BY current_yard_id, wagon_type_id`,
|
||
[looseOriginYardIds],
|
||
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
|
||
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
|
||
]);
|
||
|
||
const byTrain = new Map<string, Set<string>>();
|
||
for (const row of trainRows) {
|
||
const set = byTrain.get(row.train_id) ?? new Set<string>();
|
||
set.add(row.wagon_type_id);
|
||
byTrain.set(row.train_id, set);
|
||
}
|
||
const byYard = new Map<string, Set<string>>();
|
||
for (const row of yardRows) {
|
||
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
|
||
set.add(row.wagon_type_id);
|
||
byYard.set(row.current_yard_id, set);
|
||
}
|
||
|
||
const result = new Map<string, Set<string>>();
|
||
for (const s of schedules) {
|
||
const trainId = s.trainSet?.trainId;
|
||
result.set(
|
||
s.id,
|
||
trainId
|
||
? byTrain.get(trainId) ?? new Set()
|
||
: byYard.get(s.originStationId) ?? new Set(),
|
||
);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Booking-time gate for a chosen day: does the route have an OPEN departure
|
||
* that day at all, and can any of that day's departures physically carry the
|
||
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
|
||
* partial split offer instead).
|
||
*/
|
||
async checkDayCargoCompatibility(
|
||
originYardId: string,
|
||
destinationYardId: string,
|
||
day: string,
|
||
cargo: {
|
||
freightType: 'CONTAINER' | 'BULK';
|
||
cargoTypeId?: string | null;
|
||
containerTypeIds?: string[];
|
||
},
|
||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||
const schedules = await this.getBookableScheduleEntities(
|
||
originYardId,
|
||
destinationYardId,
|
||
);
|
||
const onDay = schedules.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
|
||
);
|
||
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
|
||
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
|
||
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
|
||
}
|
||
|
||
/**
|
||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
|
||
*/
|
||
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||
let milestoneYards: string[] = [];
|
||
if (schedule.route?.milestones?.length) {
|
||
milestoneYards = [...schedule.route.milestones]
|
||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||
.map((m) => m.yardId);
|
||
} else if (schedule.routeId) {
|
||
const milestones = await this.dataSource
|
||
.getRepository(RouteMilestone)
|
||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||
milestoneYards = milestones.map((m) => m.yardId);
|
||
}
|
||
const raw = milestoneYards.length >= 2
|
||
? milestoneYards
|
||
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
||
const unique: string[] = [];
|
||
for (const yardId of raw) {
|
||
if (yardId && !unique.includes(yardId)) unique.push(yardId);
|
||
}
|
||
return unique;
|
||
}
|
||
|
||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||
async existsOpenScheduleOnRouteDay(
|
||
originYardId: string,
|
||
destinationYardId: string,
|
||
day: string,
|
||
): Promise<boolean> {
|
||
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||
return days.includes(day);
|
||
}
|
||
|
||
/**
|
||
* Enforce the config-driven booking window at booking-create time.
|
||
*
|
||
* A booking is only allowed when the route has an OPEN departure the customer
|
||
* can join for the requested day — which, because the window engine keeps
|
||
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
|
||
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
|
||
* `importWindowLeadDays` before departure, for `windowDurationHours`).
|
||
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
|
||
*
|
||
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
|
||
* both gates are satisfied by checking that route for open departures. When a
|
||
* specific day is requested, require an open departure on that EAT day; when no
|
||
* day is given, require at least one open departure on the route at all.
|
||
* Throws `BadRequestException` when the window is closed. No-ops when the route
|
||
* yards are unknown (nothing to gate against).
|
||
*/
|
||
async assertBookingWindowOpen(input: {
|
||
originYardId?: string | null;
|
||
destinationYardId?: string | null;
|
||
scheduledDate?: Date | string | null;
|
||
direction?: string | null;
|
||
}): Promise<void> {
|
||
const { originYardId, destinationYardId } = input;
|
||
if (!originYardId || !destinationYardId) return;
|
||
|
||
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||
if (days.length === 0) {
|
||
throw new BadRequestException(
|
||
input.direction === 'EXPORT'
|
||
? 'The export booking window for this route is not open yet'
|
||
: 'The import booking window for this route is closed right now',
|
||
);
|
||
}
|
||
|
||
if (input.scheduledDate) {
|
||
const day = eatDay(new Date(input.scheduledDate));
|
||
if (!days.includes(day)) {
|
||
throw new BadRequestException(
|
||
input.direction === 'EXPORT'
|
||
? 'No departure is within the export booking window on the selected day'
|
||
: 'The import booking window is not open for the selected day',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
|
||
* engine's representative fallbacks for bookings whose cargo/container type
|
||
* has no wagon type configured. Loaded once per request before mapping.
|
||
*/
|
||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||
* table scan per detail/board request without letting edits go stale long. */
|
||
private wagonTareDimsCache: {
|
||
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
|
||
expiresAt: number;
|
||
} | null = null;
|
||
|
||
private async loadWagonTareDims(): Promise<{
|
||
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
|
||
bulk: { tareWeightTons: number; capacityTons: number };
|
||
container: { tareWeightTons: number; capacityTons: number };
|
||
}> {
|
||
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
|
||
return this.wagonTareDimsCache.value;
|
||
}
|
||
const types = await this.dataSource.getRepository(WagonType).find();
|
||
const byWagonTypeId = new Map(
|
||
types.map((t) => [
|
||
t.id,
|
||
{
|
||
tareWeightTons: Number(t.tareWeightTons) || 0,
|
||
capacityTons: Number(t.capacityTons) || 0,
|
||
},
|
||
]),
|
||
);
|
||
const value = {
|
||
byWagonTypeId,
|
||
bulk: {
|
||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||
},
|
||
container: {
|
||
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||
},
|
||
};
|
||
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
|
||
* every wagon the booking occupies — the same gross axis the batch engine
|
||
* spends against the locomotive's pull limit. Wagon count mirrors the batch
|
||
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
|
||
* tons ÷ payload for bulk — whichever is largest).
|
||
*/
|
||
private grossBookingWeightTons(
|
||
booking: Pick<
|
||
Booking,
|
||
| 'freightType'
|
||
| 'cargoTotalWeightVgm'
|
||
| 'wagonsRequired'
|
||
| 'bookingContainers'
|
||
| 'cargoType'
|
||
>,
|
||
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
|
||
): number {
|
||
const cargo = bookingCargoTons(booking);
|
||
const fallback =
|
||
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
|
||
// Same first-configured-type resolution the batch engine's dimsFor uses.
|
||
const wagonTypeId =
|
||
booking.freightType === 'BULK'
|
||
? booking.cargoType?.wagonTypes?.[0]?.id
|
||
: (booking.bookingContainers ?? [])
|
||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||
.map((wagonType) => wagonType.id)
|
||
.find((id): id is string => Boolean(id));
|
||
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
|
||
const dims = {
|
||
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
|
||
capacityTons: typed?.capacityTons || fallback.capacityTons,
|
||
};
|
||
|
||
const stored =
|
||
booking.wagonsRequired && booking.wagonsRequired > 0
|
||
? Math.ceil(booking.wagonsRequired)
|
||
: 0;
|
||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||
// wagon) — more wagons for the same cargo, so more tare to pull.
|
||
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
|
||
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
|
||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||
const byItems = bulkItemWagonsRequired(
|
||
booking,
|
||
dims.capacityTons,
|
||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||
);
|
||
const wagons = Math.max(1, stored, byLength, byWeight, byItems);
|
||
return roundTons(cargo + wagons * dims.tareWeightTons);
|
||
}
|
||
|
||
private async mapScheduleDetail(
|
||
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||
) {
|
||
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
||
(w) => w.allocations ?? [],
|
||
);
|
||
const allocationIds = allocations.map((a) => a.id);
|
||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||
|
||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||
// loading gate, so the workspace shows the confirm button as already done.
|
||
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
|
||
|
||
// Snapshot state decides below whether the live consist may be drawn at
|
||
// all, so it is derived before the consist wagons are fetched.
|
||
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||
// the per-slot wagon number + booking allocations are read from it instead.
|
||
const snapshot = schedule.wagonAllocationSnapshot ?? null;
|
||
const isWagonAllocationFrozen = Boolean(
|
||
snapshot &&
|
||
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||
schedule.status !== TrainScheduleStatusEnum.Scheduled,
|
||
);
|
||
const snapshotSlotByTrainSetWagonId = new Map(
|
||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||
);
|
||
|
||
// Booking has no ORM relation to Contract (FK only) — fetched separately
|
||
// by id so the "on this train" cards can show the contract reference.
|
||
const contractIds = [
|
||
...new Set(
|
||
(schedule.scheduleBookings ?? [])
|
||
.map((sb) => sb.booking?.contractId)
|
||
.filter((id): id is string => Boolean(id)),
|
||
),
|
||
];
|
||
|
||
// All independent lookups fired at once — they used to run one after
|
||
// another, stacking round-trips onto every detail request.
|
||
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
||
// number the locomotive actually hauls against its pull limit.
|
||
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] =
|
||
await Promise.all([
|
||
this.loadWagonTareDims(),
|
||
requiresLoadingConfirmation
|
||
? this.dataSource
|
||
.getRepository(ImportDjiboutiOperation)
|
||
.findOne({ where: { trainScheduleId: schedule.id } })
|
||
: null,
|
||
this.getWindowConfig(),
|
||
allocationIds.length
|
||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||
relations: { containerType: true, bookingContainer: true },
|
||
})
|
||
: [],
|
||
allocationIds.length
|
||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||
relations: { cargoType: true },
|
||
})
|
||
: [],
|
||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||
? this.dataSource.getRepository(Wagon).find({
|
||
where: { trainId: schedule.trainSet.trainId },
|
||
relations: { wagonType: true },
|
||
// Mirror the pinning direction: a reverse-order schedule draws the
|
||
// whole consist back-to-front, empties included.
|
||
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
||
})
|
||
: [],
|
||
contractIds.length
|
||
? this.dataSource
|
||
.getRepository(Contract)
|
||
.find({ where: { id: In(contractIds) }, select: { id: true, reference: true } })
|
||
: [],
|
||
]);
|
||
const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference]));
|
||
const loadingConfirmed = requiresLoadingConfirmation
|
||
? Boolean(importOp?.loadedOnTrainAt)
|
||
: true;
|
||
|
||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||
for (const item of containerItems) {
|
||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||
list.push(item);
|
||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||
}
|
||
const bulkLoadsByAllocation = new Map(
|
||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||
);
|
||
|
||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||
// included (the pull-limit check already counts their tare) — so append the
|
||
// train's remaining wagons as consist-only entries and the composition views
|
||
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
|
||
// really is: loaded slots first, then the empty consist. Skipped for frozen
|
||
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
|
||
// later trains, so the live consist no longer describes THIS departure.
|
||
const coveredPhysicalIds = new Set<string>();
|
||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||
const frozenSlot = isWagonAllocationFrozen
|
||
? snapshotSlotByTrainSetWagonId.get(slot.id)
|
||
: undefined;
|
||
const physicalId = frozenSlot
|
||
? frozenSlot.physicalWagonId
|
||
: slot.physicalWagonId ?? null;
|
||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||
}
|
||
// Fallback only — real empty rows below carry the wagon's OWN physical
|
||
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
|
||
const maxSlotSequenceNo = Math.max(
|
||
0,
|
||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||
);
|
||
const emptyConsistWagons = rawConsistWagons
|
||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||
.map((wagon, index) => ({
|
||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||
id: wagon.id,
|
||
// The wagon's REAL coupling position, so an empty wagon in the middle
|
||
// of the train draws in the middle — not appended after every loaded
|
||
// slot. Falls back to a tail position only if the wagon somehow has
|
||
// no sequence number of its own.
|
||
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
|
||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||
assignedWeightTons: 0,
|
||
tareWeightTons: wagon.wagonType
|
||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||
: null,
|
||
status: 'EMPTY',
|
||
// Coupled wagons ride the whole corridor — they count on every leg.
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
physicalWagonId: wagon.id,
|
||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||
wagonType: wagon.wagonType
|
||
? {
|
||
id: wagon.wagonType.id,
|
||
code: wagon.wagonType.code,
|
||
name: wagon.wagonType.name,
|
||
}
|
||
: null,
|
||
allocations: [],
|
||
consistOnly: true,
|
||
}));
|
||
|
||
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
|
||
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
|
||
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
|
||
// the slot's own stored value.
|
||
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
|
||
list: T[],
|
||
) =>
|
||
orderConsistWagons(list, {
|
||
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
|
||
reverseWagonOrder: schedule.reverseWagonOrder,
|
||
});
|
||
|
||
// Heaviest-edge consist usage. Cross-leg slot sharing means plain sums
|
||
// over-report a multi-stop train — a wagon reused Gelan→Adama and
|
||
// Adama→Doraleh is two slots but ONE physical wagon, and the train is never
|
||
// heavier/longer than its heaviest single leg. Same math as the pull-limit
|
||
// enforcement; coupled-but-empty consist wagons ride every edge.
|
||
const heaviestLeg = schedule.trainSet
|
||
? (() => {
|
||
const usage = maxEdgeConsistUsage(
|
||
[
|
||
...(schedule.trainSet.wagons ?? []).map((w) => ({
|
||
lengthMeters: Number(w.lengthMeters),
|
||
tareWeightTons: w.wagonType
|
||
? Number(w.wagonType.tareWeightTons)
|
||
: 0,
|
||
assignedWeightTons: Number(w.assignedWeightTons),
|
||
boardYardId: w.boardYardId ?? null,
|
||
alightYardId: w.alightYardId ?? null,
|
||
allocations: w.allocations ?? [],
|
||
})),
|
||
...emptyConsistWagons.map((w) => ({
|
||
lengthMeters: w.lengthMeters,
|
||
tareWeightTons: Number(w.tareWeightTons ?? 0),
|
||
assignedWeightTons: 0,
|
||
allocations: [],
|
||
})),
|
||
],
|
||
this.mapScheduleStops(schedule).map((s) => s.yardId),
|
||
);
|
||
return {
|
||
grossWeightTons: roundTons(usage.grossWeightTons),
|
||
lengthMeters: roundTons(usage.lengthMeters),
|
||
loadedWagonCount: usage.loadedWagonCount,
|
||
};
|
||
})()
|
||
: null;
|
||
|
||
return {
|
||
id: schedule.id,
|
||
reference: schedule.reference ?? null,
|
||
status: schedule.status,
|
||
freightType: this.resolveScheduleFreightType(schedule),
|
||
trainNumber: schedule.trainNumber ?? null,
|
||
voyageNumber: schedule.voyageNumber ?? null,
|
||
maxWagons: schedule.maxWagons ?? null,
|
||
direction: schedule.direction ?? null,
|
||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||
requiresLoadingConfirmation,
|
||
loadingConfirmed,
|
||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||
// operations workspace (display only — the window engine enforces them).
|
||
windowPhase: schedule.windowPhase ?? null,
|
||
windowOpensAt: schedule.windowOpensAt
|
||
? schedule.windowOpensAt.toISOString()
|
||
: null,
|
||
windowClosesAt: schedule.windowClosesAt
|
||
? schedule.windowClosesAt.toISOString()
|
||
: null,
|
||
docReviewEndsAt: schedule.docReviewEndsAt
|
||
? schedule.docReviewEndsAt.toISOString()
|
||
: null,
|
||
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
|
||
? schedule.paymentPhaseEndsAt.toISOString()
|
||
: null,
|
||
// Per-schedule booking-window rule snapshot — powers the "Booking window
|
||
// settings" editor on the ops board (prefill + save one schedule's
|
||
// override). docReview/payment are not snapshotted per schedule (only their
|
||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
||
windowRule: {
|
||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||
windowDurationHours:
|
||
schedule.ruleWindowDurationHours != null
|
||
? Number(schedule.ruleWindowDurationHours)
|
||
: null,
|
||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||
// Editor prefill: this schedule's own override when staff set one,
|
||
// else the live global for the schedule's direction (import/export
|
||
// pay windows are tuned separately).
|
||
paymentWindowMinutes:
|
||
schedule.rulePaymentWindowMinutes ??
|
||
(schedule.direction === 'EXPORT'
|
||
? windowCfg.exportPaymentWindowMinutes
|
||
: windowCfg.paymentWindowMinutes),
|
||
},
|
||
route: schedule.route
|
||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||
: null,
|
||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||
originStation: schedule.originStation,
|
||
destinationStation: schedule.destinationStation,
|
||
// Built train (Train Builder) behind this departure, when scheduled by train.
|
||
train: schedule.trainSet?.train
|
||
? {
|
||
id: schedule.trainSet.train.id,
|
||
code: schedule.trainSet.train.code,
|
||
trainName: schedule.trainSet.train.trainName ?? null,
|
||
}
|
||
: null,
|
||
trainSet: schedule.trainSet
|
||
? {
|
||
id: schedule.trainSet.id,
|
||
status: schedule.trainSet.status,
|
||
wagonCount: schedule.trainSet.wagonCount,
|
||
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
|
||
totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
|
||
// What the locomotives actually haul: usage on the corridor's
|
||
// heaviest edge, not the sum of every leg's slots.
|
||
heaviestLeg,
|
||
locomotive: schedule.trainSet.locomotive
|
||
? {
|
||
id: schedule.trainSet.locomotive.id,
|
||
code: schedule.trainSet.locomotive.code,
|
||
name: schedule.trainSet.locomotive.name,
|
||
status: schedule.trainSet.locomotive.status,
|
||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||
maxPullWeightTons: roundTons(
|
||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||
),
|
||
maxTrainLengthMeters: roundTons(
|
||
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||
),
|
||
}
|
||
: null,
|
||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||
id: loco.id,
|
||
code: loco.code,
|
||
name: loco.name ?? null,
|
||
status: loco.status,
|
||
currentYardId: loco.currentYardId ?? null,
|
||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||
})),
|
||
wagons: drawConsist(
|
||
(schedule.trainSet.wagons ?? [])
|
||
.map((wagon) => {
|
||
// Frozen schedules read the wagon number + allocations from the
|
||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||
// comes live. Falls back to live if a slot is missing from the snap.
|
||
const frozenSlot = isWagonAllocationFrozen
|
||
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||
: undefined;
|
||
// Draw the slot at its physical wagon's REAL coupling position,
|
||
// not the planning-time slot index — the two diverge once a
|
||
// load has been dragged onto a different wagon (moveWagonLoad
|
||
// repoints physicalWagonId but a slot keeps its own sequenceNo),
|
||
// or once wagon types were interleaved at pinning time. Frozen
|
||
// and not-yet-pinned slots have no live physical wagon to trust,
|
||
// so they keep their own slot sequence.
|
||
const sequenceNo =
|
||
frozenSlot || !wagon.physicalWagon
|
||
? wagon.sequenceNo
|
||
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
|
||
return {
|
||
id: wagon.id,
|
||
sequenceNo,
|
||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||
// frontend needs it to show the gross train weight.
|
||
tareWeightTons: wagon.wagonType
|
||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||
: null,
|
||
status: wagon.status,
|
||
// Corridor span this slot rides (null = schedule endpoint) —
|
||
// lets the UI compute per-leg utilization from real slots.
|
||
boardYardId: wagon.boardYardId ?? null,
|
||
alightYardId: wagon.alightYardId ?? null,
|
||
physicalWagonId: frozenSlot
|
||
? frozenSlot.physicalWagonId
|
||
: wagon.physicalWagonId ?? null,
|
||
physicalWagonNumber: frozenSlot
|
||
? frozenSlot.physicalWagonNumber
|
||
: wagon.physicalWagon?.wagonNumber ?? null,
|
||
wagonType: wagon.wagonType
|
||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||
: null,
|
||
allocations: frozenSlot
|
||
? frozenSlot.allocations.map((allocation) => ({
|
||
id: null,
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.bookingReference,
|
||
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
|
||
loadType: allocation.loadType,
|
||
status: null,
|
||
// Frozen: container detail collapses to the captured numbers;
|
||
// per-container geometry isn't re-derivable post-release.
|
||
containerItems: allocation.containerNumbers.map((containerNumber) => ({
|
||
id: null,
|
||
containerNumber,
|
||
containerTypeId: null,
|
||
grossWeightTons: null,
|
||
containerId: null,
|
||
positionOnWagon: null,
|
||
bookingContainerId: null,
|
||
})),
|
||
bulkLoad: null,
|
||
}))
|
||
: wagon.allocations?.map((allocation) => ({
|
||
id: allocation.id,
|
||
bookingId: allocation.bookingId,
|
||
bookingReference: allocation.booking?.reference ?? null,
|
||
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
||
loadType: allocation.loadType ?? null,
|
||
status: allocation.status,
|
||
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
||
(item) => ({
|
||
id: item.id,
|
||
containerNumber: item.containerNumber ?? null,
|
||
containerTypeId: item.containerTypeId,
|
||
grossWeightTons: item.grossWeightTons ?? null,
|
||
containerId: item.containerId ?? null,
|
||
positionOnWagon: item.positionOnWagon ?? null,
|
||
bookingContainerId: item.bookingContainerId ?? null,
|
||
}),
|
||
),
|
||
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
|
||
? {
|
||
id: bulkLoadsByAllocation.get(allocation.id)!.id,
|
||
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
|
||
cargoDescription:
|
||
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
|
||
}
|
||
: null,
|
||
})) ?? [],
|
||
};
|
||
})
|
||
.concat(emptyConsistWagons),
|
||
),
|
||
}
|
||
: null,
|
||
bookings:
|
||
schedule.scheduleBookings?.map((sb) => ({
|
||
id: sb.booking?.id ?? sb.bookingId,
|
||
reference: sb.booking?.reference ?? null,
|
||
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
|
||
weightTons: sb.booking
|
||
? this.grossBookingWeightTons(sb.booking, tareDims)
|
||
: 0,
|
||
// Cargo only (VGM / bulk tons) — what the customer actually booked,
|
||
// without the wagons' tare. The legs tab shows this per booking.
|
||
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
|
||
status: sb.booking?.status ?? null,
|
||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||
freightType: sb.booking?.freightType ?? null,
|
||
// Which leg of the corridor this booking rides — the workspace can't
|
||
// tell a ride-along (intercity) or sub-corridor booking from through
|
||
// cargo without it.
|
||
tradeDirection: sb.booking?.tradeDirection ?? null,
|
||
originYardId: sb.booking?.originYardId ?? null,
|
||
destinationYardId: sb.booking?.destinationYardId ?? null,
|
||
origin:
|
||
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
|
||
destination:
|
||
sb.booking?.destinationYard?.label ??
|
||
sb.booking?.destinationYard?.code ??
|
||
null,
|
||
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
|
||
contractReference:
|
||
(sb.booking?.contractId
|
||
? contractReferenceById.get(sb.booking.contractId)
|
||
: null) ?? null,
|
||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||
// booking itself — staff flip it per booking in the workspace before
|
||
// dispatch. Defaults UNLOADED for links written before the column.
|
||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||
isGovernment: Boolean(sb.booking?.isGovernment),
|
||
})) ?? [],
|
||
// Ordered corridor stops (route milestones; falls back to the two
|
||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||
stops: this.mapScheduleStops(schedule),
|
||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||
// above are gross too, so the strip can sum them per leg against this.
|
||
maxGrossWeightTons: (() => {
|
||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||
return setLimits
|
||
? roundTons(
|
||
Number(setLimits.maxPullWeightTons) +
|
||
(Number(setLimits.overageToleranceTons) || 0),
|
||
)
|
||
: null;
|
||
})(),
|
||
// Length ceiling per leg, same shape as maxGrossWeightTons: the set's
|
||
// most restrictive locomotive length plus its overage tolerance.
|
||
maxLengthMeters: (() => {
|
||
const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
|
||
const cap =
|
||
Number(setLimits?.maxTrainLengthMeters) +
|
||
(Number(setLimits?.overageToleranceMeters) || 0);
|
||
return setLimits && Number.isFinite(cap) ? roundTons(cap) : null;
|
||
})(),
|
||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||
// it "historical" and skip re-pin affordances.
|
||
isWagonAllocationFrozen,
|
||
wagonAllocationSnapshot: snapshot,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A booking's wagon footprint with a computed fallback: rows linked by paths
|
||
* that never stamped `wagonsRequired` (legacy allocate) read NULL, and every
|
||
* occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed
|
||
* a near-empty leg. Falls back to the TEU/weight-derived count when the cargo
|
||
* relations are loaded; a bare booking still degrades to 1.
|
||
*/
|
||
private effectiveWagonsRequired(booking: Booking): number {
|
||
const stored = Number(booking.wagonsRequired);
|
||
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
|
||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||
.map((wt) => Number(wt.capacityTons))
|
||
.filter((c) => c > 0);
|
||
const bulkCapacity =
|
||
booking.freightType === 'BULK' && bulkCapacities.length
|
||
? Math.max(...bulkCapacities)
|
||
: undefined;
|
||
// BULK with no cargo relations loaded: recomputing would size against a
|
||
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
|
||
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
|
||
return storedCeil;
|
||
}
|
||
// Stored is a candidate, never an early return (batch parity): rows
|
||
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
|
||
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
|
||
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
|
||
}
|
||
|
||
/**
|
||
* yardId → display label for error messages that name corridor legs. One
|
||
* query; unknown ids fall back to the raw id so a message never goes blank.
|
||
*/
|
||
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
|
||
if (!yardIds.length) return new Map();
|
||
const yards = await this.dataSource
|
||
.getRepository(Yard)
|
||
.find({ where: { id: In(yardIds) } });
|
||
return new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
||
}
|
||
|
||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||
private mapScheduleStops(
|
||
schedule: TrainSchedule,
|
||
): Array<{ yardId: string; label: string }> {
|
||
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||
);
|
||
const raw = milestones.length >= 2
|
||
? milestones.map((m) => ({
|
||
yardId: m.yardId,
|
||
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||
}))
|
||
: [
|
||
{
|
||
yardId: schedule.originStationId,
|
||
label:
|
||
schedule.originStation?.label ??
|
||
schedule.originStation?.code ??
|
||
schedule.originStationId,
|
||
},
|
||
{
|
||
yardId: schedule.destinationStationId,
|
||
label:
|
||
schedule.destinationStation?.label ??
|
||
schedule.destinationStation?.code ??
|
||
schedule.destinationStationId,
|
||
},
|
||
];
|
||
const seen = new Set<string>();
|
||
return raw.filter((stop) => {
|
||
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
||
seen.add(stop.yardId);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
private isHoldActive(booking: Booking): boolean {
|
||
if (!booking.holdExpiresAt) return false;
|
||
return booking.holdExpiresAt.getTime() > Date.now();
|
||
}
|
||
|
||
private resolvePostUnassignStatus(booking: Booking | null): string {
|
||
if (!booking) return SchedulingStatus.NotScheduled;
|
||
if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
|
||
return SchedulingStatus.Holding;
|
||
}
|
||
return SchedulingStatus.Eligible;
|
||
}
|
||
|
||
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
|
||
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!schedule.trainSet?.locomotive) {
|
||
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot assign bookings to schedule in status ${schedule.status}`,
|
||
);
|
||
}
|
||
|
||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||
if (!booking) {
|
||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
}
|
||
if (booking.trainScheduleId !== scheduleId) {
|
||
throw new BadRequestException('Booking is not linked to this schedule');
|
||
}
|
||
if (!this.isReadyToLoadBooking(booking)) {
|
||
throw new BadRequestException('Booking is not paid and ready to load');
|
||
}
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
if (wagonAssignedIds.has(bookingId)) {
|
||
throw new BadRequestException('Booking is already assigned to a wagon');
|
||
}
|
||
|
||
const allBookingIds = [...wagonAssignedIds, bookingId];
|
||
const previewDto = {
|
||
bookingIds: allBookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
trainSetLocomotiveLimits(schedule.trainSet),
|
||
schedule.maxWagons ?? undefined,
|
||
);
|
||
|
||
const validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
|
||
if (!validation.valid) {
|
||
throw new BadRequestException({
|
||
message: `Booking validation failed: ${validation.violations.join('; ')}`,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
});
|
||
}
|
||
|
||
if (!validation.bookings.some((b) => b.id === bookingId)) {
|
||
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
|
||
throw new BadRequestException({
|
||
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
deferredBookings: validation.deferredBookings,
|
||
});
|
||
}
|
||
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
|
||
(m) => m.bookingId === bookingId,
|
||
);
|
||
if (missingForBooking) {
|
||
throw new BadRequestException({
|
||
message: missingForBooking.issue,
|
||
violations: [missingForBooking.issue],
|
||
});
|
||
}
|
||
|
||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||
const needsPlacements = containerBookings.length > 0;
|
||
|
||
return this.assignBookingsToSchedule(
|
||
scheduleId,
|
||
{
|
||
bookingIds: validation.bookings.map((b) => b.id),
|
||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||
},
|
||
undefined,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Government-priority switch: free wagons by unassigning the selected
|
||
* commercial bookings, then allocate the government booking in their place.
|
||
* The gov booking must need no more wagons than the switched-out bookings
|
||
* free (ops selects more bookings otherwise), and the post-switch
|
||
* composition is fully validated BEFORE anything is unassigned so a failing
|
||
* switch never leaves the train half-emptied.
|
||
*/
|
||
async switchGovernmentBooking(
|
||
scheduleId: string,
|
||
governmentBookingId: string,
|
||
removeBookingIds: string[],
|
||
userId?: string,
|
||
) {
|
||
if (removeBookingIds.includes(governmentBookingId)) {
|
||
throw new BadRequestException('Government booking cannot be switched out by itself');
|
||
}
|
||
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot switch bookings on a schedule in status ${schedule.status}`,
|
||
);
|
||
}
|
||
|
||
const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([
|
||
governmentBookingId,
|
||
]);
|
||
if (!govBooking) {
|
||
throw new NotFoundException(`Booking ${governmentBookingId} not found`);
|
||
}
|
||
if (!govBooking.isGovernment) {
|
||
throw new BadRequestException('Only government bookings can be switched onto a train');
|
||
}
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
if (wagonAssignedIds.has(governmentBookingId)) {
|
||
throw new BadRequestException('Government booking is already allocated on this train');
|
||
}
|
||
|
||
const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds);
|
||
if (removed.length !== removeBookingIds.length) {
|
||
throw new NotFoundException('One or more bookings to switch out were not found');
|
||
}
|
||
const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id));
|
||
if (notOnTrain.length) {
|
||
throw new BadRequestException(
|
||
`Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`,
|
||
);
|
||
}
|
||
const govRemoved = removed.filter((b) => b.isGovernment);
|
||
if (govRemoved.length) {
|
||
throw new BadRequestException(
|
||
`Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`,
|
||
);
|
||
}
|
||
|
||
// Dry-run the post-switch composition: survivors + the gov booking.
|
||
const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id));
|
||
const previewDto = {
|
||
bookingIds: [...survivorIds, governmentBookingId],
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
trainSetLocomotiveLimits(schedule.trainSet),
|
||
schedule.maxWagons ?? undefined,
|
||
);
|
||
const validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
scheduleId,
|
||
);
|
||
const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0);
|
||
if (!validation.valid) {
|
||
throw new BadRequestException({
|
||
message: `Switch validation failed: ${validation.violations.join('; ')}`,
|
||
violations: validation.violations,
|
||
warnings: validation.warnings,
|
||
});
|
||
}
|
||
if (!validation.bookings.some((b) => b.id === governmentBookingId)) {
|
||
throw new BadRequestException(
|
||
`Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`,
|
||
);
|
||
}
|
||
const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan);
|
||
if (govWagons > freedWagons) {
|
||
throw new BadRequestException(
|
||
`Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`,
|
||
);
|
||
}
|
||
|
||
// Same container-number gate as single-booking assignment, applied to the
|
||
// incoming gov booking only.
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missingForGov = findMissingContainerNumberIssues(units, placements).find(
|
||
(m) => m.bookingId === governmentBookingId,
|
||
);
|
||
if (missingForGov) {
|
||
throw new BadRequestException({
|
||
message: missingForGov.issue,
|
||
violations: [missingForGov.issue],
|
||
});
|
||
}
|
||
|
||
// ponytail: unassign + assign run as sequential own-transaction steps, not
|
||
// one atomic unit — the dry-run above means the assign step can only fail
|
||
// on a concurrent edit; staff re-add from the eligible pool if it does.
|
||
for (const booking of removed) {
|
||
await this.unassignBooking(scheduleId, booking.id, userId);
|
||
}
|
||
|
||
const assignableSet = new Set(validation.bookings.map((b) => b.id));
|
||
const assignPlacements = placementsForBookings(placements, assignableSet, units);
|
||
return this.assignBookingsToSchedule(
|
||
scheduleId,
|
||
{
|
||
bookingIds: validation.bookings.map((b) => b.id),
|
||
containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined,
|
||
},
|
||
undefined,
|
||
);
|
||
}
|
||
|
||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||
async previewAllocationForSchedule(
|
||
scheduleId: string,
|
||
// Callers that already hold the full schedule graph (batch board detail)
|
||
// pass it in so the preview doesn't re-load the same heavy graph.
|
||
preloadedSchedule?: TrainSchedule,
|
||
): Promise<WagonAllocationAttemptResult> {
|
||
const schedule =
|
||
preloadedSchedule ??
|
||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
return this.buildAllocationAttempt(schedule, false);
|
||
}
|
||
|
||
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
|
||
async tryAutoWagonAllocation(
|
||
scheduleId: string,
|
||
): Promise<WagonAllocationAttemptResult> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
return this.buildAllocationAttempt(schedule, true);
|
||
}
|
||
|
||
private async buildAllocationAttempt(
|
||
schedule: TrainSchedule,
|
||
performAssign: boolean,
|
||
): Promise<WagonAllocationAttemptResult> {
|
||
const empty: WagonAllocationAttemptResult = {
|
||
assignedBookingIds: [],
|
||
deferred: [],
|
||
issues: [],
|
||
violations: [],
|
||
};
|
||
|
||
if (!schedule.trainSet?.locomotive) {
|
||
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
return {
|
||
...empty,
|
||
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
|
||
};
|
||
}
|
||
|
||
const linkedBookings = (schedule.scheduleBookings ?? [])
|
||
.map((sb) => sb.booking)
|
||
.filter((b): b is Booking => Boolean(b));
|
||
const eligible = linkedBookings.filter(
|
||
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
|
||
);
|
||
if (!eligible.length) return empty;
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
|
||
schedule.id,
|
||
schedule,
|
||
);
|
||
const previewDto = {
|
||
bookingIds: eligible.map((b) => b.id),
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
trainSetLocomotiveLimits(schedule.trainSet),
|
||
schedule.maxWagons ?? undefined,
|
||
);
|
||
|
||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||
try {
|
||
validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
schedule.id,
|
||
);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Validation failed';
|
||
return {
|
||
...empty,
|
||
violations: [message],
|
||
issues: eligible.map((b) => ({
|
||
bookingId: b.id,
|
||
status: 'FAILED' as const,
|
||
issue: message,
|
||
})),
|
||
};
|
||
}
|
||
|
||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||
const deferredMap = new Map(
|
||
validation.deferredBookings.map((d) => [d.id, d.reason]),
|
||
);
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missingNumbers = findMissingContainerNumberIssues(units, placements);
|
||
const missingByBooking = new Map<string, string>();
|
||
for (const m of missingNumbers) {
|
||
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
|
||
}
|
||
const placeholderWarnings = new Map<string, string>();
|
||
for (const p of placements) {
|
||
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
|
||
const unit = units.find(
|
||
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
|
||
);
|
||
if (unit && !placeholderWarnings.has(unit.bookingId)) {
|
||
placeholderWarnings.set(
|
||
unit.bookingId,
|
||
'Container number auto-assigned — verify before dispatch.',
|
||
);
|
||
}
|
||
}
|
||
|
||
const assignableIds = validation.bookings
|
||
.filter((b) => !missingByBooking.has(b.id))
|
||
.map((b) => b.id);
|
||
const assignableSet = new Set(assignableIds);
|
||
const assignPlacements = placementsForBookings(
|
||
placements,
|
||
assignableSet,
|
||
units,
|
||
);
|
||
|
||
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
|
||
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
|
||
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
|
||
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
|
||
}
|
||
if (missingByBooking.has(b.id)) {
|
||
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
|
||
}
|
||
if (deferredMap.has(b.id)) {
|
||
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
|
||
}
|
||
if (!fittingIds.has(b.id)) {
|
||
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
|
||
return {
|
||
bookingId: b.id,
|
||
status: 'FAILED',
|
||
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
|
||
};
|
||
}
|
||
if (wagonAssignedIds.has(b.id)) {
|
||
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
|
||
}
|
||
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
|
||
});
|
||
|
||
const result: WagonAllocationAttemptResult = {
|
||
assignedBookingIds: [],
|
||
deferred: validation.deferredBookings,
|
||
issues,
|
||
violations: validation.violations,
|
||
};
|
||
|
||
if (!performAssign || !assignableIds.length) return result;
|
||
|
||
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
|
||
if (needsPlacements && !assignPlacements.length) {
|
||
return {
|
||
...result,
|
||
violations: [...result.violations, 'Container placements could not be generated'],
|
||
};
|
||
}
|
||
|
||
try {
|
||
await this.assignBookingsToSchedule(
|
||
schedule.id,
|
||
{
|
||
bookingIds: assignableIds,
|
||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
||
},
|
||
undefined,
|
||
);
|
||
result.assignedBookingIds = assignableIds;
|
||
for (const issue of result.issues) {
|
||
if (assignableSet.has(issue.bookingId)) {
|
||
issue.status = 'ASSIGNED';
|
||
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof BadRequestException
|
||
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
|
||
'; ',
|
||
) ??
|
||
(err.getResponse() as { message?: string }).message ??
|
||
err.message)
|
||
: err instanceof Error
|
||
? err.message
|
||
: 'Allocation failed';
|
||
result.violations = [...result.violations, message];
|
||
for (const issue of result.issues) {
|
||
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
|
||
issue.status = 'FAILED';
|
||
issue.issue = message;
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
|
||
}
|
||
|
||
const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
|
||
if (!wagon) {
|
||
throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
|
||
}
|
||
|
||
if ((wagon.allocations ?? []).length > 0) {
|
||
throw new BadRequestException(
|
||
'Cannot remove a wagon slot that has active allocations; remove the booking first',
|
||
);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
|
||
// Recount from the slot rows rather than decrementing the cached counter.
|
||
// A blind `wagonCount - 1` desyncs the moment two removals race or the
|
||
// in-memory schedule graph is stale, and the counter is what the schedule
|
||
// capacity math reads.
|
||
const remaining = await manager.getRepository(TrainSetWagon).find({
|
||
where: { trainSetId: schedule.trainSetId },
|
||
select: { id: true, lengthMeters: true },
|
||
});
|
||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||
wagonCount: remaining.length,
|
||
totalLengthMeters: roundTons(
|
||
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
|
||
),
|
||
});
|
||
});
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async updateContainerItem(
|
||
scheduleId: string,
|
||
itemId: string,
|
||
dto: UpdateContainerItemDto,
|
||
): Promise<{ id: string; containerNumber: string | null }> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (schedule.status === 'DISPATCHED') {
|
||
throw new BadRequestException('Cannot edit a dispatched schedule');
|
||
}
|
||
|
||
const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
|
||
where: { id: itemId },
|
||
relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
|
||
});
|
||
|
||
if (!item) {
|
||
throw new NotFoundException(`Container item ${itemId} not found`);
|
||
}
|
||
|
||
const wagonId = item.wagonBookingAllocationId;
|
||
const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
|
||
where: { id: wagonId },
|
||
relations: ['trainSetWagon'],
|
||
});
|
||
|
||
if (!wagonAllocation?.trainSetWagon) {
|
||
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
|
||
}
|
||
|
||
const trainSetWagonId = wagonAllocation.trainSetWagon.id;
|
||
const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
|
||
if (!wagonIds.includes(trainSetWagonId)) {
|
||
throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
|
||
}
|
||
|
||
await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
|
||
containerNumber: dto.containerNumber ?? null,
|
||
});
|
||
|
||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||
}
|
||
|
||
/**
|
||
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||
* Whole-load moves keep every packing rule intact by construction (a valid
|
||
* load stays valid on any wagon whose type supports it), which is what lets
|
||
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||
* swap with containers.
|
||
*
|
||
* Three shapes, picked from the target:
|
||
* - target is an empty consist-only wagon (coupled on the built train, no
|
||
* slot row): REPIN — the source slot simply points at that physical wagon
|
||
* (type/capacity/length follow), and the wagon it left shows as empty.
|
||
* - target is an empty slot: allocations repoint to it and the load-coupled
|
||
* slot fields (assigned weight, status, board/alight leg) move across.
|
||
* - target is a loaded slot: the two loads swap wagons the same way.
|
||
*
|
||
* Validated per direction: the receiving wagon's type must support the
|
||
* incoming load type, and the incoming cargo must fit its rated payload.
|
||
*/
|
||
async moveWagonLoad(
|
||
scheduleId: string,
|
||
sourceWagonId: string,
|
||
dto: MoveWagonLoadDto,
|
||
): Promise<any> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
|
||
}
|
||
if (sourceWagonId === dto.targetWagonId) {
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
const slots = schedule.trainSet?.wagons ?? [];
|
||
const source = slots.find((w) => w.id === sourceWagonId);
|
||
if (!source) {
|
||
throw new NotFoundException('Source wagon is not part of this schedule');
|
||
}
|
||
|
||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||
const loadAllocations = (trainSetWagonId: string) =>
|
||
allocRepo.find({ where: { trainSetWagonId } });
|
||
const sourceAllocs = await loadAllocations(source.id);
|
||
if (!sourceAllocs.length) {
|
||
throw new BadRequestException('Source wagon has no load to move');
|
||
}
|
||
|
||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||
// built train (physical wagon with no slot row yet).
|
||
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||
const wagonForTarget = slotById
|
||
? null
|
||
: schedule.trainSet?.trainId
|
||
? await this.dataSource.getRepository(Wagon).findOne({
|
||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||
relations: { wagonType: true },
|
||
})
|
||
: null;
|
||
if (!slotById && !wagonForTarget) {
|
||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||
}
|
||
// A physical wagon holds at most one slot. When the caller addressed the
|
||
// wagon directly but a slot is already pinned to it, move into that slot
|
||
// rather than minting a second one on the same wagon.
|
||
const targetSlot =
|
||
slotById ??
|
||
(wagonForTarget
|
||
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
|
||
: null);
|
||
const consistWagon = targetSlot ? null : wagonForTarget;
|
||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||
if (targetSlot && targetSlot.id === source.id) {
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||
];
|
||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||
// Name wagons by their physical number — the consist is drawn in the train's
|
||
// coupling order, so a slot's sequenceNo is not the position staff can see.
|
||
const slotLabel = (slot: TrainSetWagon) =>
|
||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
||
const checkReceives = (
|
||
allocs: WagonBookingAllocation[],
|
||
label: string,
|
||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||
capacityTons: number,
|
||
) => {
|
||
const incoming = loadTypesOf(allocs);
|
||
// Unknown type or no declared support list → staff decides; don't block.
|
||
if (wagonType) {
|
||
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
|
||
for (const loadType of incoming) {
|
||
const ok =
|
||
supported.includes(loadType) ||
|
||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||
supported.length === 0;
|
||
if (!ok) {
|
||
throw new BadRequestException(
|
||
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
const cargo = cargoOf(allocs);
|
||
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
|
||
throw new BadRequestException(
|
||
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
|
||
);
|
||
}
|
||
};
|
||
|
||
// What the target must be able to receive…
|
||
checkReceives(
|
||
sourceAllocs,
|
||
wagonLabel(targetSlot, consistWagon),
|
||
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||
);
|
||
// …and, on a swap, what comes back to the source.
|
||
if (targetAllocs.length) {
|
||
checkReceives(
|
||
targetAllocs,
|
||
slotLabel(source),
|
||
source.wagonType,
|
||
Number(source.capacityTons),
|
||
);
|
||
}
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const slotRepo = manager.getRepository(TrainSetWagon);
|
||
const allocs = manager.getRepository(WagonBookingAllocation);
|
||
|
||
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||
assignedWeightTons: slot.assignedWeightTons,
|
||
status: slot.status,
|
||
boardYardId: slot.boardYardId ?? null,
|
||
alightYardId: slot.alightYardId ?? null,
|
||
});
|
||
const emptyLoadFields = {
|
||
assignedWeightTons: 0,
|
||
status: 'PLANNED',
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
};
|
||
const sourceLoadFields = loadFieldsOf(source);
|
||
|
||
// Empty consist wagon with no slot row yet: give it one, then move the
|
||
// load into it. Repinning the SOURCE slot onto that wagon would have been
|
||
// fewer writes, but it renames the wagons instead of moving the load —
|
||
// the loaded slot becomes wagon B and B's identity pops out as an empty
|
||
// wagon where A used to be. Staff read that as the train re-ordering
|
||
// itself. A wagon must never change place because a container moved.
|
||
if (consistWagon) {
|
||
const { maxSequenceNo } = (await slotRepo
|
||
.createQueryBuilder('slot')
|
||
.select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo')
|
||
.where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId })
|
||
.getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 };
|
||
|
||
const created = await slotRepo.save(
|
||
slotRepo.create({
|
||
trainSetId: source.trainSetId,
|
||
wagonTypeId: consistWagon.wagonTypeId,
|
||
physicalWagonId: consistWagon.id,
|
||
// Plan-order key only — the consist is drawn in the train's coupling
|
||
// order (wagons.sequence_number), so appending here moves nothing.
|
||
// It just has to clear the (train_set_id, sequence_no) unique index.
|
||
sequenceNo: Number(maxSequenceNo) + 1,
|
||
capacityTons: roundTons(
|
||
Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons),
|
||
),
|
||
lengthMeters: roundTons(
|
||
Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters),
|
||
),
|
||
...sourceLoadFields,
|
||
}),
|
||
);
|
||
|
||
for (const alloc of sourceAllocs) {
|
||
await allocs.update(alloc.id, { trainSetWagonId: created.id });
|
||
}
|
||
await slotRepo.update(source.id, emptyLoadFields);
|
||
return;
|
||
}
|
||
|
||
const target = targetSlot as TrainSetWagon;
|
||
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||
|
||
for (const alloc of sourceAllocs) {
|
||
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||
}
|
||
for (const alloc of targetAllocs) {
|
||
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||
}
|
||
await slotRepo.update(target.id, sourceLoadFields);
|
||
await slotRepo.update(source.id, targetLoadFields);
|
||
});
|
||
|
||
return this.getTrainScheduleById(scheduleId);
|
||
}
|
||
|
||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
|
||
const allBookings = await this.bookingsRepository.findAll({
|
||
where: { trainScheduleId: scheduleId },
|
||
select: [
|
||
'id',
|
||
'reference',
|
||
'freightType',
|
||
'priorityScore',
|
||
'cargoTotalWeightVgm',
|
||
'status',
|
||
'schedulingStatus',
|
||
'paymentStatus',
|
||
'isGovernment',
|
||
],
|
||
});
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
|
||
const unassigned = allBookings
|
||
.filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
|
||
.sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
|
||
|
||
const fleetCounts = await this.countFleetAvailability(
|
||
schedule.originStationId,
|
||
scheduleId,
|
||
);
|
||
const fleetByTypeId = new Map(
|
||
fleetCounts.map((row) => [
|
||
row.wagonTypeId,
|
||
{ code: row.wagonTypeCode, available: row.available },
|
||
]),
|
||
);
|
||
const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
|
||
wagonTypeId: row.wagonTypeId,
|
||
wagonTypeCode: row.wagonTypeCode,
|
||
needed: 0,
|
||
available: row.available,
|
||
shortfall: 0,
|
||
}));
|
||
|
||
// Gross weight needs the scheduling graph (containers, cargo type, wagon
|
||
// types) that the trimmed select above deliberately skips.
|
||
const tareDims = await this.loadWagonTareDims();
|
||
const fullById = new Map(
|
||
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
|
||
(b) => [b.id, b],
|
||
),
|
||
);
|
||
|
||
const bookings = await Promise.all(
|
||
unassigned.map(async (b) => {
|
||
const assignability = await this.previewUnassignedBookingAssignability(
|
||
schedule,
|
||
wagonAssignedIds,
|
||
b as Booking,
|
||
fleetByTypeId,
|
||
);
|
||
return {
|
||
id: b.id,
|
||
reference: b.reference ?? null,
|
||
freightType: b.freightType ?? null,
|
||
priorityScore: b.priorityScore ?? 0,
|
||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||
grossWeightTons: this.grossBookingWeightTons(
|
||
(fullById.get(b.id) ?? b) as Booking,
|
||
tareDims,
|
||
),
|
||
status: b.status ?? null,
|
||
schedulingStatus: b.schedulingStatus ?? null,
|
||
...assignability,
|
||
};
|
||
}),
|
||
);
|
||
|
||
return { fleetAtOrigin, bookings };
|
||
}
|
||
|
||
private async previewUnassignedBookingAssignability(
|
||
schedule: TrainSchedule,
|
||
wagonAssignedIds: Set<string>,
|
||
booking: Booking,
|
||
fleetByTypeId: Map<string, { code: string; available: number }>,
|
||
): Promise<{
|
||
wagonsRequired: number;
|
||
requiredWagonTypeCode: string;
|
||
yardWagonsAvailable: number;
|
||
canAssign: boolean;
|
||
blockReason: string | null;
|
||
shortage: BookingWagonShortage | null;
|
||
}> {
|
||
if (!schedule.trainSet?.locomotive) {
|
||
return {
|
||
wagonsRequired: 0,
|
||
requiredWagonTypeCode: '',
|
||
yardWagonsAvailable: 0,
|
||
canAssign: false,
|
||
blockReason: 'Schedule has no locomotive',
|
||
shortage: null,
|
||
};
|
||
}
|
||
|
||
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
|
||
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
|
||
const resolvedBooking = fullBooking ?? booking;
|
||
// List-based resolution: every wagon type allowed for the booking's
|
||
// container/cargo type counts toward its availability.
|
||
const allowed = this.loadAllowedWagonTypes([resolvedBooking]);
|
||
const candidates =
|
||
freightType === 'BULK'
|
||
? [...allowed.byCargoTypeId.values()].flat()
|
||
: [...allowed.byContainerTypeId.values()].flat();
|
||
if (!candidates.length) {
|
||
return {
|
||
wagonsRequired: 0,
|
||
requiredWagonTypeCode: '',
|
||
yardWagonsAvailable: 0,
|
||
canAssign: false,
|
||
blockReason: 'No suitable wagon type found',
|
||
shortage: null,
|
||
};
|
||
}
|
||
|
||
const bulkCapacity =
|
||
freightType === 'BULK'
|
||
? Math.max(...candidates.map((wt) => Number(wt.capacityTons)))
|
||
: undefined;
|
||
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
|
||
const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/');
|
||
const yardWagonsAvailable = candidates.reduce(
|
||
(sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0),
|
||
0,
|
||
);
|
||
|
||
const allBookingIds = [...wagonAssignedIds, booking.id];
|
||
const previewDto = {
|
||
bookingIds: allBookingIds,
|
||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
};
|
||
const limits = await this.resolveTrainLimitConfig(
|
||
undefined,
|
||
trainSetLocomotiveLimits(schedule.trainSet),
|
||
schedule.maxWagons ?? undefined,
|
||
);
|
||
|
||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||
try {
|
||
validation = await this.validateBookingsForScheduling(
|
||
previewDto,
|
||
null,
|
||
false,
|
||
[],
|
||
false,
|
||
limits,
|
||
schedule.id,
|
||
);
|
||
} catch (err) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||
shortage: null,
|
||
};
|
||
}
|
||
|
||
if (!validation.valid) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||
shortage: null,
|
||
};
|
||
}
|
||
|
||
const fittingIds = new Set(validation.bookings.map((b) => b.id));
|
||
if (!fittingIds.has(booking.id)) {
|
||
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
|
||
const yardShortfall =
|
||
yardWagonsAvailable < wagonsRequired
|
||
? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
|
||
: null;
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason:
|
||
deferred?.reason ??
|
||
yardShortfall ??
|
||
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
|
||
shortage:
|
||
deferred?.shortage ??
|
||
(yardShortfall
|
||
? {
|
||
wagonTypeCodes: requiredWagonTypeCode,
|
||
wagonsNeeded: wagonsRequired,
|
||
wagonsAvailable: yardWagonsAvailable,
|
||
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
|
||
}
|
||
: null),
|
||
};
|
||
}
|
||
|
||
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
|
||
if (containerBookings.some((b) => b.id === booking.id)) {
|
||
const units = expandBookingContainerUnits(containerBookings);
|
||
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
|
||
const placements = autoFillPlacements(units, slots);
|
||
const missing = findMissingContainerNumberIssues(units, placements).find(
|
||
(m) => m.bookingId === booking.id,
|
||
);
|
||
if (missing) {
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode,
|
||
yardWagonsAvailable,
|
||
canAssign: false,
|
||
blockReason: missing.issue,
|
||
shortage: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
return {
|
||
wagonsRequired,
|
||
requiredWagonTypeCode,
|
||
yardWagonsAvailable,
|
||
canAssign: true,
|
||
blockReason: null,
|
||
shortage: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
|
||
* structured per-type shortage this booking would hit if placed on top of the
|
||
* schedule's current wagon assignments, or null when it fits (or is blocked
|
||
* by something other than missing wagons — those keep the legacy link-then-
|
||
* fix-manually path).
|
||
*/
|
||
async previewPaidBookingWagonShortage(
|
||
scheduleId: string,
|
||
bookingId: string,
|
||
): Promise<BookingWagonShortage | null> {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule?.trainSet?.locomotive) return null;
|
||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;
|
||
|
||
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
|
||
if (!booking) return null;
|
||
|
||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||
const fleetCounts = await this.countFleetAvailability(
|
||
schedule.originStationId,
|
||
scheduleId,
|
||
);
|
||
const fleetByTypeId = new Map(
|
||
fleetCounts.map((row) => [
|
||
row.wagonTypeId,
|
||
{ code: row.wagonTypeCode, available: row.available },
|
||
]),
|
||
);
|
||
|
||
const assignability = await this.previewUnassignedBookingAssignability(
|
||
schedule,
|
||
wagonAssignedIds,
|
||
booking,
|
||
fleetByTypeId,
|
||
);
|
||
return assignability.shortage;
|
||
}
|
||
|
||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||
private isReadyToLoadBooking(booking: {
|
||
status: string;
|
||
paymentStatus?: string | null;
|
||
isGovernment?: boolean;
|
||
}): boolean {
|
||
if (booking.status === 'EXPIRED') return false;
|
||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||
return false;
|
||
}
|
||
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
|
||
if (booking.isGovernment) return true;
|
||
return false;
|
||
}
|
||
|
||
async getCompositionRemovals(scheduleId: string): Promise<any[]> {
|
||
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||
}
|
||
|
||
private async getWagonAssignedBookingIds(
|
||
scheduleId: string,
|
||
// Pass when the caller already holds the schedule with trainSet.wagons —
|
||
// only wagon ids are read here, the old full-graph reload was pure waste.
|
||
preloadedSchedule?: TrainSchedule,
|
||
): Promise<Set<string>> {
|
||
const schedule =
|
||
preloadedSchedule ??
|
||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||
if (!wagonIds.length) return new Set();
|
||
|
||
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
|
||
where: { trainSetWagonId: In(wagonIds) },
|
||
select: ['bookingId'],
|
||
});
|
||
return new Set(allocations.map((a) => a.bookingId));
|
||
}
|
||
|
||
// ── Train merge ────────────────────────────────────────────────────────────
|
||
// Combine two trains into one departure. The schedule the action is taken
|
||
// from ALWAYS survives: its train set is repointed at the target train, the
|
||
// target's wagons join this consist, and the source train is emptied and
|
||
// deactivated. When the target also runs a schedule on the SAME DAY, that
|
||
// schedule's bookings move here and it is soft-deleted; the target's
|
||
// other-day schedules contribute wagons only.
|
||
|
||
/** Statuses whose schedules may take part in a merge. */
|
||
private static readonly MERGEABLE_STATUSES: string[] = [
|
||
TrainScheduleStatusEnum.Draft,
|
||
TrainScheduleStatusEnum.Scheduled,
|
||
];
|
||
|
||
/**
|
||
* Everything a merge needs to decide, gathered once. Both `previewMerge` and
|
||
* `mergeScheduleTrain` run this so the modal shows exactly what will happen
|
||
* and the commit cannot diverge from it.
|
||
*/
|
||
private async planMerge(scheduleId: string, targetTrainId: string) {
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) {
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
}
|
||
if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) {
|
||
throw new BadRequestException(
|
||
`Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`,
|
||
);
|
||
}
|
||
|
||
const sourceTrainId = schedule.trainSet?.trainId ?? null;
|
||
if (sourceTrainId && sourceTrainId === targetTrainId) {
|
||
throw new BadRequestException(
|
||
'That is already this schedule\'s train — pick a different one to merge in.',
|
||
);
|
||
}
|
||
|
||
const targetTrain = await this.dataSource
|
||
.getRepository(Train)
|
||
.findOne({ where: { id: targetTrainId } });
|
||
if (!targetTrain) {
|
||
throw new NotFoundException(`Train ${targetTrainId} not found`);
|
||
}
|
||
|
||
// Every schedule the target train is committed to, via its train sets.
|
||
// Locomotives come along: the merged train is pulled by the union of this
|
||
// schedule's locos and the target train's, so capacity checks need both.
|
||
const targetSets = await this.dataSource
|
||
.getRepository(TrainSet)
|
||
.find({
|
||
where: { trainId: targetTrainId },
|
||
relations: { locomotives: { locomotive: true }, locomotive: true },
|
||
});
|
||
const targetSetIds = targetSets.map((s) => s.id);
|
||
const targetSchedules = targetSetIds.length
|
||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||
where: { trainSetId: In(targetSetIds) },
|
||
})
|
||
: [];
|
||
|
||
// The same-day schedule is the one whose bookings move here. Only a
|
||
// draft/scheduled one qualifies — a dispatched departure keeps its cargo.
|
||
const sameDay = (a: Date | string, b: Date | string) =>
|
||
new Date(a).toISOString().slice(0, 10) ===
|
||
new Date(b).toISOString().slice(0, 10);
|
||
|
||
const absorbed =
|
||
targetSchedules.find(
|
||
(s) =>
|
||
s.id !== schedule.id &&
|
||
sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) &&
|
||
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||
) ?? null;
|
||
|
||
// Wagons ride with the train, so every OTHER draft/scheduled schedule on it
|
||
// is affected too — it gains the merged consist but never the bookings.
|
||
const affectedOthers = targetSchedules.filter(
|
||
(s) =>
|
||
s.id !== schedule.id &&
|
||
s.id !== absorbed?.id &&
|
||
TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||
);
|
||
const untouched = targetSchedules.filter(
|
||
(s) =>
|
||
s.id !== schedule.id &&
|
||
s.id !== absorbed?.id &&
|
||
!TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status),
|
||
);
|
||
|
||
// The wagons joining this consist: whatever physically sits on the target
|
||
// train today.
|
||
const incomingWagons = await this.dataSource
|
||
.getRepository(Wagon)
|
||
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
|
||
|
||
// EVERY wagon physically on the source train moves with the merge — not
|
||
// just the ones coupled into this schedule's set. A wagon left behind
|
||
// would strand on the deactivated train. "Loose" = on the train but not
|
||
// backing a set slot; it joins the counts and the capacity math.
|
||
const sourceWagons = sourceTrainId
|
||
? await this.dataSource
|
||
.getRepository(Wagon)
|
||
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
|
||
: [];
|
||
const coupledPhysicalIds = new Set(
|
||
(schedule.trainSet?.wagons ?? [])
|
||
.map((w) => w.physicalWagonId)
|
||
.filter(Boolean),
|
||
);
|
||
const looseSourceWagons = sourceWagons.filter(
|
||
(w) => !coupledPhysicalIds.has(w.id),
|
||
);
|
||
|
||
const movingBookings = absorbed
|
||
? await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||
where: { trainScheduleId: absorbed.id },
|
||
relations: { booking: true },
|
||
})
|
||
: [];
|
||
|
||
return {
|
||
schedule,
|
||
sourceTrainId,
|
||
targetTrain,
|
||
targetSets,
|
||
absorbed,
|
||
affectedOthers,
|
||
untouched,
|
||
incomingWagons,
|
||
looseSourceWagons,
|
||
movingBookings,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Blocking checks, run against the plan. Returns human-readable reasons; an
|
||
* empty array means the merge may proceed. Kept separate from `planMerge` so
|
||
* the preview can SHOW the reasons rather than throwing on them.
|
||
*/
|
||
private async mergeBlockers(
|
||
plan: Awaited<ReturnType<TrainSchedulingService['planMerge']>>,
|
||
): Promise<string[]> {
|
||
const blockers: string[] = [];
|
||
const { schedule, incomingWagons, movingBookings, absorbed } = plan;
|
||
|
||
if (incomingWagons.length === 0) {
|
||
blockers.push(
|
||
`${plan.targetTrain.code} has no wagons to merge — nothing would move.`,
|
||
);
|
||
}
|
||
|
||
// ── Capacity: the merged consist must fit the merged train's locomotives ─
|
||
// Existing side = coupled set slots PLUS loose wagons riding the source
|
||
// train without a slot — they all move, so they all count.
|
||
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
|
||
lengthMeters: Number(w.lengthMeters) || 0,
|
||
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
|
||
cargoTons: 0,
|
||
}));
|
||
const wagonTypeIds = [
|
||
...new Set(
|
||
[...incomingWagons, ...plan.looseSourceWagons]
|
||
.map((w) => w.wagonTypeId)
|
||
.filter(Boolean),
|
||
),
|
||
];
|
||
const wagonTypes = wagonTypeIds.length
|
||
? await this.dataSource
|
||
.getRepository(WagonType)
|
||
.find({ where: { id: In(wagonTypeIds) } })
|
||
: [];
|
||
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
|
||
const slotFromWagon = (w: Wagon) => {
|
||
const t = typeById.get(w.wagonTypeId);
|
||
return {
|
||
lengthMeters: Number(t?.lengthMeters) || 0,
|
||
tareWeightTons: Number(t?.tareWeightTons) || 0,
|
||
cargoTons: 0,
|
||
};
|
||
};
|
||
const incomingSlots = incomingWagons.map(slotFromWagon);
|
||
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
|
||
|
||
// The merged train is pulled by the union of this schedule's locomotives
|
||
// and whatever already pulls the target train (its sets keep their locos).
|
||
// Pull weight adds up across the pool; length stays the tightest cap.
|
||
const locoPool = [
|
||
...this.locomotivesOfTrainSet(schedule.trainSet),
|
||
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
|
||
];
|
||
const limits = combinedLocomotiveLimits([
|
||
...new Map(locoPool.map((l) => [l.id, l])).values(),
|
||
]);
|
||
if (limits) {
|
||
const rules = await this.dataSource
|
||
.getRepository(TrainSchedulingGlobalRules)
|
||
.find({ take: 1 });
|
||
const caps = trainHardCaps(limits, {
|
||
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
|
||
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
|
||
});
|
||
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
|
||
// Merge is a physical consist move, so only the physical axes gate it:
|
||
// can this schedule's locomotives pull the merged weight and length.
|
||
// `schedule.maxWagons` is the booking-window planning ceiling — using it
|
||
// as a slot cap here blocked every merge into a bigger train (e.g. a
|
||
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
|
||
// ceiling to the merged size instead.
|
||
const violations = consistViolations(merged, {
|
||
maxWeightTons: caps.maxWeightTons,
|
||
maxLengthMeters: caps.maxLengthMeters,
|
||
maxWagonSlots: merged.length,
|
||
});
|
||
blockers.push(...violations);
|
||
}
|
||
|
||
// ── Legs: an absorbed booking must be servable by THIS schedule's route ──
|
||
if (absorbed && movingBookings.length) {
|
||
const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null);
|
||
if (routeYardIds.length) {
|
||
const position = new Map(routeYardIds.map((id, i) => [id, i]));
|
||
const slotIds = movingBookings.map((mb) => mb.bookingId);
|
||
const allocations = slotIds.length
|
||
? await this.dataSource.getRepository(WagonBookingAllocation).find({
|
||
where: { bookingId: In(slotIds) },
|
||
relations: { trainSetWagon: true },
|
||
})
|
||
: [];
|
||
const offRoute = new Set<string>();
|
||
for (const alloc of allocations) {
|
||
const board = alloc.trainSetWagon?.boardYardId ?? null;
|
||
const alight = alloc.trainSetWagon?.alightYardId ?? null;
|
||
// Null on both = rides the whole route; always compatible.
|
||
if (!board && !alight) continue;
|
||
const from = board ? position.get(board) : 0;
|
||
const to = alight ? position.get(alight) : routeYardIds.length - 1;
|
||
if (from === undefined || to === undefined || from >= to) {
|
||
offRoute.add(alloc.bookingId);
|
||
}
|
||
}
|
||
if (offRoute.size) {
|
||
blockers.push(
|
||
`${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` +
|
||
'travel legs this schedule\'s route does not serve in the same order.',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
return blockers;
|
||
}
|
||
|
||
/** Ordered yard ids along a route, origin first. Empty when unknown. */
|
||
private async routeYardSequence(routeId: string | null): Promise<string[]> {
|
||
if (!routeId) return [];
|
||
const milestones = await this.dataSource
|
||
.getRepository(RouteMilestone)
|
||
.find({ where: { routeId }, order: { sequenceNo: 'ASC' } });
|
||
return milestones
|
||
.map((m) => m.yardId)
|
||
.filter((id): id is string => Boolean(id));
|
||
}
|
||
|
||
/**
|
||
* What a merge WOULD do, without doing it. Drives the confirmation modal:
|
||
* which schedules gain wagons, which one is absorbed, and why it is blocked.
|
||
*/
|
||
async previewMerge(scheduleId: string, targetTrainId: string) {
|
||
const plan = await this.planMerge(scheduleId, targetTrainId);
|
||
const blockers = await this.mergeBlockers(plan);
|
||
|
||
// Coupled slots plus loose wagons on the source train — everything moves.
|
||
const existingCount =
|
||
(plan.schedule.trainSet?.wagons?.length ?? 0) +
|
||
plan.looseSourceWagons.length;
|
||
return {
|
||
canMerge: blockers.length === 0,
|
||
blockers,
|
||
targetTrain: {
|
||
id: plan.targetTrain.id,
|
||
code: plan.targetTrain.code,
|
||
trainNumber: plan.targetTrain.trainNumber ?? null,
|
||
},
|
||
wagons: {
|
||
current: existingCount,
|
||
incoming: plan.incomingWagons.length,
|
||
merged: existingCount + plan.incomingWagons.length,
|
||
},
|
||
/** The same-day schedule whose bookings move here and is then removed. */
|
||
absorbedSchedule: plan.absorbed
|
||
? {
|
||
id: plan.absorbed.id,
|
||
reference: plan.absorbed.reference ?? null,
|
||
scheduledDepartureDate: plan.absorbed.scheduledDepartureDate,
|
||
status: plan.absorbed.status,
|
||
bookingsMoving: plan.movingBookings.length,
|
||
}
|
||
: null,
|
||
/** Other draft/scheduled schedules on the target — wagons only. */
|
||
affectedSchedules: plan.affectedOthers.map((s) => ({
|
||
id: s.id,
|
||
reference: s.reference ?? null,
|
||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||
status: s.status,
|
||
})),
|
||
/** On the target train but left alone (dispatched, cancelled, …). */
|
||
untouchedSchedules: plan.untouched.map((s) => ({
|
||
id: s.id,
|
||
reference: s.reference ?? null,
|
||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||
status: s.status,
|
||
})),
|
||
sourceTrainWillDeactivate: Boolean(plan.sourceTrainId),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Execute the merge. One transaction: repoint the train set, move the wagons
|
||
* (appended last so the builder can reorder them later), carry the absorbed
|
||
* schedule's bookings across, soft-delete that schedule, and deactivate the
|
||
* emptied source train.
|
||
*/
|
||
async mergeScheduleTrain(
|
||
scheduleId: string,
|
||
dto: MergeScheduleTrainDto,
|
||
): Promise<TrainSchedule> {
|
||
const plan = await this.planMerge(scheduleId, dto.targetTrainId);
|
||
const blockers = await this.mergeBlockers(plan);
|
||
if (blockers.length) {
|
||
throw new BadRequestException(blockers.join(' '));
|
||
}
|
||
|
||
const {
|
||
schedule,
|
||
sourceTrainId,
|
||
targetTrain,
|
||
absorbed,
|
||
incomingWagons,
|
||
movingBookings,
|
||
} = plan;
|
||
const trainSetId = schedule.trainSetId;
|
||
|
||
await this.dataSource.transaction(async (manager) => {
|
||
// 1. This schedule's set now runs on the target train.
|
||
await manager.getRepository(TrainSet).update(trainSetId, {
|
||
trainId: targetTrain.id,
|
||
});
|
||
|
||
// 2. The physical wagons follow the train — the target's stay put, and
|
||
// EVERY wagon on the source train (coupled or loose) moves across so
|
||
// nothing strands on the deactivated train.
|
||
if (incomingWagons.length) {
|
||
await manager.getRepository(Wagon).update(
|
||
{ id: In(incomingWagons.map((w) => w.id)) },
|
||
{ trainId: targetTrain.id },
|
||
);
|
||
}
|
||
if (sourceTrainId) {
|
||
await manager
|
||
.getRepository(Wagon)
|
||
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
|
||
}
|
||
|
||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||
// after the existing wagons. Sequence is provisional — staff reorder
|
||
// in the train builder afterwards.
|
||
const existing = schedule.trainSet?.wagons ?? [];
|
||
let nextSequence =
|
||
existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1;
|
||
const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({
|
||
where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) },
|
||
});
|
||
for (const row of incomingSetWagons) {
|
||
if (row.trainSetId === trainSetId) continue;
|
||
await manager.getRepository(TrainSetWagon).update(row.id, {
|
||
trainSetId,
|
||
sequenceNo: nextSequence,
|
||
});
|
||
nextSequence += 1;
|
||
}
|
||
|
||
// 4. The absorbed schedule's bookings move here. `bookingId` is uniquely
|
||
// indexed, so these rows are UPDATED across rather than re-inserted.
|
||
if (absorbed && movingBookings.length) {
|
||
await manager
|
||
.getRepository(TrainScheduleBooking)
|
||
.update(
|
||
{ trainScheduleId: absorbed.id },
|
||
{ trainScheduleId: schedule.id },
|
||
);
|
||
}
|
||
|
||
// 5. The absorbed schedule is soft-deleted — its bookings still exist and
|
||
// still depart that day, so nobody is notified and nothing is lost.
|
||
if (absorbed) {
|
||
await manager.getRepository(TrainSchedule).softDelete(absorbed.id);
|
||
}
|
||
|
||
// 6. The source train is now empty; park it.
|
||
if (sourceTrainId) {
|
||
await manager.getRepository(Train).update(sourceTrainId, {
|
||
status: Freight.TrainStatus.Deactivated,
|
||
});
|
||
}
|
||
|
||
// 7. Keep the set's cached totals honest.
|
||
const mergedCount =
|
||
(schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length;
|
||
await manager
|
||
.getRepository(TrainSet)
|
||
.update(trainSetId, { wagonCount: mergedCount });
|
||
|
||
// 8. Booking capacity follows the consist: raise (never lower) the
|
||
// planning ceiling so the merged wagons are actually sellable.
|
||
if (mergedCount > (schedule.maxWagons ?? 0)) {
|
||
await manager
|
||
.getRepository(TrainSchedule)
|
||
.update(schedule.id, { maxWagons: mergedCount });
|
||
}
|
||
});
|
||
|
||
this.logger.log(
|
||
`Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` +
|
||
` — ${incomingWagons.length} wagon(s) moved` +
|
||
(absorbed
|
||
? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))`
|
||
: '') +
|
||
(sourceTrainId ? ', source train deactivated' : '') +
|
||
(dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''),
|
||
);
|
||
|
||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||
return fresh ?? schedule;
|
||
}
|
||
}
|